diff --git a/TODO.md b/TODO.md index 34b18db..b2b0eb6 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,25 @@ # TODO +## Setup-script unification (separate PR) + +Collapse the three diverged setup stacks (`macOS/`, `linux-desktop/`, `linux-server/`) +into one root `setup.sh` + one `packages.json`, with platform quirks in `platforms/` +and a shared `lib/core.sh`. Full spec, schema, and phased breakdown in +[UNIFICATION.md](UNIFICATION.md). Decisions locked: single `packages.json` (managers +keyed by `{macos,ubuntu,arch,server}`), root dispatcher + platform modules, incremental +migration gated on dry-run parity. + +- [ ] Phase 1 — Author unified root `packages.json`; build `scripts/parity-check.sh` + proving per-platform install lists match the current per-folder scripts +- [ ] Phase 2 — Extract `lib/core.sh` + `platforms/{macos,arch,ubuntu,server}.sh`; add + root `setup.sh` dispatcher; gate on `--dry-run` parity vs old scripts +- [ ] Phase 3 — Unify `verify.sh` the same way (shared core + platform checks) +- [ ] Phase 4 — Convert per-folder `setup.sh`/`verify.sh` into thin shims; update + `README.md` / `CLAUDE.md` for the root entrypoint +- [ ] Phase 5 — Delete the three old per-folder package JSONs once parity is proven +- [ ] Resolve open questions: server-as-platform vs profile; `install_command` + string vs object; keep `priority: "none"` tier? (see UNIFICATION.md) + ## Ghostty config standardization The ghostty config lives in two places (`macOS/ghostty.config`, `linux-desktop/ghostty.config`) @@ -47,13 +67,24 @@ Port the linux-desktop zsh enhancements to the macOS config. See Test and validate the linux-desktop setup on the personal CachyOS desktop (Arch-based, yay as AUR helper). The package JSON already has Arch support -(`package_manager.arch`, `arch_name` overrides) but nothing has been tested. - -- [ ] Create an Arch-aware setup script (or extend `setup.sh` with distro detection) -- [ ] Verify all `arch_name` overrides resolve to real AUR/pacman packages -- [ ] Test antidote, zsh-notify, eza icons, and zoxide on CachyOS -- [ ] Handle CachyOS defaults that may conflict (e.g., existing fish config) -- [ ] Add personal-only packages: discord, spotify, steam, bolt-launcher, notion +(`package_manager.arch`, `arch_name` overrides). + +- [x] Create an Arch-aware setup script (or extend `setup.sh` with distro detection) + — `setup.sh` auto-detects ubuntu/arch from `/etc/os-release`, bootstraps `yay` + via pacman, and drives repo+AUR installs through `yay` +- [x] Verify all `arch_name` overrides resolve to real AUR/pacman packages + — all resolve; fixed `huggingface-hub` → `python-huggingface-hub`; pyenv/nvm + switched to the curl installers (unified `~/.pyenv` / `~/.nvm` across all OSes) +- [x] Handle CachyOS defaults that may conflict (e.g., existing fish config) + — login shell switch reads the real shell via `getent` and switches fish → zsh; + existing `~/.zshrc` is backed up before replacement +- [x] Add personal-only packages: discord, spotify, steam, bolt-launcher, notion + — present in `linux_desktop_packages.json` with `environment: ["personal"]` +- [x] Test antidote, zsh-notify, eza icons, and zoxide on CachyOS (after first run) + — verified via `verify.sh --work` (52/52); antidote clones plugins on first + zsh launch; zoxide/eza installed. zsh-notify reports "unsupported environment" + over SSH (no graphical session) — expected, works locally. +- [x] Add a `verify.sh` for linux-desktop (mirrors setup.sh selection + runtime checks) - [ ] Test `--personal` flag end-to-end - [ ] Create PR for CachyOS support diff --git a/UNIFICATION.md b/UNIFICATION.md new file mode 100644 index 0000000..02f917b --- /dev/null +++ b/UNIFICATION.md @@ -0,0 +1,258 @@ +# Setup-script unification plan + +Status: **planned — not yet implemented.** This document is the spec for a future +PR. It is intentionally detailed so it can be picked up cold without re-deriving the +analysis below. + +## Goal + +Collapse the three diverged setup stacks (`macOS/`, `linux-desktop/`, +`linux-server/`) into **one main `setup.sh` at the repo root** backed by **one +`packages.json`**, with platform-specific quirks isolated in small modules under +subfolders. Per-folder configs (zshrc, ghostty.config, etc.) stay where they are; +only the *install/orchestration logic and package data* get unified. + +### Locked decisions (agreed before this plan was written) + +1. **Single `packages.json`** at the repo root. `package_manager` becomes an object + keyed by platform: `{macos, ubuntu, arch, server}`. Entries omit keys for + platforms they don't apply to. +2. **Root `setup.sh` + platform modules.** `setup.sh` detects the platform, sources a + shared core (`lib/core.sh`) and a platform module (`platforms/.sh`) for + bootstrap quirks. Per-folder `setup.sh` scripts become thin shims (or are removed). +3. **Incremental migration**, verifying each phase (dry-run parity gates) before the + next. + +## Current state (the problem) + +Three scripts that do the same job with three different schemas: + +| Aspect | `macOS/setup.sh` (323 ln) | `linux-desktop/setup.sh` (644 ln) | `linux-server/setup.sh` (346 ln) | +|---|---|---|---| +| `package_manager` | flat string | **object keyed by distro** (`ubuntu`/`arch`) | flat string | +| Env targeting | `work: true` boolean | `environment: ["work","personal"]` tag array | none | +| Name overrides | none | `ubuntu_name` / `arch_name` (`// .name` fallback) | none | +| Custom installs | `curl` + `custom` w/ `install_command` | `custom` w/ `install_command` | `custom` w/ `install_command` + `handled_by_setup` | +| Output style | `printf` | `echo` | `echo` | +| Flags | `--optional --work --dry-run` | `--optional --work --personal --dry-run --distro` | `--optional --dry-run` | +| Related-scripts footer | yes (mirrored from linux-desktop) | yes (`RELATED_SCRIPTS` array) | no | +| Package count | 59 | 61 | 31 | + +`linux-desktop/setup.sh` is the most evolved and is the **superset model** everything +else migrates onto (`env_filter()`, distro-keyed managers, `_name` overrides, +`RELATED_SCRIPTS`). + +### Manager inventory (current, per platform) + +- macOS: `brew`, `brew-cask`, `app-store`, `curl`, `npm`, `pipx` +- linux-desktop: `apt`, `yay`, `snap`, `curl`, `custom`, `npm`, `pipx` +- linux-server: `apt`, `custom` + +### Field inventory (current) + +- macOS: `name`, `package_manager`, `priority`, `optional`, `description`, `install_command`, `work` +- linux-desktop: `name`, `package_manager`, `priority`, `optional`, `description`, `install_command`, `environment`, `ubuntu_name`, `arch_name` +- linux-server: `name`, `package_manager`, `priority`, `optional`, `description`, `install_command`, `handled_by_setup` + +## Target architecture + +``` +setup.sh # root dispatcher: detect platform → source core + module → run +packages.json # unified package data (single source of truth) +verify.sh # root dispatcher for the read-only health check +lib/ + core.sh # shared engine (see below) +platforms/ + macos.sh # Homebrew bootstrap; brew / brew-cask / app-store handling; mac quirks + arch.sh # yay bootstrap (via pacman); fish→zsh login-shell switch + ubuntu.sh # apt update; snap handling + server.sh # apt; no-GUI profile; SSH hardening / tailscale / docker steps +macOS/ linux-desktop/ linux-server/ + # configs stay (zshrc, ghostty.config, zsh_plugins.txt, homepage/, …) + # each folder's setup.sh/verify.sh become thin shims that exec the root + # script with the right platform/profile, preserving documented usage +``` + +### `lib/core.sh` responsibilities (platform-agnostic) + +- Arg parsing → flags: `--optional --work --personal --dry-run --distro --profile `. +- `run` / `run_eval` dry-run wrappers (unify `printf` vs `echo` on one style — pick `printf`). +- Platform detection (see below); sets `PLATFORM` ∈ {macos, ubuntu, arch, server}. +- `env_filter()` — the linux-desktop logic: packages with no `environment` always + install; `work`/`personal`-tagged ones gate on `--work`/`--personal`. +- `pkg_names(manager, priority)` — jq query honoring `package_manager[$PLATFORM]`, + `_name // .name`, env filter, priority. +- `custom_installs(priority)` — run `install_command[$PLATFORM]` (or string form) for + `custom` packages; respect `handled_by_setup` (auto-run vs print-as-reminder). +- Install loops by priority tier (high → medium → low/optional). +- Shared post-install configure steps: pyenv (`~/.pyenv`), nvm (`~/.nvm`), Ghostty + config deploy, `.zshrc` deploy, GPG signing hookup. +- `print_related_scripts()` from a `RELATED_SCRIPTS` array (already exists in both + desktop scripts). +- App-store reminders (macOS only; no-op elsewhere). + +### `platforms/.sh` responsibilities (quirks only) + +- **macos.sh**: install Homebrew if missing; `expat` before pyenv (Tahoe build fix); + `brew install` / `brew install --cask`; App Store reminder printing; Spark Mail via + App Store (not brew). +- **arch.sh**: bootstrap `yay` via `pacman`; skip already-satisfied pyenv build deps; + fish→zsh login-shell switch via `getent`; harden `yay` batch install (lessons already + encoded in current `linux-desktop/setup.sh` — port verbatim). +- **ubuntu.sh**: `sudo apt update`; `apt install -y`; `snap install`. +- **server.sh**: apt only; exclude GUI/desktop packages; SSH hardening, Tailscale, + Docker, homepage dashboard (see `linux-server/` TODO). + +## Unified `packages.json` schema + +One array of package objects. Field-by-field: + +```jsonc +{ + "name": "zsh", // canonical name; default install token + "package_manager": { // object keyed by platform; omit platforms N/A + "macos": "brew", + "ubuntu": "apt", + "arch": "yay", + "server": "apt" + }, + "macos_name": "…", // optional per-platform install-token override + "ubuntu_name": "…", // (core reads ._name // .name) + "arch_name": "…", + "server_name": "…", + "priority": "high", // high | medium | low | none + "optional": false, // low/none + optional → only with --optional + "environment": ["work", "personal"], // optional; absent ⇒ always installs + "install_command": { // only for manager == "custom"; per-platform + "macos": "curl … | bash", + "ubuntu": "…" + }, + "handled_by_setup": true, // custom pkg: auto-run (true) vs print reminder (false) + "description": "…" +} +``` + +### Migration rules (old → unified) + +1. **`package_manager` string → object.** Wrap each platform's current flat value + under its platform key. macOS `brew`→`{macos:"brew"}`, server `apt`→`{server:"apt"}`, + etc. Merge duplicate logical packages across files into one entry with multiple keys. +2. **`work: true` → `environment: ["work"]`.** (macOS only uses `work`; the array form + from linux-desktop wins.) +3. **Fold `curl` manager into `custom`.** macOS's `curl` entries (nvm) are just custom + installs with an `install_command`; standardize on `custom`. +4. **`install_command` string → object keyed by platform** (parallel to + `package_manager`). Core should accept either a bare string (applies to every + platform whose manager is `custom`) or the object form. nvm's curl command is + cross-platform identical; macOS `mongodb-community` brew-tap command is macOS-only. +5. **Preserve `_name` overrides** already present for ubuntu/arch (e.g. + `huggingface-hub` → arch `python-huggingface-hub`). Add `macos_name`/`server_name` + only where the install token differs from `name`. +6. **Keep `priority` / `optional` / `description` verbatim.** Reconcile descriptions + when merging duplicates (prefer the most informative; note platform caveats inline). +7. **App-store** stays a macOS-only `package_manager.macos: "app-store"` → printed as a + manual reminder, never auto-installed. + +### "server" modeling — OPEN DECISION (resolve in implementation PR) + +The chosen schema lists `server` as a 4th `package_manager` key, but the Pi is +Debian/apt — same manager as `ubuntu`. Two viable models: + +- **(A) Server as a platform key** (as the locked schema implies): explicit + `server: "apt"`; duplicates ubuntu's apt token for shared packages but is unambiguous. +- **(B) Server as a *profile* over the debian/apt platform**: platform stays the + manager dimension (macos/debian/arch); "desktop vs server" is a `--profile` + dimension that filters out GUI packages and adds server-only steps. Less duplication. + +Recommendation: start with **(A)** to honor the locked schema and keep the engine +simple; revisit (B) only if the duplication becomes painful. Either way, GUI/desktop +packages must be excluded on server — model that with an `environment`/profile tag +(e.g. `environment: ["desktop"]` excluded unless `--profile desktop`). + +## Platform detection + +``` +macos → uname -s == Darwin +linux → /etc/os-release: ID/ID_LIKE → ubuntu (debian-like) | arch (arch-like) +server → explicit --profile server (and/or --server); NOT auto-distinguished from + ubuntu by distro alone since both are apt-based +``` + +Reuse the existing `detect_distro()` from `linux-desktop/setup.sh` (handles +ubuntu/debian/mint/pop and arch/cachyos/manjaro/endeavouros/garuda/arcolinux, with +`--distro` override) and extend it with the macOS branch + server profile. + +## Phased work breakdown + +Each phase is independently committable and gated by a verification step. Phases 1–5 +are the future PR(s); Phase 0 is this document. + +- **Phase 0 — Plan (this PR).** Add `UNIFICATION.md` + TODO items. No behavior change. + +- **Phase 1 — Unified data + parity harness.** + - Author root `packages.json` by merging the three files per the migration rules. + - Write `scripts/parity-check.sh`: for every `(platform × priority × flag-combo)`, + compute the install list from the **new** `packages.json` (via the planned core + jq) and diff it against the list the **current** per-folder script computes from + its **old** JSON. **Acceptance: zero diffs** (modulo intentional, documented + changes like curl→custom folding). + - Keep old scripts/JSONs untouched in this phase; the new JSON is validated in + isolation first. + +- **Phase 2 — Engine extraction + root dispatcher.** + - Build `lib/core.sh` and `platforms/{macos,arch,ubuntu,server}.sh` by lifting logic + from `linux-desktop/setup.sh` (the superset) and grafting macOS/server quirks. + - Add root `setup.sh` that detects platform → sources core + module → runs. + - **Acceptance:** `setup.sh --dry-run [flags]` output matches the corresponding old + `/setup.sh --dry-run [flags]` for every platform and flag combination + (cosmetic differences allowed; install/eval lines must match). + +- **Phase 3 — verify.sh unification.** + - Same treatment for `macOS/verify.sh` (183 ln) and `linux-desktop/verify.sh` + (249 ln): shared `verify` core + platform checks; root `verify.sh` dispatcher. + - **Acceptance:** root `verify.sh` reproduces each platform's current check counts + (e.g. linux-desktop's 52/52). + +- **Phase 4 — Shims + docs.** + - Convert `macOS/setup.sh`, `linux-desktop/setup.sh`, `linux-server/setup.sh` (and + the verify scripts) into thin shims that `exec` the root script with the right + platform/profile, so existing READMEs/muscle-memory keep working. + - Update `README.md` and `CLAUDE.md` to document the root entrypoint. + +- **Phase 5 — Cleanup.** + - Once parity is proven and shims are in place, delete the three old per-folder JSONs + (their data now lives in root `packages.json`). Keep configs (zshrc, ghostty, etc.). + +## Verification strategy + +- **Dry-run parity is the primary gate.** This environment can only execute the Arch + path live (CachyOS); brew/apt/snap aren't available here. `--dry-run` prints commands + without executing, so parity can be checked on every platform regardless of host. +- Run `shellcheck` on all new scripts; preserve the `# shellcheck disable=SC2086` + directives where unquoted `$names` word-splitting is intentional (already in the + scripts today). +- Re-run `linux-desktop/verify.sh --work` on real CachyOS after Phase 2/3 to confirm no + regression in the one path that can be fully exercised here. + +## Risks & gotchas to carry forward + +- **Can't run macOS/Ubuntu/server natively here** → lean entirely on dry-run parity + + shellcheck; do not claim live verification for those platforms. +- **`set -e` + pyenv init** aborts setup — already fixed in `linux-desktop/setup.sh` + (commit 3b946c4); port the guard, don't reintroduce the bug. +- **pyenv build deps on Arch** must skip already-satisfied packages (commit 303f803). +- **macOS schema change is the biggest data risk**: `work:true`→`environment` and + string→object managers touch all 59 macOS entries. The parity harness exists + specifically to catch mistakes here. +- **App-store packages are reminders only** — must never end up in an auto-install list. +- **Spark Mail** installs via App Store, not brew (commit 48d03ac) — preserve. + +## Open questions (decide during implementation) + +1. server-as-platform vs server-as-profile (see above) — recommend platform key (A). +2. `install_command` string vs per-platform object — support **both** (string = all + custom platforms; object = per-platform). +3. Keep the `priority: "none"` tier, or fold into `low + optional`? (Currently macOS + uses `none` for octave/qemu/mongodb/Pixelmator/Amphetamine.) +4. Single root `packages.json` vs `packages/` split by domain if the file grows + unwieldy (>150 entries). Start single; revisit only if needed. diff --git a/agentic-ai/Claude/CLAUDE.md b/agentic-ai/Claude/CLAUDE.md index ce19ff8..a7b169a 100644 --- a/agentic-ai/Claude/CLAUDE.md +++ b/agentic-ai/Claude/CLAUDE.md @@ -1,3 +1,52 @@ @rules/common/general.md @rules/common/agents.md -@rules/bash/style.md +@rules/bash/style.md +# Railguard — Active Guardrails + +Railguard is monitoring this session. Every tool call (Bash, Write, Edit, Read) passes through Railguard before execution. + +## What you need to know + +- **Some commands will be blocked.** If you see a "denied" response from a hook, it means Railguard blocked the command. Do NOT retry the same command — find a safer alternative. +- **Some commands require human approval.** If you see an "ask" response, the human will be prompted to approve or deny. +- **File writes are snapshotted.** Every Write/Edit you make is backed up before execution. The human can rollback any change. +- **Everything is logged.** All tool calls and decisions are recorded in `.railguard/traces/`. + +## If something goes wrong + +If the human asks you to undo changes, fix a mistake, or rollback: + +1. **Get context first.** Run: `railguard context --session $SESSION_ID --verbose` + This shows you exactly what changed, diffs, blocked commands, and available rollback commands. + +2. **Check what changed.** Run: `railguard diff --session $SESSION_ID` + Or for a specific file: `railguard diff --session $SESSION_ID --file ` + +3. **Rollback options:** + - Undo the last edit: `railguard rollback --session $SESSION_ID --steps 1` + - Undo the last N edits: `railguard rollback --session $SESSION_ID --steps N` + - Restore a specific file: `railguard rollback --session $SESSION_ID --file ` + - Restore everything: `railguard rollback --session $SESSION_ID` + - Restore a specific snapshot: `railguard rollback --session $SESSION_ID --id ` + +4. **Find your session ID.** Run: `railguard log` + This lists all sessions. Pick the most recent one. + +## Configuring Railguard + +You **can** help the user customize their Railguard policy. This is encouraged: + +- **Read `railguard.yaml`** to understand the current policy. +- **Propose edits to `railguard.yaml`** — the user will be prompted to approve before any change is applied. +- **Run `railguard init`** to generate a starter `railguard.yaml` if one doesn't exist (user approves). +- **Run `railguard status`** to show the current protection state. +All changes to Railguard policy require explicit human approval. You generate the change, the user reviews and accepts or rejects it. Changes take effect on the next tool call — no restart needed. + +## Do NOT attempt to + +- Run `railguard uninstall` — it will be blocked. +- Modify `~/.claude/settings.json` — it will be blocked. +- Remove the railguard binary — it will be blocked. +- Access `~/.ssh`, `~/.aws`, `~/.gnupg`, `/etc`, or other fenced paths (if path fencing is enabled). + + diff --git a/agentic-ai/Claude/README.md b/agentic-ai/Claude/README.md index aa540c5..116ff9c 100644 --- a/agentic-ai/Claude/README.md +++ b/agentic-ai/Claude/README.md @@ -15,6 +15,8 @@ This will: - Symlink `~/.claude/CLAUDE.md` → this `CLAUDE.md` - Symlink `~/.claude/rules/` → this `rules/` - Symlink each `hooks/*.sh` script into `~/.claude/hooks/` +- Install `railguard` via `cargo install railguard` (requires Rust/cargo; skipped if already installed) +- Run `railguard install` to register it as a global PreToolUse hook Restart Claude Code after running. @@ -30,6 +32,15 @@ Claude auto-approves all tool calls without prompting. The hooks below act as th The hooks below are the primary safety layer. +### PreToolUse: `railguard` (all tools) + +Runtime policy enforcer installed globally via `cargo install railguard`. Policy lives in `railguard.yaml`; custom blocklist/allowlist are left empty since `validate-bash.sh` owns those patterns. + +- **Path fence**: denies access to `~/.ssh`, `~/.aws`, `~/.config/gcloud`, `/etc`; explicitly allows `~/.claude` and `/tmp` +- **Traces**: every tool call logged to `.railguard/traces/` +- **Snapshots**: pre-edit state captured for Write/Edit to `.railguard/snapshots/` +- **Memory integrity**: session-start warns on untracked memory files (`railguard memory verify`) + ### PreToolUse: `validate-bash.sh` (Bash) Blocks dangerous or escalation-prone shell commands: - `rm -rf` on `/` or `~` / `$HOME` @@ -51,6 +62,12 @@ After any shell script edit, runs `shellcheck --severity=error`. Exits 2 if erro Skips gracefully if `shellcheck` is not installed. +### PostToolUse: `post-test-runner.sh` (Write / Edit / MultiEdit) + +After any source file edit, auto-detects and runs the project test suite. Detection order: `.claude/test-cmd` override → `Cargo.toml` → `go.mod` → `pyproject.toml`/`pytest.ini` → `package.json` → `Makefile`. Skips non-source extensions (md, json, yaml, etc.) and projects with no recognized test suite. + +Exits 2 on test failure (Claude sees the output) or timeout (60 s). Exits 0 silently on pass, printing timing to stderr. + ### Stop: `driftcheck.sh` At session end, validates project conventions for all git-tracked `.sh` files: - Execute permission set diff --git a/agentic-ai/Claude/TODO.md b/agentic-ai/Claude/TODO.md new file mode 100644 index 0000000..b3b7ac9 --- /dev/null +++ b/agentic-ai/Claude/TODO.md @@ -0,0 +1,31 @@ +# TODO + +## Validate safeguards + +After a fresh install, run each check to confirm hooks and railguard are wired correctly. + +### Hooks + +- [ ] `validate-bash.sh` — blocked patterns exit 2: `rm -rf /`, `sudo`, `git add -A`, `curl | sh`, force-push to main +- [ ] `validate-write.sh` — writes to `~/.ssh/`, `/etc/` blocked (exit 2); project paths allowed (exit 0) +- [ ] `post-edit-shellcheck.sh` — fires after shell script edits and blocks on shellcheck errors +- [ ] `post-test-runner.sh` — auto-detects test suite and runs after source edits; exits 2 on failure +- [ ] `driftcheck.sh` — flags `.sh` files missing execute permission or shebang at session end + +### Railguard + +- [ ] `railguard memory verify` — no integrity warnings at session start +- [ ] Path fence blocks writes to `~/.ssh`, `~/.aws`, `~/.config/gcloud`, `/etc` +- [ ] Traces appear in `.railguard/traces/` after a session +- [ ] Snapshots appear in `.railguard/snapshots/` after Write/Edit calls +- [ ] `railguard-config-edit-2` rule fires on edits to railguard policy content + +## Sandbox + +Revisit `sandbox.enabled` once the upstream seccomp/AF_UNIX issue is resolved. + +- Upstream: [#44180](https://github.com/anthropics/claude-code/issues/44180) — seccomp BPF blocks `AF_UNIX`, breaking `gpg-agent` and `ssh-agent` +- [ ] Re-enable sandbox after upstream fix and test on CachyOS +- [ ] Verify GPG commit signing still works with sandbox on +- [ ] Verify SSH push to GitHub/Bitbucket/Forgejo still works +- [ ] Re-evaluate whether `validate-write.sh` path fence is redundant with sandbox on diff --git a/agentic-ai/Claude/hooks/post-edit-shellcheck.sh b/agentic-ai/Claude/hooks/post-edit-shellcheck.sh index 2592af6..39c93f1 100755 --- a/agentic-ai/Claude/hooks/post-edit-shellcheck.sh +++ b/agentic-ai/Claude/hooks/post-edit-shellcheck.sh @@ -12,6 +12,9 @@ FILE=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // ""') [[ "$FILE" == *.sh ]] || exit 0 [[ -f "$FILE" ]] || exit 0 +# Skip zsh shebangs — only sh/bash/dash/ksh/busybox are supported (SC1071). +[[ "$(head -n1 "$FILE")" == *zsh* ]] && exit 0 + if ! shellcheck --severity=error "$FILE" >&2; then printf '\npost-edit-shellcheck.sh: shellcheck errors in %s — fix before continuing.\n' "$FILE" >&2 exit 2 diff --git a/agentic-ai/Claude/install.sh b/agentic-ai/Claude/install.sh index 4726fad..ccfbbc6 100755 --- a/agentic-ai/Claude/install.sh +++ b/agentic-ai/Claude/install.sh @@ -45,6 +45,34 @@ for hook in "$REPO_DIR/hooks/"*.sh; do printf 'Linked: hooks/%s\n' "$(basename "$hook")" done +# Install railguard binary if not already present +RAILGUARD_BIN="${CARGO_HOME:-$HOME/.cargo}/bin/railguard" +if ! [[ -x "$RAILGUARD_BIN" ]]; then + CARGO_BIN="$(command -v cargo 2>/dev/null || echo "${CARGO_HOME:-$HOME/.cargo}/bin/cargo")" + if ! [[ -x "$CARGO_BIN" ]]; then + printf '\nerror: railguard is not installed and cargo was not found.\n' >&2 + printf ' Install Rust via rustup, then re-run this script:\n' >&2 + printf ' curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs | sh\n' >&2 + exit 1 + fi + printf 'Installing railguard via cargo...\n' + if ! "$CARGO_BIN" install railguard; then + printf '\nerror: cargo install railguard failed — see output above.\n' >&2 + exit 1 + fi + printf 'Installed: railguard\n' +fi + +# Always configure railguard (idempotent; picks up policy changes on re-run) +# railguard install rewrites settings.json with machine-specific absolute paths; +# save and restore it so the repo version (portable ~ paths) wins. +SETTINGS_TMP=$(mktemp) +cp "$REPO_DIR/settings.json" "$SETTINGS_TMP" +printf 'Configuring railguard...\n' +"$RAILGUARD_BIN" install +cp "$SETTINGS_TMP" "$REPO_DIR/settings.json" +rm -f "$SETTINGS_TMP" + # Warn on Ubuntu 24.04+ if the bwrap AppArmor profile isn't set up if grep -qi 'ubuntu' /etc/os-release 2>/dev/null && ! [[ -f /etc/apparmor.d/bwrap ]]; then printf '\n' diff --git a/agentic-ai/Claude/settings.json b/agentic-ai/Claude/settings.json index 0fe588c..5bc04a5 100644 --- a/agentic-ai/Claude/settings.json +++ b/agentic-ai/Claude/settings.json @@ -2,6 +2,9 @@ "permissions": { "defaultMode": "bypassPermissions" }, + "env": { + "CLAUDE_CODE_SHELL": "~/.cargo/bin/railguard-shell" + }, "hooks": { "PreToolUse": [ { diff --git a/agentic-ai/Claude/validate.sh b/agentic-ai/Claude/validate.sh new file mode 100755 index 0000000..bb1585d --- /dev/null +++ b/agentic-ai/Claude/validate.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# Post-install health check for install.sh. +# Run after install.sh to verify all symlinks, hooks, and required tools are in place. +# Exit 0 = all checks passed. Exit 1 = one or more failures. + +set -uo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLAUDE_DIR="$HOME/.claude" +HOOKS_DIR="$CLAUDE_DIR/hooks" +ERRORS=0 + +pass() { printf ' [ OK ] %s\n' "$1"; } +fail() { printf ' [FAIL] %s\n' "$1" >&2; ERRORS=$(( ERRORS + 1 )); } +warn() { printf ' [WARN] %s\n' "$1"; } +section() { printf '\n=== %s ===\n' "$1"; } + +check_symlink() { + local link="$1" expected="$2" + if [[ -L "$link" && "$(readlink "$link")" == "$expected" ]]; then + pass "$link → $expected" + elif [[ -L "$link" ]]; then + fail "$link is a symlink but points to $(readlink "$link") (expected $expected)" + else + fail "$link is missing or not a symlink" + fi +} + +# ── Symlinks ────────────────────────────────────────────────────────────────── +section "Symlinks" +check_symlink "$CLAUDE_DIR/settings.json" "$REPO_DIR/settings.json" +check_symlink "$CLAUDE_DIR/CLAUDE.md" "$REPO_DIR/CLAUDE.md" +check_symlink "$CLAUDE_DIR/rules" "$REPO_DIR/rules" +check_symlink "$HOME/.railguard.yaml" "$REPO_DIR/railguard.yaml" + +# ── Hooks ───────────────────────────────────────────────────────────────────── +section "Hooks" +for hook in "$REPO_DIR/hooks/"*.sh; do + name="$(basename "$hook")" + linked="$HOOKS_DIR/$name" + if ! [[ -L "$linked" && "$(readlink "$linked")" == "$hook" ]]; then + fail "hooks/$name: not linked in $HOOKS_DIR" + continue + fi + if ! [[ -x "$hook" ]]; then + fail "hooks/$name: not executable (run: chmod +x $hook)" + continue + fi + if ! bash -n "$hook" 2>/dev/null; then + fail "hooks/$name: bash syntax error" + continue + fi + pass "hooks/$name" +done + +# ── Required binaries ───────────────────────────────────────────────────────── +section "Required binaries" +check_bin() { + local name="$1" + if bin=$(command -v "$name" 2>/dev/null); then + pass "$name → $bin" + else + fail "$name: not found in PATH" + fi +} +check_bin jq +check_bin shellcheck + +# pytest — post-test-runner.sh fires when pyproject.toml / pytest.ini / setup.py exists. +# If pytest isn't on PATH the hook exits non-zero and Claude sees a spurious failure. +section "pytest (post-test-runner)" +if command -v pytest &>/dev/null; then + pass "pytest → $(command -v pytest)" +elif python3 -m pytest --version &>/dev/null 2>&1; then + warn "pytest binary not on PATH but 'python3 -m pytest' works — post-test-runner.sh uses bare 'pytest' and will fail in Python projects" + warn "Fix: add your venv/bin to PATH before starting Claude Code, or change TEST_CMD to 'python3 -m pytest' via .claude/test-cmd" +else + warn "pytest not found (neither 'pytest' binary nor 'python3 -m pytest')" + warn "post-test-runner.sh will skip Python projects until pytest is installed or a .claude/test-cmd override exists" +fi + +# ── settings.json ───────────────────────────────────────────────────────────── +section "settings.json" +SETTINGS="$REPO_DIR/settings.json" +if ! jq . "$SETTINGS" >/dev/null 2>&1; then + fail "settings.json: invalid JSON" +else + pass "settings.json: valid JSON" + jq -e '.hooks.PreToolUse[] | select(.hooks[]?.command | strings | test("railguard hook"))' \ + "$SETTINGS" >/dev/null 2>&1 \ + && pass "railguard PreToolUse hook registered" \ + || fail "railguard PreToolUse hook missing from settings.json" + jq -e '.hooks.PostToolUse[] | select(.hooks[]?.command | strings | test("railguard hook"))' \ + "$SETTINGS" >/dev/null 2>&1 \ + && pass "railguard PostToolUse hook registered" \ + || fail "railguard PostToolUse hook missing from settings.json" + jq -e '.hooks.Stop[] | select(.hooks[]?.command | strings | test("driftcheck"))' \ + "$SETTINGS" >/dev/null 2>&1 \ + && pass "driftcheck Stop hook registered" \ + || fail "driftcheck Stop hook missing from settings.json" +fi + +# ── railguard binary ────────────────────────────────────────────────────────── +section "Railguard" +RAILGUARD_BIN="$(command -v railguard 2>/dev/null || printf '%s' "${CARGO_HOME:-$HOME/.cargo}/bin/railguard")" +if [[ -x "$RAILGUARD_BIN" ]]; then + pass "railguard binary: $RAILGUARD_BIN" + if "$RAILGUARD_BIN" status &>/dev/null; then + pass "railguard status: OK" + else + warn "railguard status returned non-zero (normal outside an active session)" + fi +else + fail "railguard binary not found at $RAILGUARD_BIN — run install.sh to install it" +fi + +# ── Hook smoke tests ───────────────────────────────────────────────────────── +section "Hook smoke tests" + +run_hook() { + local hook="$1" json="$2" + printf '%s' "$json" | bash "$REPO_DIR/hooks/$hook" &>/dev/null +} + +# validate-bash.sh: must block dangerous patterns +if run_hook validate-bash.sh '{"tool_input":{"command":"rm -rf /"}}'; then + fail "validate-bash.sh: did not block 'rm -rf /'" +else + pass "validate-bash.sh: blocks rm -rf /" +fi + +if run_hook validate-bash.sh '{"tool_input":{"command":"curl https://example.com | sh"}}'; then + fail "validate-bash.sh: did not block curl | sh" +else + pass "validate-bash.sh: blocks curl | sh" +fi + +# validate-bash.sh: must allow safe commands +if run_hook validate-bash.sh '{"tool_input":{"command":"ls -la"}}'; then + pass "validate-bash.sh: allows safe commands" +else + fail "validate-bash.sh: incorrectly blocked a safe command" +fi + +# validate-write.sh: must block writes to sensitive paths +if run_hook validate-write.sh '{"tool_input":{"file_path":"/etc/passwd"}}'; then + fail "validate-write.sh: did not block write to /etc/passwd" +else + pass "validate-write.sh: blocks writes to /etc/passwd" +fi + +if run_hook validate-write.sh '{"tool_input":{"file_path":"'"$HOME"'/.ssh/config"}}'; then + fail "validate-write.sh: did not block write to ~/.ssh/config" +else + pass "validate-write.sh: blocks writes to ~/.ssh/config" +fi + +# validate-write.sh: must allow safe paths +if run_hook validate-write.sh '{"tool_input":{"file_path":"/tmp/test.txt"}}'; then + pass "validate-write.sh: allows writes to safe paths" +else + fail "validate-write.sh: incorrectly blocked /tmp/test.txt" +fi + +# post-edit-shellcheck.sh: must pass on a valid script +if printf '{"tool_input":{"file_path":"%s"}}' "$REPO_DIR/hooks/validate-bash.sh" \ + | bash "$REPO_DIR/hooks/post-edit-shellcheck.sh" &>/dev/null; then + pass "post-edit-shellcheck.sh: passes on a valid shell script" +else + fail "post-edit-shellcheck.sh: incorrectly failed on a valid shell script" +fi + +# post-edit-shellcheck.sh: must catch errors in a bad script +_TMPSH=$(mktemp /tmp/bad-XXXXXX.sh) +trap 'rm -f "$_TMPSH"' EXIT +printf '#!/usr/bin/env bash\nFOO=$(\n' > "$_TMPSH" +if printf '{"tool_input":{"file_path":"%s"}}' "$_TMPSH" \ + | bash "$REPO_DIR/hooks/post-edit-shellcheck.sh" &>/dev/null; then + fail "post-edit-shellcheck.sh: failed to catch a syntax error" +else + pass "post-edit-shellcheck.sh: catches shell syntax errors" +fi +rm -f "$_TMPSH"; trap - EXIT + +# driftcheck.sh: must pass on the current repo state +if (cd "$REPO_DIR" && bash "$REPO_DIR/hooks/driftcheck.sh" &>/dev/null); then + pass "driftcheck.sh: no convention violations in repo" +else + fail "driftcheck.sh: convention violations found — run hooks/driftcheck.sh directly for details" +fi + +# ── Summary ─────────────────────────────────────────────────────────────────── +printf '\n' +if [[ $ERRORS -eq 0 ]]; then + printf 'All checks passed.\n' +else + printf '%d check(s) failed. Re-run install.sh or fix the issues above.\n' "$ERRORS" >&2 + exit 1 +fi diff --git a/linux-desktop/README.md b/linux-desktop/README.md index c71b911..e4abf15 100644 --- a/linux-desktop/README.md +++ b/linux-desktop/README.md @@ -2,6 +2,35 @@ Tested on CachyOS (Arch-based). Most packages should work on other Arch or Debian-based distros with minor adjustments. +## Setup + +```bash +bash linux-desktop/setup.sh [--work] [--personal] [--optional] [--dry-run] +``` + +`setup.sh` auto-detects the distro from `/etc/os-release`: + +- **Arch / CachyOS** (and Manjaro, EndeavourOS, …) — installs via `yay` (repo + AUR), + bootstrapping `yay` with `pacman` if it's missing. +- **Ubuntu / Debian** — installs via `apt` + `snap`, adding the eza / fastfetch / GitHub CLI repos as needed. + +Either way, `pyenv` and `nvm` install via their official curl scripts (`~/.pyenv`, `~/.nvm`), +the login shell is switched to `zsh`, and the `zshrc` / antidote / Ghostty configs are +copied into place. Use `--dry-run` to preview every command without changing anything. + +Package definitions and per-distro names live in +[`linux_desktop_packages.json`](linux_desktop_packages.json). + +After running, verify the result (read-only — installs nothing): + +```bash +bash linux-desktop/verify.sh [--work] [--personal] [--optional] [--all] +``` + +`verify.sh` mirrors `setup.sh`'s selection logic, so passing the same flags checks +exactly what that install should have produced, plus runtime checks (login shell is +zsh, pyenv Python, nvm Node, antidote, configs, tailscaled). + ## Gaming 1. Bolt | [Codeberg](https://codeberg.org/Adamcake/Bolt) diff --git a/linux-desktop/linux_desktop_packages.json b/linux-desktop/linux_desktop_packages.json index 3ed75ec..f97fb86 100644 --- a/linux-desktop/linux_desktop_packages.json +++ b/linux-desktop/linux_desktop_packages.json @@ -22,16 +22,16 @@ }, { "name": "pyenv", - "package_manager": { "ubuntu": "curl", "arch": "yay" }, - "install_command": { "ubuntu": "curl https://pyenv.run | bash" }, + "package_manager": { "ubuntu": "curl", "arch": "curl" }, + "install_command": { "ubuntu": "curl https://pyenv.run | bash", "arch": "curl https://pyenv.run | bash" }, "priority": "high", "optional": false, "description": "Python version manager — compiles from source to avoid distro-packaged Python quirks" }, { "name": "nvm", - "package_manager": { "ubuntu": "curl", "arch": "yay" }, - "install_command": { "ubuntu": "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash" }, + "package_manager": { "ubuntu": "curl", "arch": "curl" }, + "install_command": { "ubuntu": "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash", "arch": "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash" }, "priority": "high", "optional": false, "description": "Node version manager — installed via curl on Ubuntu per nvm recommendation" @@ -52,6 +52,14 @@ "optional": false, "description": "Compiler toolchain — gcc, make, etc.; required for pyenv, native Node modules, and AUR builds" }, + { + "name": "rust", + "package_manager": { "ubuntu": "apt", "arch": "yay" }, + "ubuntu_name": "rustup", + "priority": "medium", + "optional": false, + "description": "Rust toolchain — rustc and cargo" + }, { "name": "git", "package_manager": { "ubuntu": "apt", "arch": "yay" }, @@ -89,6 +97,20 @@ "optional": false, "description": "Git extension for versioning large binary files" }, + { + "name": "pre-commit", + "package_manager": { "ubuntu": "pipx", "arch": "yay" }, + "priority": "medium", + "optional": false, + "description": "Git pre-commit hook framework — runs this repo's shellcheck, JSON, and zsh syntax checks before each commit" + }, + { + "name": "shellcheck", + "package_manager": { "ubuntu": "apt", "arch": "yay" }, + "priority": "medium", + "optional": false, + "description": "Static analysis linter for shell scripts — used by the pre-commit config and CI" + }, { "name": "gnupg", "package_manager": { "ubuntu": "apt", "arch": "yay" }, @@ -327,7 +349,7 @@ }, { "name": "codeburn", - "package_manager": { "ubuntu": "npm", "arch": "npm" }, + "package_manager": { "ubuntu": "pnpm", "arch": "pnpm" }, "priority": "medium", "optional": false, "description": "Track AI coding token usage by task, tool, model, and project" @@ -335,7 +357,7 @@ { "name": "hf", "package_manager": { "ubuntu": "pipx", "arch": "yay" }, - "arch_name": "huggingface-hub", + "arch_name": "python-huggingface-hub", "install_command": { "ubuntu": "pipx install huggingface-hub[cli]" }, "priority": "medium", "optional": false, diff --git a/linux-desktop/setup.sh b/linux-desktop/setup.sh index 9b45170..4292f5d 100755 --- a/linux-desktop/setup.sh +++ b/linux-desktop/setup.sh @@ -1,10 +1,15 @@ #!/usr/bin/env bash -# Linux desktop initial setup script (Ubuntu) -# Usage: bash linux-desktop/setup.sh [--optional] [--work] [--personal] [--dry-run] -# --optional also install low-priority optional packages -# --work also install work-only packages (Slack, Zoom, Teams, Chromium) -# --personal also install personal-only packages (Discord, VLC, Spotify, Steam) -# --dry-run print all commands without executing anything +# Linux desktop initial setup script (Ubuntu / Arch) +# Usage: bash linux-desktop/setup.sh [--optional] [--work] [--personal] [--dry-run] [--distro ] +# --optional also install low-priority optional packages +# --work also install work-only packages (Slack, Zoom, Teams, Chromium) +# --personal also install personal-only packages (Discord, VLC, Spotify, Steam) +# --dry-run print all commands without executing anything +# --distro force distro family (ubuntu|arch); default: auto-detect from /etc/os-release +# +# Distro support: +# ubuntu → apt + snap + curl installers +# arch → yay (repo + AUR) + curl installers (CachyOS, Arch, Manjaro, EndeavourOS, …) # # Environment filtering: # No flags → installs packages with no environment tag (shared across all) @@ -16,21 +21,67 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PACKAGES_JSON="$SCRIPT_DIR/linux_desktop_packages.json" -DISTRO="ubuntu" + +# Other repo setup scripts surfaced at the end of the run. Each entry is +# "|". To advertise a new +# script, just add a line here — only scripts that exist on disk are shown. +RELATED_SCRIPTS=( + "agentic-ai/Claude/install.sh|Claude Code config — symlink settings, hooks, and CLAUDE.md into ~/.claude" + "SSH_and_GPG/create_ssh_key.sh|Generate an SSH key (and add it to GitHub)" + "SSH_and_GPG/create_gpg_key.sh|Generate a GPG key for signed commits" + "linux-desktop/verify.sh|Verify this install (read-only health check)" +) + +DISTRO="" INCLUDE_OPTIONAL=false INCLUDE_WORK=false INCLUDE_PERSONAL=false DRY_RUN=false -for arg in "$@"; do - case "$arg" in +while [[ $# -gt 0 ]]; do + case "$1" in --optional) INCLUDE_OPTIONAL=true ;; --work) INCLUDE_WORK=true ;; --personal) INCLUDE_PERSONAL=true ;; --dry-run) DRY_RUN=true ;; + --distro) DISTRO="${2:-}"; shift ;; + *) echo "Unknown argument: $1" >&2 ;; esac + shift done +# ── Distro detection ─────────────────────────────────────────────────────────── +detect_distro() { + [[ -n "$DISTRO" ]] && return + local id="" id_like="" + if [[ -r /etc/os-release ]]; then + # shellcheck disable=SC1091 + id="$(. /etc/os-release && echo "${ID:-}")" + # shellcheck disable=SC1091 + id_like="$(. /etc/os-release && echo "${ID_LIKE:-}")" + fi + case "$id" in + ubuntu|debian|linuxmint|pop) DISTRO="ubuntu" ;; + arch|cachyos|manjaro|endeavouros|garuda|arcolinux) DISTRO="arch" ;; + *) + case "$id_like" in + *debian*|*ubuntu*) DISTRO="ubuntu" ;; + *arch*) DISTRO="arch" ;; + *) + echo "ERROR: Unsupported distro (ID='$id' ID_LIKE='$id_like')." >&2 + echo " Re-run with --distro ubuntu|arch to override." >&2 + exit 1 ;; + esac ;; + esac +} +detect_distro + +if [[ "$DISTRO" != "ubuntu" && "$DISTRO" != "arch" ]]; then + echo "ERROR: --distro must be 'ubuntu' or 'arch' (got '$DISTRO')." >&2 + exit 1 +fi +echo "==> Detected distro family: $DISTRO" + # ── Helpers ─────────────────────────────────────────────────────────────────── run() { @@ -49,6 +100,71 @@ run_eval() { fi } +# npm supply-chain cooldown: refuse to install package versions younger than +# NPM_MIN_RELEASE_AGE days. Compromised releases of popular packages (e.g. the +# axios RAT, Mar 2026) are typically caught and yanked within hours, so a short +# cooldown blocks them while barely delaying legit updates. See issue #23. +NPM_MIN_RELEASE_AGE=7 + +configure_npm_cooldown() { + local npmrc="$HOME/.npmrc" key="min-release-age" tmp + if [[ "$DRY_RUN" == true ]]; then + echo " [dry-run] set $key=$NPM_MIN_RELEASE_AGE in $npmrc" + return 0 + fi + touch "$npmrc" + # Drop any existing entry, then append the desired value (idempotent, portable). + tmp="$(mktemp)" + grep -v "^${key}=" "$npmrc" > "$tmp" 2>/dev/null || true + printf '%s=%s\n' "$key" "$NPM_MIN_RELEASE_AGE" >> "$tmp" + mv "$tmp" "$npmrc" + echo " ✓ npm $key=$NPM_MIN_RELEASE_AGE (${NPM_MIN_RELEASE_AGE}-day supply-chain cooldown)" +} + +# Enable pnpm (our daily-driver package manager) via corepack — corepack ships +# with Node, so no extra download is gated by the npm cooldown above — and apply +# the same supply-chain cooldown. pnpm measures minimumReleaseAge in MINUTES; it +# also enforces the value strictly only when set explicitly (the built-in 1-day +# default is non-strict), so we set it on purpose. See issue #23. +configure_pnpm() { + local age_minutes=$((NPM_MIN_RELEASE_AGE * 24 * 60)) + if [[ "$DRY_RUN" == true ]]; then + echo " [dry-run] corepack enable && corepack prepare pnpm@latest --activate" + echo " [dry-run] pnpm config set minimumReleaseAge $age_minutes --location=user" + return 0 + fi + if ! command -v corepack &>/dev/null; then + echo " ⚠ corepack not found (needs Node ≥16) — skipping pnpm setup" + return 0 + fi + corepack enable + corepack prepare pnpm@latest --activate + export PNPM_HOME="$HOME/.local/share/pnpm" + export PATH="$PNPM_HOME/bin:$PATH" + if command -v pnpm &>/dev/null; then + pnpm config set minimumReleaseAge "$age_minutes" --location=user + echo " ✓ pnpm enabled, minimumReleaseAge=$age_minutes min (${NPM_MIN_RELEASE_AGE}-day cooldown)" + else + echo " ⚠ pnpm not on PATH after corepack — skipped cooldown config" + fi +} + +# Echo the other repo setup scripts (those present on disk) for discoverability. +print_related_scripts() { + local repo_root entry rel desc shown=false + repo_root="$(cd "$SCRIPT_DIR/.." && pwd)" + for entry in "${RELATED_SCRIPTS[@]}"; do + rel="${entry%%|*}"; desc="${entry#*|}" + [[ -f "$repo_root/$rel" ]] || continue + if [[ "$shown" == false ]]; then + echo " Other setup scripts in this repo:" + shown=true + fi + echo " • $desc" + echo " bash $rel" + done +} + # Build a jq filter for environment tags. # Packages with no environment field always install. # Packages with environment=["work"] only install if --work is passed, etc. @@ -63,7 +179,7 @@ env_filter() { JQEOF } -# Query packages by distro package manager and priority, respecting environment + ubuntu_name +# Query packages by distro package manager and priority, respecting environment + _name pkg_names() { local manager="$1" priority="$2" local ef @@ -115,6 +231,24 @@ apt_install() { [[ -n "$names" ]] && run sudo apt install -y $names } +# Install repo + AUR packages on Arch via yay (handles both uniformly). +# If the batch fails (e.g. a single AUR build breaks), retry one package at a +# time so one failure can't abort the whole run (and skip shell/config steps). +yay_install() { + local priority="$1" + local names + names=$(pkg_names "yay" "$priority") + [[ -z "$names" ]] && return 0 + # shellcheck disable=SC2086 + if ! run yay -S --needed --noconfirm $names; then + echo " batch install errored — retrying packages individually..." + local pkg + for pkg in $names; do + run yay -S --needed --noconfirm "$pkg" || echo " ✗ failed: $pkg" + done + fi +} + snap_install() { local priority="$1" local ef @@ -173,78 +307,103 @@ pipx_install() { done } -npm_install() { +pnpm_install() { local priority="$1" local ef names ef=$(env_filter) names=$(jq -r --arg p "$priority" --arg d "$DISTRO" \ "[.[] | select( - .package_manager[\$d] == \"npm\" and + .package_manager[\$d] == \"pnpm\" and .priority == \$p and $ef ) | .name] | join(\" \")" \ "$PACKAGES_JSON") # shellcheck disable=SC2086 - [[ -n "$names" ]] && run npm install -g $names + [[ -n "$names" ]] && run pnpm add -g $names } -# ── apt update ─────────────────────────────────────────────────────────────── -echo "==> Updating package list..." -run sudo apt update +# Bootstrap the AUR helper on Arch (yay lives in the CachyOS/Arch repos, so pacman +# installs it directly — the one place pacman is required instead of yay). +ensure_yay() { + if ! command -v yay &>/dev/null; then + echo "==> Bootstrapping yay (via pacman)..." + run sudo pacman -S --needed --noconfirm base-devel git yay + else + echo "==> yay already installed ($(yay --version 2>/dev/null | head -1))" + fi +} -# ── Bootstrap: jq ──────────────────────────────────────────────────────────── -if ! command -v jq &>/dev/null; then - echo "==> Bootstrapping jq..." - run sudo apt install -y jq -fi +# ── Package-manager bootstrap ──────────────────────────────────────────────── +if [[ "$DISTRO" == "ubuntu" ]]; then + echo "==> Updating package list..." + run sudo apt update -# ── External apt repos (idempotent) ────────────────────────────────────────── -NEED_UPDATE=false + # Bootstrap jq + if ! command -v jq &>/dev/null; then + echo "==> Bootstrapping jq..." + run sudo apt install -y jq + fi -# fastfetch — not in standard Ubuntu repos -if ! apt-cache show fastfetch &>/dev/null 2>&1; then - echo "==> Adding fastfetch PPA..." - run sudo add-apt-repository -y ppa:zhangsongcui3371/fastfetch - NEED_UPDATE=true -fi + # External apt repos (idempotent) + NEED_UPDATE=false -# eza — not in standard Ubuntu repos -if ! apt-cache show eza &>/dev/null 2>&1; then - echo "==> Adding eza apt repo..." - if [[ "$DRY_RUN" != true ]]; then - sudo apt install -y gpg - wget -qO- https://raw.githubusercontent.com/eza-community/eza/main/deb.asc \ - | sudo gpg --dearmor -o /etc/apt/keyrings/gierens.gpg - echo "deb [signed-by=/etc/apt/keyrings/gierens.gpg] http://deb.gierens.de stable main" \ - | sudo tee /etc/apt/sources.list.d/gierens.list > /dev/null - sudo chmod 644 /etc/apt/keyrings/gierens.gpg /etc/apt/sources.list.d/gierens.list - else - echo " [dry-run] add eza community apt repo" + # fastfetch — not in standard Ubuntu repos + if ! apt-cache show fastfetch &>/dev/null 2>&1; then + echo "==> Adding fastfetch PPA..." + run sudo add-apt-repository -y ppa:zhangsongcui3371/fastfetch + NEED_UPDATE=true fi - NEED_UPDATE=true -fi -# gh — GitHub CLI apt repo -if ! command -v gh &>/dev/null; then - echo "==> Adding GitHub CLI apt repo..." - if [[ "$DRY_RUN" != true ]]; then - wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg \ - | sudo gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg - sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ - | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null - else - echo " [dry-run] add GitHub CLI apt repo" + # eza — not in standard Ubuntu repos + if ! apt-cache show eza &>/dev/null 2>&1; then + echo "==> Adding eza apt repo..." + if [[ "$DRY_RUN" != true ]]; then + sudo apt install -y gpg + wget -qO- https://raw.githubusercontent.com/eza-community/eza/main/deb.asc \ + | sudo gpg --dearmor -o /etc/apt/keyrings/gierens.gpg + echo "deb [signed-by=/etc/apt/keyrings/gierens.gpg] http://deb.gierens.de stable main" \ + | sudo tee /etc/apt/sources.list.d/gierens.list > /dev/null + sudo chmod 644 /etc/apt/keyrings/gierens.gpg /etc/apt/sources.list.d/gierens.list + else + echo " [dry-run] add eza community apt repo" + fi + NEED_UPDATE=true fi - NEED_UPDATE=true -fi -[[ "$NEED_UPDATE" == true ]] && run sudo apt update + # gh — GitHub CLI apt repo + if ! command -v gh &>/dev/null; then + echo "==> Adding GitHub CLI apt repo..." + if [[ "$DRY_RUN" != true ]]; then + wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | sudo gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg + sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null + else + echo " [dry-run] add GitHub CLI apt repo" + fi + NEED_UPDATE=true + fi -# ── High-priority apt packages ─────────────────────────────────────────────── + [[ "$NEED_UPDATE" == true ]] && run sudo apt update +else + # Arch: yay covers repo + AUR, so no external repos needed. + ensure_yay + if ! command -v jq &>/dev/null; then + echo "==> Bootstrapping jq..." + run sudo pacman -S --needed --noconfirm jq + fi +fi + +# ── High-priority packages ─────────────────────────────────────────────────── echo "" -echo "==> Installing high-priority apt packages..." -apt_install "high" +if [[ "$DISTRO" == "ubuntu" ]]; then + echo "==> Installing high-priority apt packages..." + apt_install "high" +else + echo "==> Installing high-priority packages (yay)..." + yay_install "high" +fi # ── pyenv ──────────────────────────────────────────────────────────────────── PYTHON_VERSION="3.12.13" @@ -267,11 +426,29 @@ else export PYENV_ROOT="$HOME/.pyenv" export PATH="$PYENV_ROOT/bin:$PATH" if command -v pyenv &>/dev/null; then - eval "$(pyenv init -)" + # Force bash output — pyenv otherwise detects the shell from $SHELL and may + # emit fish/zsh syntax that this bash script's eval rejects (aborts under set -e). + eval "$(pyenv init - bash)" if ! pyenv versions 2>/dev/null | grep -q "$PYTHON_VERSION"; then echo "==> Installing pyenv build dependencies..." - sudo apt install -y libssl-dev libffi-dev libncurses-dev libreadline-dev \ - libbz2-dev libsqlite3-dev liblzma-dev zlib1g-dev tk-dev + if [[ "$DISTRO" == "ubuntu" ]]; then + sudo apt install -y libssl-dev libffi-dev libncurses-dev libreadline-dev \ + libbz2-dev libsqlite3-dev liblzma-dev zlib1g-dev tk-dev + else + # Only install deps not already satisfied. CachyOS ships zlib-ng-compat + # (which provides zlib), so explicitly requesting the zlib package would + # conflict; pacman -T treats it as already satisfied and skips it. + _pyenv_deps=(base-devel openssl zlib xz tk) + _pyenv_need=() + for _dep in "${_pyenv_deps[@]}"; do + pacman -T "$_dep" >/dev/null 2>&1 || _pyenv_need+=("$_dep") + done + if [[ ${#_pyenv_need[@]} -gt 0 ]]; then + sudo pacman -S --needed --noconfirm "${_pyenv_need[@]}" + else + echo " build deps already satisfied" + fi + fi echo "==> Installing Python $PYTHON_VERSION via pyenv..." pyenv install "$PYTHON_VERSION" pyenv global "$PYTHON_VERSION" @@ -287,6 +464,8 @@ if [[ "$DRY_RUN" == true ]]; then echo "==> nvm..." echo " [dry-run] install nvm via curl" echo " [dry-run] nvm install lts/* && nvm alias default lts/*" + configure_npm_cooldown + configure_pnpm else if [ ! -d "$HOME/.nvm" ]; then echo "==> Installing nvm..." @@ -305,44 +484,59 @@ else nvm alias default 'lts/*' nvm use default fi + + # Apply the supply-chain cooldown before any package install runs below. + configure_npm_cooldown + configure_pnpm fi -# ── Medium-priority apt packages ───────────────────────────────────────────── +# ── Medium-priority packages ───────────────────────────────────────────────── echo "" -echo "==> Installing medium-priority apt packages..." -apt_install "medium" +if [[ "$DISTRO" == "ubuntu" ]]; then + echo "==> Installing medium-priority apt packages..." + apt_install "medium" -# ── Medium-priority snap packages ──────────────────────────────────────────── -echo "" -echo "==> Installing snap packages..." -snap_install "medium" + echo "" + echo "==> Installing snap packages..." + snap_install "medium" -# ── bat / fd-find symlinks ─────────────────────────────────────────────────── -echo "" -echo "==> Setting up bat and fd symlinks..." -for pair in "batcat bat" "fdfind fd"; do - src="${pair%% *}"; dst="${pair##* }" - if command -v "$src" &>/dev/null && ! command -v "$dst" &>/dev/null; then - run sudo ln -sf "$(command -v "$src")" /usr/local/bin/"$dst" - echo " linked $dst → $src" - else - echo " ✓ $dst" - fi -done + # bat / fd-find symlinks (Ubuntu installs them as batcat / fdfind) + echo "" + echo "==> Setting up bat and fd symlinks..." + for pair in "batcat bat" "fdfind fd"; do + src="${pair%% *}"; dst="${pair##* }" + if command -v "$src" &>/dev/null && ! command -v "$dst" &>/dev/null; then + run sudo ln -sf "$(command -v "$src")" /usr/local/bin/"$dst" + echo " linked $dst → $src" + else + echo " ✓ $dst" + fi + done +else + echo "==> Installing medium-priority packages (yay)..." + yay_install "medium" + # Arch ships bat and fd under their real names — no symlinks needed. +fi # ── Tailscale ──────────────────────────────────────────────────────────────── echo "" -if [[ "$DRY_RUN" == true ]]; then - command -v tailscale &>/dev/null \ - && echo "==> Tailscale already installed" \ - || echo " [dry-run] eval: curl -fsSL https://tailscale.com/install.sh | sudo sh" -elif ! command -v tailscale &>/dev/null; then - echo "==> Installing Tailscale..." - custom_installs "medium" | while IFS='|' read -r name cmd; do - [[ "$name" == "tailscale" ]] && eval "$cmd" - done +if [[ "$DISTRO" == "ubuntu" ]]; then + if [[ "$DRY_RUN" == true ]]; then + command -v tailscale &>/dev/null \ + && echo "==> Tailscale already installed" \ + || echo " [dry-run] eval: curl -fsSL https://tailscale.com/install.sh | sudo sh" + elif ! command -v tailscale &>/dev/null; then + echo "==> Installing Tailscale..." + custom_installs "medium" | while IFS='|' read -r name cmd; do + [[ "$name" == "tailscale" ]] && eval "$cmd" + done + else + echo "==> Tailscale already installed ($(tailscale version | head -1))" + fi else - echo "==> Tailscale already installed ($(tailscale version | head -1))" + # Arch: tailscale was installed in the yay medium batch; just enable the daemon. + echo "==> Enabling tailscaled service..." + run sudo systemctl enable --now tailscaled fi # ── claude-code ────────────────────────────────────────────────────────────── @@ -361,6 +555,7 @@ else fi # ── pipx packages ──────────────────────────────────────────────────────────── +# (No-op on Arch: poetry/hf are installed via yay there; nothing maps to pipx.) echo "" echo "==> Installing pipx packages..." if command -v pipx &>/dev/null; then @@ -372,21 +567,31 @@ fi # ── npm packages ───────────────────────────────────────────────────────────── echo "" -echo "==> Installing npm packages..." +echo "==> Installing pnpm packages..." export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" -if command -v npm &>/dev/null; then - npm_install "medium" +export PNPM_HOME="$HOME/.local/share/pnpm" +export PATH="$PNPM_HOME/bin:$PATH" +if command -v pnpm &>/dev/null; then + pnpm_install "medium" else - echo " npm not found — skipping" + echo " pnpm not found — skipping" fi # ── zsh as default shell ───────────────────────────────────────────────────── +# Use the real login shell from /etc/passwd — $SHELL can be an inherited +# interactive shell (e.g. zsh launched over an SSH session whose login shell is fish). ZSH_BIN="$(command -v zsh 2>/dev/null || true)" -if [[ -n "$ZSH_BIN" && "$SHELL" != "$ZSH_BIN" ]]; then - echo "" - echo "==> Setting zsh as default shell..." - run sudo usermod -s "$ZSH_BIN" "$USER" +if [[ -n "$ZSH_BIN" ]]; then + CURRENT_SHELL="$(getent passwd "$USER" | cut -d: -f7)" + if [[ "$CURRENT_SHELL" != "$ZSH_BIN" ]]; then + echo "" + echo "==> Setting zsh as default shell (was: ${CURRENT_SHELL:-unknown})..." + run sudo usermod -s "$ZSH_BIN" "$USER" + else + echo "" + echo "==> zsh already the default login shell" + fi fi # ── zshrc ──────────────────────────────────────────────────────────────────── @@ -403,6 +608,20 @@ else echo "==> ~/.zshrc already up to date" fi +# ── tmux config ─────────────────────────────────────────────────────────────── +echo "" +if [[ ! -f "$HOME/.tmux.conf" ]]; then + run cp "$SCRIPT_DIR/tmux.conf" "$HOME/.tmux.conf" + echo "==> ~/.tmux.conf installed from tmux.conf" +elif ! diff -q "$SCRIPT_DIR/tmux.conf" "$HOME/.tmux.conf" &>/dev/null; then + TMUX_BACKUP="$HOME/.tmux.conf.bak.$(date +%Y%m%d_%H%M%S)" + run cp "$HOME/.tmux.conf" "$TMUX_BACKUP" + run cp "$SCRIPT_DIR/tmux.conf" "$HOME/.tmux.conf" + echo "==> ~/.tmux.conf updated (backup saved to $TMUX_BACKUP)" +else + echo "==> ~/.tmux.conf already up to date" +fi + # ── antidote plugins file ──────────────────────────────────────────────────── echo "" if [[ ! -f "$HOME/.zsh_plugins.txt" ]]; then @@ -441,19 +660,33 @@ fi if [[ "$INCLUDE_OPTIONAL" == true ]]; then echo "" echo "==> Installing optional (low) packages..." - apt_install "low" - snap_install "low" + if [[ "$DISTRO" == "ubuntu" ]]; then + apt_install "low" + snap_install "low" + else + yay_install "low" + fi pipx_install "low" - npm_install "low" + pnpm_install "low" # Docker (optional) - if ! command -v docker &>/dev/null; then - echo "==> Installing Docker..." - curl -fsSL https://get.docker.com | sudo sh - sudo usermod -aG docker "$USER" - echo " Log out and back in for the docker group to take effect." + if [[ "$DISTRO" == "ubuntu" ]]; then + if ! command -v docker &>/dev/null; then + echo "==> Installing Docker..." + run_eval "curl -fsSL https://get.docker.com | sudo sh" + run sudo usermod -aG docker "$USER" + echo " Log out and back in for the docker group to take effect." + else + echo "==> Docker already installed ($(docker --version | head -1))" + fi else - echo "==> Docker already installed ($(docker --version | head -1))" + # Arch: docker was installed in the yay low batch; enable the daemon + group. + if command -v docker &>/dev/null || [[ "$DRY_RUN" == true ]]; then + echo "==> Enabling Docker..." + run sudo systemctl enable --now docker.service + run sudo usermod -aG docker "$USER" + echo " Log out and back in for the docker group to take effect." + fi fi fi @@ -474,9 +707,9 @@ else echo " 3. Authenticate Tailscale:" echo " sudo tailscale up" echo "" - echo " 4. SSH / GPG keys:" - echo " bash SSH_and_GPG/create_ssh_key.sh" - echo " bash SSH_and_GPG/create_gpg_key.sh" + echo " 4. Optional — run these from the repo root as needed:" + echo "" + print_related_scripts echo "" echo "================================================================" fi diff --git a/linux-desktop/tmux.conf b/linux-desktop/tmux.conf new file mode 100644 index 0000000..f3ce4d7 --- /dev/null +++ b/linux-desktop/tmux.conf @@ -0,0 +1,38 @@ +# ── General ─────────────────────────────────────────────────────────────────── +# Prefix left at the default C-b. +set -g default-terminal "tmux-256color" +set -ag terminal-overrides ",xterm-256color:RGB" +set -g mouse on +set -g history-limit 10000 +set -s escape-time 10 +set -g base-index 1 +setw -g pane-base-index 1 +set -g renumber-windows on + +# ── Key bindings ────────────────────────────────────────────────────────────── +bind | split-window -h -c "#{pane_current_path}" +bind - split-window -v -c "#{pane_current_path}" +unbind '"' +unbind % + +bind r source-file ~/.tmux.conf \; display "Config reloaded" + +bind h select-pane -L +bind j select-pane -D +bind k select-pane -U +bind l select-pane -R + +# ── Copy mode (vi) ──────────────────────────────────────────────────────────── +setw -g mode-keys vi +bind-key -T copy-mode-vi v send-keys -X begin-selection +bind-key -T copy-mode-vi y send-keys -X copy-selection-and-cancel + +# ── Status bar ──────────────────────────────────────────────────────────────── +set -g status-position top +set -g status-style "bg=#1e2030,fg=#c8d3f5" +set -g status-left-length 30 +set -g status-right-length 60 +set -g status-left "#[bold] #H #[nobold]" +set -g status-right "#[fg=#82aaff]%Y-%m-%d #[fg=#c8d3f5]%H:%M" +set -g window-status-format " #I:#W " +set -g window-status-current-format "#[bg=#82aaff,fg=#1e2030] #I:#W " diff --git a/linux-desktop/verify.sh b/linux-desktop/verify.sh new file mode 100755 index 0000000..0dd6eb0 --- /dev/null +++ b/linux-desktop/verify.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +# Linux desktop install verification (Ubuntu / Arch) +# Usage: bash linux-desktop/verify.sh [--work] [--personal] [--optional] [--all] [--distro ] +# --work also check work-only packages +# --personal also check personal-only packages +# --optional also check low-priority optional packages +# --all check everything (implies --work --personal --optional + priority "none") +# --distro force distro family (ubuntu|arch); default: auto-detect +# +# Mirrors setup.sh's selection logic, so the packages checked here match what +# `setup.sh` with the same flags would install. Read-only — installs nothing. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PACKAGES_JSON="$SCRIPT_DIR/linux_desktop_packages.json" +DISTRO="" +INCLUDE_OPTIONAL=false +INCLUDE_WORK=false +INCLUDE_PERSONAL=false +INCLUDE_NONE=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --optional) INCLUDE_OPTIONAL=true ;; + --work) INCLUDE_WORK=true ;; + --personal) INCLUDE_PERSONAL=true ;; + --all) INCLUDE_OPTIONAL=true; INCLUDE_WORK=true; INCLUDE_PERSONAL=true; INCLUDE_NONE=true ;; + --distro) DISTRO="${2:-}"; shift ;; + *) echo "Unknown argument: $1" >&2 ;; + esac + shift +done + +# ── Distro detection (same logic as setup.sh) ──────────────────────────────── +detect_distro() { + [[ -n "$DISTRO" ]] && return + local id="" id_like="" + if [[ -r /etc/os-release ]]; then + # shellcheck disable=SC1091 + id="$(. /etc/os-release && echo "${ID:-}")" + # shellcheck disable=SC1091 + id_like="$(. /etc/os-release && echo "${ID_LIKE:-}")" + fi + case "$id" in + ubuntu|debian|linuxmint|pop) DISTRO="ubuntu" ;; + arch|cachyos|manjaro|endeavouros|garuda|arcolinux) DISTRO="arch" ;; + *) + case "$id_like" in + *debian*|*ubuntu*) DISTRO="ubuntu" ;; + *arch*) DISTRO="arch" ;; + *) + echo "ERROR: Unsupported distro (ID='$id' ID_LIKE='$id_like')." >&2 + echo " Re-run with --distro ubuntu|arch to override." >&2 + exit 1 ;; + esac ;; + esac +} +detect_distro + +PASS=0 +FAIL=0 + +check() { + local label="$1" ok="$2" + if [[ "$ok" == true ]]; then + echo " ✅ $label" + PASS=$(( PASS + 1 )) + else + echo " ❌ $label" + FAIL=$(( FAIL + 1 )) + fi +} + +# Build the same environment filter setup.sh uses. +env_filter() { + local work="$INCLUDE_WORK" personal="$INCLUDE_PERSONAL" + cat </dev/null && return 0 + command -v "$name" &>/dev/null && return 0 + return 1 ;; + apt) + dpkg -s "$rname" &>/dev/null && return 0 + command -v "$name" &>/dev/null && return 0 + return 1 ;; + snap) + snap list "$rname" &>/dev/null && return 0 + command -v "$name" &>/dev/null && return 0 + return 1 ;; + pipx) + pipx list --short 2>/dev/null | awk '{print $1}' | grep -qx "$rname" && return 0 + command -v "$name" &>/dev/null && return 0 + return 1 ;; + npm) + npm list -g --depth=0 2>/dev/null | grep -q " ${name}@" && return 0 + command -v "$name" &>/dev/null && return 0 + return 1 ;; + curl|custom) + case "$name" in + pyenv) [[ -d "$HOME/.pyenv" ]] && return 0 ;; + nvm) [[ -s "$HOME/.nvm/nvm.sh" ]] && return 0 ;; + claude-code) command -v claude &>/dev/null && return 0 ;; + *) + command -v "$name" &>/dev/null && return 0 + pacman -Qq "$rname" &>/dev/null && return 0 ;; + esac + return 1 ;; + *) + return 1 ;; + esac +} + +# ── Section printer ─────────────────────────────────────────────────────────── +verify_section() { + local section_label="$1" priority="$2" + local ef rows + ef=$(env_filter) + rows=$(jq -r --arg p "$priority" --arg d "$DISTRO" \ + "[.[] | select( + .package_manager[\$d] != null and + .priority == \$p and + $ef + ) | [.name, .package_manager[\$d], (.[\$d + \"_name\"] // .name), (.optional | tostring)] | @tsv] + | .[]" \ + "$PACKAGES_JSON") + + [[ -z "$rows" ]] && return + + echo "" + echo "── $section_label ──────────────────────────────────────────" + + local name mgr rname opt_flag label + while IFS=$'\t' read -r name mgr rname opt_flag; do + [[ -z "$name" ]] && continue + label="$name ($mgr" + [[ "$rname" != "$name" ]] && label="$label:$rname" + label="$label)" + [[ "$opt_flag" == "true" ]] && label="$label [optional]" + if is_installed "$mgr" "$name" "$rname"; then + check "$label" true + else + check "$label" false + fi + done <<< "$rows" +} + +# ── Runtime environment + config checks ────────────────────────────────────── +verify_extras() { + echo "" + echo "── Runtime environment & configs ────────────────────────────" + + # Login shell + local login_shell zsh_bin + login_shell="$(getent passwd "$USER" | cut -d: -f7)" + zsh_bin="$(command -v zsh 2>/dev/null || true)" + if [[ -n "$zsh_bin" && "$login_shell" == "$zsh_bin" ]]; then + check "login shell is zsh ($login_shell)" true + else + check "login shell is zsh (currently ${login_shell:-unknown})" false + fi + + # pyenv + active Python + export PYENV_ROOT="$HOME/.pyenv" + [[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH" + if command -v pyenv &>/dev/null; then + local py_ver + py_ver=$(pyenv version-name 2>/dev/null || echo "") + if [[ -n "$py_ver" && "$py_ver" != "system" ]]; then + check "python via pyenv ($py_ver)" true + else + check "python via pyenv (no version active — run: pyenv global )" false + fi + else + check "pyenv on PATH" false + fi + + # nvm + Node + export NVM_DIR="$HOME/.nvm" + # shellcheck disable=SC1091 + [[ -s "$NVM_DIR/nvm.sh" ]] && source "$NVM_DIR/nvm.sh" 2>/dev/null + if command -v nvm &>/dev/null; then + local node_ver + node_ver=$(node --version 2>/dev/null || echo "") + if [[ -n "$node_ver" ]]; then + check "node via nvm ($node_ver)" true + else + check "node via nvm (run: nvm install 'lts/*')" false + fi + else + check "nvm loadable" false + fi + + # npm supply-chain cooldown (issue #23) + if grep -q '^min-release-age=' "$HOME/.npmrc" 2>/dev/null; then + local mra + mra=$(grep '^min-release-age=' "$HOME/.npmrc" | tail -1 | cut -d= -f2) + check "npm min-release-age set (${mra}-day cooldown)" true + else + check "npm min-release-age set (run setup.sh)" false + fi + + # pnpm (daily-driver package manager) + its supply-chain cooldown (issue #23) + export PNPM_HOME="$HOME/.local/share/pnpm" + [[ -d "$PNPM_HOME/bin" ]] && export PATH="$PNPM_HOME/bin:$PATH" + if command -v pnpm &>/dev/null; then + check "pnpm ($(pnpm --version 2>/dev/null))" true + local pmra + pmra=$(pnpm config get minimumReleaseAge 2>/dev/null) + if [[ -n "$pmra" && "$pmra" != "undefined" && "$pmra" -gt 0 ]] 2>/dev/null; then + check "pnpm minimumReleaseAge set (${pmra} min)" true + else + check "pnpm minimumReleaseAge set (run setup.sh)" false + fi + else + check "pnpm (run setup.sh — corepack enable)" false + fi + + # Shell config files + [[ -f "$HOME/.zshrc" ]] && check "zshrc present (~/.zshrc)" true || check "zshrc present (~/.zshrc)" false + [[ -f "$HOME/.zsh_plugins.txt" ]] && check "antidote plugin list present (~/.zsh_plugins.txt)" true || check "antidote plugin list present (~/.zsh_plugins.txt)" false + [[ -f "$HOME/.tmux.conf" ]] && check "tmux config present (~/.tmux.conf)" true || check "tmux config present (~/.tmux.conf)" false + + # antidote (Arch AUR package puts it under /usr/share/zsh-antidote) + if [[ -f /usr/share/zsh-antidote/antidote.zsh ]] || command -v antidote &>/dev/null; then + check "antidote available" true + else + check "antidote available" false + fi + + # Ghostty config + local ghostty_cfg="${XDG_CONFIG_HOME:-$HOME/.config}/ghostty/config" + [[ -f "$ghostty_cfg" ]] && check "ghostty config present" true || check "ghostty config present" false + + # Tailscale daemon + if command -v systemctl &>/dev/null; then + if systemctl is-active --quiet tailscaled 2>/dev/null; then + check "tailscaled service active" true + else + check "tailscaled service active (run: sudo systemctl enable --now tailscaled)" false + fi + fi +} + +# ── Run ─────────────────────────────────────────────────────────────────────── +echo "==> Verifying $DISTRO package installs against $PACKAGES_JSON" + +verify_extras +verify_section "High priority" "high" +verify_section "Medium priority" "medium" +[[ "$INCLUDE_OPTIONAL" == true ]] && verify_section "Low priority (optional)" "low" +[[ "$INCLUDE_NONE" == true ]] && verify_section "Priority none (manual only)" "none" + +# ── Summary ───────────────────────────────────────────────────────────────── +echo "" +echo "────────────────────────────────────────────────────────────" +echo " ✅ $PASS ok ❌ $FAIL missing" +echo "" +if [[ $FAIL -gt 0 ]]; then + echo "Run setup.sh (with matching flags) to install missing items, or install manually." +else + echo "All checked items are present." +fi diff --git a/linux-desktop/zshrc.example b/linux-desktop/zshrc.example index f9350c2..46f61e1 100644 --- a/linux-desktop/zshrc.example +++ b/linux-desktop/zshrc.example @@ -12,6 +12,25 @@ export PATH="$HOME/.local/bin:$PATH" +# ═══════════════════════════════════════════════════════════════ +# VERSION MANAGERS (pyenv / nvm — installed by setup.sh via curl) +# ═══════════════════════════════════════════════════════════════ +# Same ~/.pyenv and ~/.nvm layout on macOS, Ubuntu, and Arch. +# Guarded so the shell still starts cleanly if neither is installed. +export PYENV_ROOT="$HOME/.pyenv" +[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH" +command -v pyenv &>/dev/null && eval "$(pyenv init -)" + +export NVM_DIR="$HOME/.nvm" +[[ -s $NVM_DIR/nvm.sh ]] && source "$NVM_DIR/nvm.sh" +[[ -s $NVM_DIR/bash_completion ]] && source "$NVM_DIR/bash_completion" + +# pnpm — enabled by setup.sh via corepack (the pnpm binary itself is a corepack +# shim on PATH via nvm). Global CLI binaries land in $PNPM_HOME/bin. +export PNPM_HOME="$HOME/.local/share/pnpm" +[[ -d $PNPM_HOME/bin ]] && export PATH="$PNPM_HOME/bin:$PATH" + + # ═══════════════════════════════════════════════════════════════ # HISTORY # ═══════════════════════════════════════════════════════════════ diff --git a/linux-server/tmux.conf b/linux-server/tmux.conf index 1914ed1..f3ce4d7 100644 --- a/linux-server/tmux.conf +++ b/linux-server/tmux.conf @@ -1,9 +1,5 @@ -# ── Prefix ──────────────────────────────────────────────────────────────────── -set -g prefix C-a -unbind C-b -bind C-a send-prefix - # ── General ─────────────────────────────────────────────────────────────────── +# Prefix left at the default C-b. set -g default-terminal "tmux-256color" set -ag terminal-overrides ",xterm-256color:RGB" set -g mouse on diff --git a/macOS/macOS_packages.json b/macOS/macOS_packages.json index 49f1db8..99a9a4d 100644 --- a/macOS/macOS_packages.json +++ b/macOS/macOS_packages.json @@ -285,7 +285,7 @@ }, { "name": "codeburn", - "package_manager": "npm", + "package_manager": "pnpm", "priority": "medium", "optional": false, "description": "Track AI coding token usage by task, tool, model, and project" diff --git a/macOS/setup.sh b/macOS/setup.sh index dc39362..6a718bd 100755 --- a/macOS/setup.sh +++ b/macOS/setup.sh @@ -9,6 +9,17 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PACKAGES_JSON="$SCRIPT_DIR/macOS_packages.json" + +# Other repo setup scripts to advertise at the end of a run. Each entry is +# "|". To advertise a new +# script, just add a line here — only scripts that exist on disk are shown. +RELATED_SCRIPTS=( + "agentic-ai/Claude/install.sh|Claude Code config — symlink settings, hooks, and CLAUDE.md into ~/.claude" + "SSH_and_GPG/create_ssh_key.sh|Generate an SSH key (and add it to GitHub)" + "SSH_and_GPG/create_gpg_key.sh|Generate a GPG key for signed commits" + "macOS/verify.sh|Verify this install (read-only health check)" +) + INCLUDE_OPTIONAL=false INCLUDE_WORK=false DRY_RUN=false @@ -41,6 +52,54 @@ run_eval() { fi } +# npm supply-chain cooldown: refuse to install package versions younger than +# NPM_MIN_RELEASE_AGE days. Compromised releases of popular packages (e.g. the +# axios RAT, Mar 2026) are typically caught and yanked within hours, so a short +# cooldown blocks them while barely delaying legit updates. See issue #23. +NPM_MIN_RELEASE_AGE=7 + +configure_npm_cooldown() { + local npmrc="$HOME/.npmrc" key="min-release-age" tmp + if [[ "$DRY_RUN" == true ]]; then + printf ' [dry-run] set %s=%s in %s\n' "$key" "$NPM_MIN_RELEASE_AGE" "$npmrc" + return 0 + fi + touch "$npmrc" + # Drop any existing entry, then append the desired value (idempotent, portable). + tmp="$(mktemp)" + grep -v "^${key}=" "$npmrc" > "$tmp" 2>/dev/null || true + printf '%s=%s\n' "$key" "$NPM_MIN_RELEASE_AGE" >> "$tmp" + mv "$tmp" "$npmrc" + printf ' ✓ npm %s=%s (%s-day supply-chain cooldown)\n' "$key" "$NPM_MIN_RELEASE_AGE" "$NPM_MIN_RELEASE_AGE" +} + +# Enable pnpm (our daily-driver package manager) via corepack — corepack ships +# with Node — and apply the same supply-chain cooldown. pnpm measures +# minimumReleaseAge in MINUTES and enforces it strictly only when set explicitly +# (the built-in 1-day default is non-strict), so we set it on purpose. Issue #23. +configure_pnpm() { + local age_minutes=$((NPM_MIN_RELEASE_AGE * 24 * 60)) + if [[ "$DRY_RUN" == true ]]; then + printf ' [dry-run] corepack enable && corepack prepare pnpm@latest --activate\n' + printf ' [dry-run] pnpm config set minimumReleaseAge %s --location=user\n' "$age_minutes" + return 0 + fi + if ! command -v corepack &>/dev/null; then + printf ' ⚠ corepack not found (needs Node ≥16) — skipping pnpm setup\n' + return 0 + fi + corepack enable + corepack prepare pnpm@latest --activate + export PNPM_HOME="$HOME/.local/share/pnpm" + export PATH="$PNPM_HOME/bin:$PATH" + if command -v pnpm &>/dev/null; then + pnpm config set minimumReleaseAge "$age_minutes" --location=user + printf ' ✓ pnpm enabled, minimumReleaseAge=%s min (%s-day cooldown)\n' "$age_minutes" "$NPM_MIN_RELEASE_AGE" + else + printf ' ⚠ pnpm not on PATH after corepack — skipped cooldown config\n' + fi +} + add_to_zshrc() { local line="$1" if [[ "$DRY_RUN" == true ]]; then @@ -94,18 +153,18 @@ pipx_install() { done } -npm_install() { +pnpm_install() { local priority="$1" optional_filter="$2" local names names=$(jq -r --arg p "$priority" --argjson opt "$optional_filter" \ '[.[] | select( - .package_manager == "npm" and + .package_manager == "pnpm" and .priority == $p and (.work != true) and (if $opt then true else .optional == false end) ) | .name] | join(" ")' "$PACKAGES_JSON") # shellcheck disable=SC2086 - [[ -n "$names" ]] && run npm install -g $names + [[ -n "$names" ]] && run pnpm add -g $names } brew_custom_install() { @@ -135,6 +194,22 @@ print_app_store_reminders() { [[ -n "$apps" ]] && printf '%s\n' "$apps" } +# Echo the other repo setup scripts (those present on disk) for discoverability. +print_related_scripts() { + local repo_root entry rel desc shown=false + repo_root="$(cd "$SCRIPT_DIR/.." && pwd)" + for entry in "${RELATED_SCRIPTS[@]}"; do + rel="${entry%%|*}"; desc="${entry#*|}" + [[ -f "$repo_root/$rel" ]] || continue + if [[ "$shown" == false ]]; then + printf ' Other setup scripts in this repo:\n' + shown=true + fi + printf ' • %s\n' "$desc" + printf ' bash %s\n' "$rel" + done +} + # ── Homebrew ────────────────────────────────────────────────────────────────── if ! command -v brew &>/dev/null; then printf '==> Installing Homebrew...\n' @@ -200,9 +275,16 @@ if [[ "$DRY_RUN" == false ]]; then nvm use default fi +# Apply the supply-chain cooldown before any package install runs below. +configure_npm_cooldown +configure_pnpm + add_to_zshrc 'export NVM_DIR="$HOME/.nvm"' add_to_zshrc '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' add_to_zshrc 'nvm use --delete-prefix default --silent 2>/dev/null' +# pnpm global bin dir (corepack provides the pnpm shim; PNPM_HOME holds global CLIs) +add_to_zshrc 'export PNPM_HOME="$HOME/.local/share/pnpm"' +add_to_zshrc '[ -d "$PNPM_HOME/bin" ] && export PATH="$PNPM_HOME/bin:$PATH"' # ── Medium-priority brew casks ──────────────────────────────────────────────── printf '==> Installing brew casks...\n' @@ -244,12 +326,18 @@ if [[ "$DRY_RUN" == false ]]; then fi pipx_install "medium" false -# ── Medium-priority npm packages ────────────────────────────────────────────── -printf '==> Installing npm packages...\n' -npm_install "medium" false +# ── Medium-priority pnpm packages ───────────────────────────────────────────── +printf '==> Installing pnpm packages...\n' +export PNPM_HOME="$HOME/.local/share/pnpm" +export PATH="$PNPM_HOME/bin:$PATH" +if [[ "$DRY_RUN" == true ]] || command -v pnpm &>/dev/null; then + pnpm_install "medium" false -# Post-install: codeburn menubar (macOS native Swift app) -run codeburn menubar + # Post-install: codeburn menubar (macOS native Swift app) + run codeburn menubar +else + printf ' pnpm not found — skipping (run corepack enable)\n' +fi # ── Medium-priority brew formulae ───────────────────────────────────────────── printf '==> Installing brew formulae...\n' @@ -263,7 +351,7 @@ if [[ "$INCLUDE_OPTIONAL" == true ]]; then brew_custom_install "low" true brew_cask_install "low" true pipx_install "low" true - npm_install "low" true + command -v pnpm &>/dev/null && pnpm_install "low" true fi # ── Work packages (--work flag) ─────────────────────────────────────────────── @@ -288,5 +376,9 @@ printf 'Install these manually from the App Store:\n' print_app_store_reminders "medium" false [[ "$INCLUDE_OPTIONAL" == true ]] && print_app_store_reminders "low" true +printf '\n' +printf 'Optional — run these from the repo root as needed:\n' +print_related_scripts + printf '\n' [[ "$DRY_RUN" == true ]] && printf 'Dry run complete — nothing was installed.\n' || printf 'Done! Restart your terminal or open a new tab.\n' diff --git a/macOS/verify.sh b/macOS/verify.sh index 1b13ed4..c81db60 100755 --- a/macOS/verify.sh +++ b/macOS/verify.sh @@ -140,6 +140,31 @@ verify_extras() { check "node via nvm (run: nvm install 'lts/*')" false fi + # npm supply-chain cooldown (issue #23) + if grep -q '^min-release-age=' "$HOME/.npmrc" 2>/dev/null; then + local mra + mra=$(grep '^min-release-age=' "$HOME/.npmrc" | tail -1 | cut -d= -f2) + check "npm min-release-age set (${mra}-day cooldown)" true + else + check "npm min-release-age set (run setup.sh)" false + fi + + # pnpm (daily-driver package manager) + its supply-chain cooldown (issue #23) + export PNPM_HOME="$HOME/.local/share/pnpm" + [[ -d "$PNPM_HOME/bin" ]] && export PATH="$PNPM_HOME/bin:$PATH" + if command -v pnpm &>/dev/null; then + check "pnpm ($(pnpm --version 2>/dev/null))" true + local pmra + pmra=$(pnpm config get minimumReleaseAge 2>/dev/null) + if [[ -n "$pmra" && "$pmra" != "undefined" && "$pmra" -gt 0 ]] 2>/dev/null; then + check "pnpm minimumReleaseAge set (${pmra} min)" true + else + check "pnpm minimumReleaseAge set (run setup.sh)" false + fi + else + check "pnpm (run setup.sh — corepack enable)" false + fi + # Ghostty XDG config local ghostty_cfg="${XDG_CONFIG_HOME:-$HOME/.config}/ghostty/config.ghostty" [[ -f "$ghostty_cfg" ]] && check "ghostty config at XDG path" true || check "ghostty config at XDG path" false