commit c349fe488cf98662ad2693e803c2b6694ec8fb8b Author: Wayne Hayes Date: Mon Jun 29 00:17:18 2026 -0400 Homescale — a roaming home for your fleet SSH into one stable Tailscale node and reach your whole tailnet from it via short per-host functions + sshfs mounts. A riced Arch dev container with sshd, a stateful home, and declarative .env auth. See README.md and ROAMING.md. diff --git "a/\033\033\033\033" "b/\033\033\033\033" new file mode 100644 index 0000000..4fc2b94 --- /dev/null +++ "b/\033\033\033\033" @@ -0,0 +1,39 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn +NhAAAAAwEAAQAAAYEAnFyYUvo2ItCC0o3MmXWKFEqV32AyTIO7gUhZOXEHqeBATnwbITFk +a6EIoAyenLeimd7Kn7SvPNoedxLgdq5AorLO1QMLCvd8ccVtaizREmpGWCB7iDn1xguf+9 +Zmg6EcQgp7NVgYSlJV7mVGfhYib5Zh6cFlxWkNEOrVaSxu9yYsMg0dA0o+G+DbrtitArY0 +lBiFcUA/URAdXiKIVR83sk4OeTcQQ5MijaKWGq95y5S6qP3fyvm7Lbe9XC43GFliMlhIN+ +EHtmREVoZyqFIzO2ZZ3o/SWdM9TfPwCbuJNBH1ZY9z0lI7/YROsJvtbK8973fq4lsuV8FY +zuLe7R8R/acMv5n0ZTJa8Ky5/wt3vVuYkxx9XJ+eGoSBxzOge9LMsLb0QojUofPcRI0Nln +rwE3MHKFTEw/+hsyEhKZ42+jueyCbs3WdhzMq1WGcxa4Rwe7tQVthMLjpkhbr2x2R4stRT +oxbqgNdj1Nke0qaniiQCbASuqS1t39Dm02SaRZujAAAFoAF9wnoBfcJ6AAAAB3NzaC1yc2 +EAAAGBAJxcmFL6NiLQgtKNzJl1ihRKld9gMkyDu4FIWTlxB6ngQE58GyExZGuhCKAMnpy3 +opneyp+0rzzaHncS4HauQKKyztUDCwr3fHHFbWos0RJqRlgge4g59cYLn/vWZoOhHEIKez +VYGEpSVe5lRn4WIm+WYenBZcVpDRDq1WksbvcmLDINHQNKPhvg267YrQK2NJQYhXFAP1EQ +HV4iiFUfN7JODnk3EEOTIo2ilhqvecuUuqj938r5uy23vVwuNxhZYjJYSDfhB7ZkRFaGcq +hSMztmWd6P0lnTPU3z8Am7iTQR9WWPc9JSO/2ETrCb7WyvPe936uJbLlfBWM7i3u0fEf2n +DL+Z9GUyWvCsuf8Ld71bmJMcfVyfnhqEgcczoHvSzLC29EKI1KHz3ESNDZZ68BNzByhUxM +P/obMhISmeNvo7nsgm7N1nYczKtVhnMWuEcHu7UFbYTC46ZIW69sdkeLLUU6MW6oDXY9TZ +HtKmp4okAmwErqktbd/Q5tNkmkWbowAAAAMBAAEAAAGAAJ825ugxmoCPnCR0lFUcSCIL+R +44OVCwF7GC11/Q1rGv2bLcd5bZB8Nz8uSsfxavjPCjKm611ERZKxHnux4Rusc9yWpGAkvh +WADSh3y0Ic4WbRzRXCUHUXaKvT57iiRBVM/6o1YVvYGOutj6rIXTdduTzXDtfu6TKAoT6G +OC/bPELOcjk65yo5rP093i2FC/5kwC9TNqrApOwfIOeLfCoh6yhUtePitDpQ4Nhixc50yX +4kTYLXSflO72fJvkk796f+kwuFDP788lo1gohrNZYsZKr20sJuyQvxpAiOU20bNIG8E7pw +PsnYIIkhTgSAH5klJa9d+Nl+7y8lw9EzTI4dhdTUc9PWmo4G3wjwTHLE1GluDc2zHvJV9Q +vLo/nflWn2eqn26JdZmjGI23sVRPNh1or5S7BLldPIMXnz8l6jiGztpGTc2tapHCeXsHm9 +37guDFQHHhZ0ZoUdYy2pB8rC1WoMo52t9N3s957+zwff4MqSeEdgrsMz5jAkmBNfQlAAAA +wQDIf2w9+9NGy9i1kBUCL40XwBhJwT9vGYxoyhB87UdV+exuYU8Mu5mDs1un9YIJRT3lTe +j0l+2Ko57uym/FJ2zvM+lErZSAzUNH4QGcMCaDNtZ61+KHRCkkY+CirXEjDs9+pzsRdVBl +r/LZASdPxsmOMHKAgC91jKSaRmcIe52YTsMJRtKpCOx6A11YnJhJqzMbpWPV8S3CZkLk4D +MGd8DTd59fcgGYzbs/svgPIHzRSXXYdUHFATX6NgCvaJGyM+8AAADBAM6AnnZ4PROkRrla +1fiOgWDnCHSR/wKe1c4e1tlpv9wPchddtq3UDNoWaiqp49+CUnrimkrEPgGig2QArv3Bop +BwAQEAYr1wLXGr31m6wLschHmGzOIhA09V1PmWzB3resAsCAw2BA1a5HYDS+IuD0JS6TMu +DZ3J8mLCHO8C95QI0AQzRoJ9903i9Y3v3uj4zYebZXHK1oJot2o4natrjKJlz3tPsCgnQN +tROruIOlzsbzSQrft6fY1u58isE1nuxwAAAMEAwdc/BhtXnVghY9Ftj6xqFH2uhP9ul3rw +FVeEItMvciVihrex8KYMP3+GHcAkInaPLVqW0b6K5IxxsO1bgxHukgDBWWIacS5RguDM7B +FO1sghEPL1QJBGONJ0rJBPDgiXkVdSRCo8ay2TASBVD9dHTI2MV0q+a0W1o74LQvYj/7cx +3MXkbeaywz5MqS5F6h5RXl0BczafJa4EsUeibZP1FaadgDDQT7VupR9Ol8gd1iF5607n5X +avX4guaGi5dcBFAAAAI3dheW5lQGJveC53YXluZWhheWVzZGV2ZWxvcG1lbnQuY29tAQID +BAUGBw== +-----END OPENSSH PRIVATE KEY----- diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..611169a --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# nvimide — declarative config. Copy to .env (gitignored) and fill in. +# cp .env.example .env + +# ── Tailnet (your Tailscale OAuth client + tailnet) ── +TAILSCALE_VERSION=v1.98.4 +TS_OAUTH_CLIENT_SECRET= # OAuth client secret, authorized for tag:nvimide +TS_TAILNET= # e.g. your-tailnet.ts.net — used by the roaming engine for MagicDNS +NVIMIDE_MAGIC_NAME=nvimide + +# ── Declarative auth (NixOS-style — nothing baked in the image) ── +NVIMIDE_USER=dev +NVIMIDE_PASSWORD= # sets the user's password → enables sudo + ssh fallback +NVIMIDE_SSH_PUBKEY= # your PUBLIC key (e.g. ~/.ssh/id_ed25519.pub) — the real login + +# ── Build (match host uid so the home volume perms line up) ── +UID=1000 +GID=1000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f3981f --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# ── arch-dev repo .gitignore ────────────────────────────────────────────────── + +# Docker build artifacts +*.tar.gz +*.tar + +# Environment / secrets +.env +.env.local +.env.*.local +!.env.example + +# Editor +.DS_Store +*.swp +*.swo +*~ + +# Python +__pycache__/ +*.pyc +*.pyo + +# Workspace contents (bind mount — user's project files, not ours) +workspace/* +!workspace/.gitkeep + +# nvimide: never ship auth material +authorized_keys +*.key +*_rsa +*_ed25519 +id_* +ssh_host_* + +# local-only compose override (external/migrated home volume, etc.) +docker-compose.override.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2f43a65 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,131 @@ +FROM archlinux:latest + +# ── Build args: UID/GID matching for clean /workspace permissions ───────────── +# Override at build: UID=$(id -u) GID=$(id -g) docker compose build +# Or set in .env file +ARG USER_UID=1000 +ARG USER_GID=1000 + +# ── Rolling release: full system update first, always ───────────────────────── +RUN pacman -Syu --noconfirm + +# ── Crown Jewel #1: pacman ──────────────────────────────────────────────────── +RUN pacman -S --noconfirm --needed \ + base-devel git curl wget unzip zip \ + zsh tmux screen mosh \ + zsh-syntax-highlighting zsh-autosuggestions zsh-history-substring-search \ + zsh-completions \ + neovim \ + starship \ + python python-pip python-pynvim \ + perl \ + pyright \ + bash-language-server \ + python-black ruff shellcheck shfmt \ + python-pylint \ + ripgrep fd bat eza fzf zoxide \ + git-delta lazygit \ + btop \ + ttf-nerd-fonts-symbols ttf-jetbrains-mono-nerd \ + man-db man-pages \ + jq tree wget \ + rsync \ + imagemagick chafa jp2a \ + go \ + github-cli \ + libnewt \ + openssh sshfs \ + && pacman -Scc --noconfirm + +# ── Crown Jewel #2: AUR ─────────────────────────────────────────────────────── +RUN useradd -m -s /bin/zsh -u 9001 -G wheel aurbuild && \ + echo 'aurbuild ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers.d/aurbuild + +RUN cd /tmp && \ + git clone --depth=1 https://aur.archlinux.org/yay-bin.git && \ + chown -R aurbuild:aurbuild yay-bin && \ + cd yay-bin && \ + sudo -u aurbuild makepkg -si --noconfirm && \ + cd / && rm -rf /tmp/yay-bin + +RUN sudo -u aurbuild yay -S --noconfirm --needed \ + eza \ + wl-clipboard \ + trash-cli \ + tailscale \ + && sudo -u aurbuild yay -Scc --noconfirm + +# ── FUSE config: allow_other for sshfs mounts ───────────────────────────────── +RUN sed -i 's/^#user_allow_other/user_allow_other/' /etc/fuse.conf 2>/dev/null || \ + echo 'user_allow_other' >> /etc/fuse.conf + +# ── Dev user with host-matching UID/GID ─────────────────────────────────────── +# UID/GID match host so /workspace bind mount has clean permissions both sides. +# aurbuild is parked at UID 9001 so there's no collision with host UID. +RUN set -e; \ + if getent group ${USER_GID} >/dev/null; then \ + groupmod -n dev "$(getent group ${USER_GID} | cut -d: -f1)"; \ + else \ + groupadd -g ${USER_GID} dev; \ + fi; \ + useradd -m -s /bin/zsh -u ${USER_UID} -g ${USER_GID} -G wheel dev; \ + echo 'dev ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers.d/dev + +# ── Skeleton: bake dotfiles into /etc/skel-arch-dev/ ────────────────────────── +COPY --chown=dev:dev dotfiles/ /etc/skel-arch-dev/ + +# ── Roaming toolkit: add_new_machine.sh onto PATH ───────────────────────────── +COPY scripts/add_new_machine.sh /usr/local/bin/add_new_machine.sh +RUN chmod +x /usr/local/bin/add_new_machine.sh + +# ── Initial seed of /home/dev so plugin bake works at build time ────────────── +RUN cp -an /etc/skel-arch-dev/. /home/dev/ && \ + chown -R dev:dev /home/dev + +# ── nvm + LTS Node (as dev user) ────────────────────────────────────────────── +# nvm install script writes to ~/.zshrc — we sandbox it then merge cleanly +RUN sudo -u dev bash -c '\ + export PROFILE=/dev/null && \ + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash && \ + export NVM_DIR="/home/dev/.nvm" && \ + [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && \ + nvm install --lts && \ + nvm alias default node \ +' + +# Persist nvm install into the skel template so volume seeding includes it +RUN cp -an /home/dev/.nvm /etc/skel-arch-dev/ && \ + chown -R dev:dev /etc/skel-arch-dev/.nvm + +# ── Python tools ────────────────────────────────────────────────────────────── +RUN pip install --break-system-packages pynvim httpx requests + +# ── Bake neovim plugins ─────────────────────────────────────────────────────── +RUN sudo -u dev HOME=/home/dev XDG_DATA_HOME=/home/dev/.local/share \ + nvim --headless +"Lazy! sync" +qa 2>/dev/null; exit 0 + +RUN sudo -u dev HOME=/home/dev XDG_DATA_HOME=/home/dev/.local/share \ + nvim --headless \ + +"TSUpdateSync python bash lua json yaml toml markdown vim vimdoc regex" \ + +qa 2>/dev/null; exit 0 + +# Copy fully-baked /home/dev back into the skel template +RUN cp -an /home/dev/.local /etc/skel-arch-dev/ && \ + cp -an /home/dev/.cache /etc/skel-arch-dev/ 2>/dev/null || true && \ + chown -R dev:dev /etc/skel-arch-dev + +# ── Cleanup AUR build user ──────────────────────────────────────────────────── +RUN userdel -r aurbuild && rm -f /etc/sudoers.d/aurbuild + +# ── Entrypoint script ───────────────────────────────────────────────────────── +COPY entrypoint.sh /usr/local/bin/arch-dev-entrypoint +RUN chmod +x /usr/local/bin/arch-dev-entrypoint + +# ── Final permissions ───────────────────────────────────────────────────────── +RUN chown -R dev:dev /home/dev + +USER dev +WORKDIR /workspace + +ENTRYPOINT ["/usr/local/bin/arch-dev-entrypoint"] +CMD ["/bin/zsh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..91f02b8 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# Homescale +### Your fleet, one word away · a Tailscale-native roaming home + +> *"I'd far rather be happy than right any day."* And you're far more likely to +> be both when your whole fleet answers from a single container you `ssh` into. + +**Homescale** turns a riced Arch dev container into a **roaming home for your +fleet**: one stable Tailscale node you SSH into, from which every host on your +tailnet is a single word away. It holds the keyring; you carry nothing but a +terminal. Run a coding agent inside it and it becomes a fleet control plane with +hands. + +The IDE is still under the hood — colorscheme, LSP, a git-snapshotted home. It's +just no longer the point. The point is **home**. + +*(Tailscale today → Headscale later: the "home" you scale.)* + +--- + +## Use it + +```bash +ssh @..ts.net +``` + +You land in your full stateful home. Each registered host is then just a verb: + +```bash +web uptime # runs on the host named "web", over the tailnet +db sudo systemctl status pg # remote sudo prompts — the boundary holds +edge journalctl -u caddy # any registered host +mount_host db # sshfs its filesystem → /workspace/db +roaming-status # what's registered +``` + +Each host becomes a shell function. Pipes/redirects run **locally**, exactly like +plain ssh — quote the whole pipeline to run it remote. + +--- + +## Onboard a host + +```bash +add_new_machine.sh NAME NAME..ts.net / +roaming-reload +``` + +Three things that bite, up front: +- **Key-only hosts** (`PasswordAuthentication no`) can't be bootstrapped by + `ssh-copy-id`. Pre-install Homescale's `~/.ssh/id_ed25519.pub` via a path the + host already trusts — keyless `tailscale ssh` is ideal. +- **Custom ssh port?** Add a `Host … Port ` block to `~/.ssh/config` (the + roaming engine uses plain ssh). +- **Aliases don't travel** — `NAME ` is non-interactive ssh. Use real + binaries or `~/bin` scripts. + +--- + +## How it's built + +| Piece | What | +|---|---| +| `ts-nvimide` | Tailscale sidecar — **stable** node (no `?ephemeral`), `tag:nvimide` | +| `nvimide` | the dev image sharing the sidecar's netns, running **sshd as init** | +| home volume | stateful home (snapshots, tools, `.ssh`) | +| auth | declarative via `.env` (`NVIMIDE_USER` / `NVIMIDE_PASSWORD` / `NVIMIDE_SSH_PUBKEY`) | + +```bash +cp .env.example .env # fill TS_OAUTH_CLIENT_SECRET, NVIMIDE_SSH_PUBKEY, TS_TAILNET … +docker compose up -d --build +``` + +> The committed compose uses a **managed** home volume so it works out of the box. +> To pin it to an existing/migrated volume, override `nvimide-home` in a local +> (gitignored) `docker-compose.override.yml`. + +### Tailnet ACL (lives in your Tailscale console, not this repo) + +A **grant** for plain-TCP reach + **two `ssh` rules** — a separate section from +grants, needed in **both directions**: + +```jsonc +{ "src": ["tag:nvimide"], "dst": ["*"], "ip": ["*"] } // reach (plain ssh) +{ "src": ["tag:nvimide"], "dst": ["*"], "users": [""], "action": "accept" } // tailscale ssh OUT +{ "src": ["*"], "dst": ["tag:nvimide"], "users": [""], "action": "accept" } // tailscale ssh IN — "Go Home!" +``` + +--- + +## Deeper + +- **`ROAMING.md`** — the roaming engine: registry format, host-functions, sshfs + mounts, the gotchas (host-key persistence, the `CAP_SYS_CHROOT` sshd needs, the + sidecar healthcheck that must not gate on the app's `:22`). + +--- + +*MIT — fork it, name your node something silly, make it home.* diff --git a/ROAMING.md b/ROAMING.md new file mode 100644 index 0000000..88f0e58 --- /dev/null +++ b/ROAMING.md @@ -0,0 +1,132 @@ +# Roaming ~ — arch-dev v3 + +Turn neovim-ide into your **roaming home directory**. SSH in from any device, +and every server on your tailnet feels like home: one keyring, one shell +history, one set of dotfiles, reachable from anywhere. + +This is what makes neovim-ide more than another riced nvim install — it's a +control plane for your whole fleet. + +--- + +## The Model + +``` + You (phone / tablet / chromebook) + ↓ ssh + nvimide.tailnet.ts ← your roaming home (this container) + ↓ ssh / sshfs over tailnet + ┌──────────┬──────────┬──────────┐ + mail proxy dev (any host) +``` + +You live in nvimide. Files live on remote hosts (mounted via sshfs when you +want them). Commands run on remote hosts via short per-host functions. + +Keys never leave nvimide — agent auth handles remote login, remote `sudo` +still prompts (the security boundary stays intact). + +--- + +## Files + +| File | Purpose | +|---|---| +| `~/.config/roaming/hosts` | Registry — one line per host (source of truth) | +| `~/.config/roaming/roaming.zsh` | Engine — generates host functions, mount helpers | +| `add_new_machine.sh` | Onboard a new host (key + register + verify) | + +--- + +## Usage + +### Run commands on a host + +Each registry entry becomes a command. Dumb by design — pipes and redirects +run **locally**, exactly like plain ssh (predictable, no surprises). + +```bash +mail # interactive shell on mail +mail tail -f /var/log/mail.log # TTY allocated — -f works +mail sudo systemctl restart postfix # remote sudo prompts for password +proxy sudo systemctl reload caddy +dev docker compose logs -f +``` + +### Mount a host's filesystem + +```bash +mount_host mail # sshfs the registered path → /workspace/mail +ls /workspace/mail/etc/postfix/ +unmount_host mail # clean unmount (handles stale mounts) +``` + +Mounts use `reconnect,ServerAliveInterval=15,ServerAliveCountMax=3` so a +network blip errors out in ~45s instead of freezing your shell forever. + +### Status & reload + +```bash +roaming-status # list hosts + which are mounted +roaming-reload # re-read registry after editing hosts by hand +``` + +### Add a new machine + +```bash +add_new_machine.sh mail mail.tailnet.ts root / +# 1. ssh-copy-id (one password prompt) +# 2. appends to ~/.config/roaming/hosts +# 3. verifies key-based login +``` + +After that, `mail` works in the next shell (or after `roaming-reload`). + +--- + +## Registry Format + +``` +# name fqdn user mount +mail mail.tailnet.ts root / +proxy proxy.tailnet.ts admin /etc/caddy +dev dev-server.tailnet.ts youruser +``` + +- `user` optional — defaults to `$ROAMING_DEFAULT_USER` or current user +- `mount` optional — omit if you only run commands, never mount + +--- + +## Onboarding a Fresh Client + +The whole point: a new device needs almost nothing. + +1. Install your SSH key to reach nvimide.tailnet.ts +2. SSH in — you're home. Dotfiles, history (atuin), keys, the works. + +No per-device key sprawl. nvimide holds the fleet keyring; clients only need +to reach nvimide. + +--- + +## Gotchas (learned, baked in) + +- **Pipes are local.** `mail tail -f log | grep err` greps locally. Want it + remote? Quote the whole pipeline: `mail 'tail -f log | grep err'`. +- **Stale mount frozen?** `fusermount3 -uz /workspace/` force-clears it. +- **`tail -f` / `htop` / sudo need a TTY** — handled (`ssh -t` always). +- **Agent forwarding (`-A`) is intentionally OFF** — a compromised remote + could abuse a forwarded agent. Add per-host later only if you need to hop. + +--- + +## Roadmap (future hardening) + +- **SSH CA + OIDC (Authelia)** — replace `authorized_keys` with short-lived + certs issued after Authelia auth. Collapses key management fleet-wide. + Deferred: keys stay as the stable fallback until the cert flow is proven. +- **autofs** — system-level lazy mount/unmount (60s idle auto-unmount) as an + alternative to manual `mount_host`. +- **atuin** — single shell-history brain in nvimide, DB in the postgres + cluster; retire atuin-server. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9105daf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,114 @@ +# nvimide — roaming dev IDE (arch-dev v3). Its OWN thing; follows the +# Tailscale sidecar pattern and rides your tailnet, but is +# not part of that stack. +# +# You SSH into this node from any device; it holds the fleet keyring + roaming +# engine and reaches every other host. It is a STABLE tailnet node (not +# ephemeral) — always reachable, clean MagicDNS name (no -N tombstones) — so +# TS_AUTHKEY omits ?ephemeral=true. +# +# Declarative auth (NixOS-style): user, password, and ssh pubkey all come from +# .env — nothing baked into the image, nothing mounted. Copy .env.example to +# .env and fill it in. +# +# Bring up: +# 1. ACL: add `tag:nvimide` + authorize the OAuth client to mint it. +# 2. cp .env.example .env && fill NVIMIDE_SSH_PUBKEY / NVIMIDE_PASSWORD / TS_* +# 3. docker compose up -d --build +# 4. ssh ${NVIMIDE_USER}@nvimide + +name: nvimide + +services: + + ts-nvimide: + image: tailscale/tailscale:${TAILSCALE_VERSION:-v1.98.4} + hostname: ${NVIMIDE_MAGIC_NAME:-nvimide} + environment: + TS_AUTHKEY: ${TS_OAUTH_CLIENT_SECRET} # NO ?ephemeral=true → stable node + TS_EXTRA_ARGS: --advertise-tags=tag:nvimide + TS_HOSTNAME: ${NVIMIDE_MAGIC_NAME:-nvimide} + TS_ACCEPT_DNS: "true" + TS_AUTH_ONCE: "true" + TS_USERSPACE: "false" + TS_ENABLE_HEALTH_CHECK: "true" + TS_LOCAL_ADDR_PORT: "127.0.0.1:9009" + dns: [1.1.1.1, 1.0.0.1] + devices: + - /dev/net/tun:/dev/net/tun + cap_add: [NET_ADMIN, NET_RAW] + healthcheck: # green only once the IDE's sshd is accepting on :22 in this netns + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9009/healthz"] + interval: 10s + timeout: 15s + retries: 6 + start_period: 30s + restart: unless-stopped + + nvimide: + build: + context: . + args: + USER_UID: ${UID:-1000} + USER_GID: ${GID:-1000} + image: nvimide:latest + network_mode: "service:ts-nvimide" # share the sidecar's tailnet netns + user: root # sshd needs root (privsep + bind :22); drops to the user on login + environment: + - TERM=xterm-256color + - MOBILE=0 + - NVIMIDE_USER=${NVIMIDE_USER:-dev} + - NVIMIDE_PASSWORD=${NVIMIDE_PASSWORD:-} + - NVIMIDE_SSH_PUBKEY=${NVIMIDE_SSH_PUBKEY:-} + # entrypoint seeds /home, then exec's this: provision auth declaratively from + # env (password enables sudo + fallback; pubkey is the real login), host + # keys, sshd in foreground. + command: + - /bin/bash + - -lc + - | + u="${NVIMIDE_USER:-dev}" + [ -n "$$NVIMIDE_PASSWORD" ] && echo "$$u:$$NVIMIDE_PASSWORD" | chpasswd + if [ -n "$$NVIMIDE_SSH_PUBKEY" ]; then + install -d -o "$$u" -g "$$u" -m 700 "/home/$$u/.ssh" + printf '%s\n' "$$NVIMIDE_SSH_PUBKEY" > "/home/$$u/.ssh/authorized_keys" + chown "$$u:$$u" "/home/$$u/.ssh/authorized_keys"; chmod 600 "/home/$$u/.ssh/authorized_keys" + fi + # Persist host keys on the home volume so client known_hosts stays valid + hk="/home/$$u/.ssh/host_keys" + install -d -o "$$u" -g "$$u" -m 700 "$$hk" + if [ ! -e "$$hk/ssh_host_ed25519_key" ]; then + ssh-keygen -q -t ed25519 -f "$$hk/ssh_host_ed25519_key" -N "" + ssh-keygen -q -t rsa -b 4096 -f "$$hk/ssh_host_rsa_key" -N "" + fi + exec /usr/bin/sshd -D -e \ + -h "$$hk/ssh_host_ed25519_key" \ + -h "$$hk/ssh_host_rsa_key" + volumes: + - nvimide-home:/home/dev # stateful home (roaming engine, history, dotfiles). Phase 2: rclone-sync to object storage. + - nvimide-workspace:/workspace # sshfs roaming mount points land here + cap_drop: [ALL] + cap_add: + - SETUID # sudo / sshd privsep + - SETGID + - AUDIT_WRITE + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SYS_ADMIN # sshfs / FUSE roaming mounts + - NET_BIND_SERVICE # sshd binds :22 + - SYS_CHROOT # sshd privilege-separation chroot (/usr/share/empty.sshd) + devices: + - /dev/fuse:/dev/fuse + security_opt: + - apparmor:unconfined + depends_on: + ts-nvimide: + condition: service_healthy + restart: unless-stopped + +volumes: + nvimide-home: + external: true + name: nvimide-ts_arch-dev-home + nvimide-workspace: diff --git a/dotfiles/.aliases b/dotfiles/.aliases new file mode 100644 index 0000000..f4580b6 --- /dev/null +++ b/dotfiles/.aliases @@ -0,0 +1,89 @@ +# ╔══════════════════════════════════════════════════════════════════╗ +# ║ arch-dev :: aliases ║ +# ╚══════════════════════════════════════════════════════════════════╝ + +# ── Navigation ──────────────────────────────────────────────────────────────── +alias ..='cd ..' +alias ...='cd ../..' +alias ....='cd ../../..' +alias ~='cd ~' +alias -- -='cd -' + +# ── ls / eza ────────────────────────────────────────────────────────────────── +alias ls='eza --icons --group-directories-first --color=always' +alias ll='eza -la --icons --git --group-directories-first --color=always' +alias lt='eza --tree --icons --level=2 --color=always' +alias ltt='eza --tree --icons --level=3 --color=always' + +# ── bat (better cat) ────────────────────────────────────────────────────────── +alias cat='bat --style=plain --paging=never --color=never' +alias catn='bat --style=numbers --paging=never --color=never' +alias catp='bat --style=full --paging=never' + +# ── Editor ──────────────────────────────────────────────────────────────────── +alias v='nvim' +alias vi='nvim' +alias vim='nvim' +alias vdiff='nvim -d' + +# ── Git (tearless mobile ref bindings) ──────────────────────────────────────── +alias g='git' +alias gs='git status' +alias gl='git log --oneline --graph --decorate --color' +alias glo='git log --oneline -20' +alias gd='git diff' +alias gds='git diff --staged' +alias ga='git add' +alias gaa='git add -A' +alias gc='git commit' +alias gcm='git commit -m' +alias gp='git push' +alias gpl='git pull' +alias gco='git checkout' +alias gcb='git checkout -b' +alias gst='git stash' +alias gstp='git stash pop' +alias lg='lazygit' + +# ── Shell utils ─────────────────────────────────────────────────────────────── +alias grep='grep --color=auto' +alias rg='ripgrep' +alias src='source ~/.zshrc && echo "↻ zshrc reloaded"' +alias cls='clear' +alias path='echo $PATH | tr ":" "\n"' + +# ── System (btop > top) ─────────────────────────────────────────────────────── +alias top='btop' +alias df='df -h' +alias du='du -h' +alias free='free -h' + +# ── Safety nets ─────────────────────────────────────────────────────────────── +# trash-cli installed from AUR — rm is still available for scripts +alias rm='echo "Use trash or /bin/rm"; false' +alias tp='trash-put' +alias tl='trash-list' + +# ── Python ──────────────────────────────────────────────────────────────────── +alias py='python' +alias py3='python3' +alias pip='pip3' +alias venv='python -m venv' +alias va='source .venv/bin/activate' +alias vd='deactivate' + +# ── Perl ────────────────────────────────────────────────────────────────────── +alias pl='perl' +alias plcheck='perl -wc' # syntax check a file +alias plcritic='perlcritic' + +# ── arch-dev meta ───────────────────────────────────────────────────────────── +alias pacman-list='pacman -Qqe' # list explicitly installed +alias pacman-orphans='pacman -Qtdq' # list orphaned packages +alias aur-update='yay -Syu --aur' + +# ── Snapshot shortcuts ──────────────────────────────────────────────────────── +alias snap='snapshot' +alias snaps='snapshots' +alias snapd='diff-state' +alias snapr='rollback' diff --git a/dotfiles/.config/nvim/init.lua b/dotfiles/.config/nvim/init.lua new file mode 100644 index 0000000..82c3af9 --- /dev/null +++ b/dotfiles/.config/nvim/init.lua @@ -0,0 +1,8 @@ +-- ╔══════════════════════════════════════════════════════════════════╗ +-- ║ arch-dev :: neovim ║ +-- ║ Kanagawa Wave · lazy.nvim · LSP/lint/format · riced IDE ║ +-- ╚══════════════════════════════════════════════════════════════════╝ + +require("options") +require("keymaps") +require("plugins") diff --git a/dotfiles/.config/nvim/lua/keymaps.lua b/dotfiles/.config/nvim/lua/keymaps.lua new file mode 100644 index 0000000..b9a0f1d --- /dev/null +++ b/dotfiles/.config/nvim/lua/keymaps.lua @@ -0,0 +1,103 @@ +-- ── keymaps.lua ─────────────────────────────────────────────────────────────── +local map = vim.keymap.set +local opts = { silent = true } + +-- ── Escape (tearless mobile ref: jj) ───────────────────────────────────────── +map("i", "jj", "", { desc = "Escape insert mode" }) +map("i", "jk", "", { desc = "Escape insert mode" }) + +-- ── File ops (tearless mobile ref) ─────────────────────────────────────────── +map("n", "w", "w", { desc = "Save" }) +map("n", "q", "q", { desc = "Quit" }) +map("n", "x", "wq", { desc = "Save + quit" }) +map("n", "Q", "qa!",{ desc = "Force quit all" }) + +-- ── Clear search highlight ──────────────────────────────────────────────────── +map("n", "", "noh", opts) + +-- ── Better movement ─────────────────────────────────────────────────────────── +map("n", "j", "gj", opts) -- visual line movement +map("n", "k", "gk", opts) +map("n", "H", "^", opts) -- start/end of line +map("n", "L", "$", opts) +map("n", "", "zz", opts) -- keep cursor centred on scroll +map("n", "", "zz", opts) +map("n", "n", "nzzzv", opts) -- keep cursor centred on search +map("n", "N", "Nzzzv", opts) + +-- ── Window navigation (matches tmux bindings) ───────────────────────────────── +map("n", "", "h", opts) +map("n", "", "j", opts) +map("n", "", "k", opts) +map("n", "", "l", opts) + +-- ── Window resize ───────────────────────────────────────────────────────────── +map("n", "", "resize +2", opts) +map("n", "", "resize -2", opts) +map("n", "", "vertical resize -2", opts) +map("n", "", "vertical resize +2", opts) + +-- ── Buffer navigation ───────────────────────────────────────────────────────── +map("n", "", "bprev", opts) +map("n", "", "bnext", opts) +map("n", "bd", "bdelete", { desc = "Delete buffer" }) + +-- ── Indenting keeps selection (tearless mobile ref) ────────────────────────── +map("v", "<", "", ">gv", opts) + +-- ── Move lines ──────────────────────────────────────────────────────────────── +map("v", "J", ":m '>+1gv=gv", opts) +map("v", "K", ":m '<-2gv=gv", opts) + +-- ── Paste without losing register ───────────────────────────────────────────── +map("v", "p", '"_dP', opts) + +-- ── File explorer (tearless mobile ref: Ctrl+n) ─────────────────────────────── +map("n", "", "Oil", { desc = "File explorer (oil)" }) +map("n", "e", "Oil", { desc = "File explorer (oil)" }) + +-- ── Telescope (tearless mobile ref) ────────────────────────────────────────── +map("n", "ff", "Telescope find_files", { desc = "Find files" }) +map("n", "fg", "Telescope live_grep", { desc = "Live grep" }) +map("n", "fb", "Telescope buffers", { desc = "Buffers" }) +map("n", "fh", "Telescope help_tags", { desc = "Help tags" }) +map("n", "fd", "Telescope diagnostics", { desc = "Diagnostics" }) +map("n", "fr", "Telescope oldfiles", { desc = "Recent files" }) +map("n", "fs", "Telescope lsp_document_symbols",{ desc = "Symbols" }) + +-- ── LSP (set globally; lsp.lua on_attach adds buffer-local too) ─────────────── +map("n", "gd", vim.lsp.buf.definition, { desc = "Go to definition" }) +map("n", "gD", vim.lsp.buf.declaration, { desc = "Go to declaration" }) +map("n", "gr", "Telescope lsp_references",{ desc = "References" }) +map("n", "gi", vim.lsp.buf.implementation, { desc = "Implementation" }) +map("n", "K", vim.lsp.buf.hover, { desc = "Hover docs" }) +map("n", "", vim.lsp.buf.signature_help, { desc = "Signature help" }) +map("n", "rn", vim.lsp.buf.rename, { desc = "Rename symbol" }) +map("n", "ca", vim.lsp.buf.code_action, { desc = "Code action" }) +map("n", "f", function() + vim.lsp.buf.format({ async = true }) +end, { desc = "Format buffer" }) + +-- ── Diagnostics ─────────────────────────────────────────────────────────────── +map("n", "[d", vim.diagnostic.goto_prev, { desc = "Prev diagnostic" }) +map("n", "]d", vim.diagnostic.goto_next, { desc = "Next diagnostic" }) +map("n", "dl", vim.diagnostic.open_float, { desc = "Line diagnostics" }) + +-- ── Leap (info only — sets its own mappings) ───────────────────────────────── +-- s → leap (n/x/o) +-- S → leap-from-window (n) +-- Surround defaults (v4): ys{motion}{char}, ds{char}, cs{old}{new} + +-- ── LazyGit ─────────────────────────────────────────────────────────────────── +map("n", "g", "LazyGit", { desc = "LazyGit" }) + +-- ── Which-key group labels ──────────────────────────────────────────────────── +local ok, wk = pcall(require, "which-key") +if ok then + wk.add({ + { "f", group = "find / telescope" }, + { "d", group = "diagnostics" }, + { "b", group = "buffer" }, + }) +end diff --git a/dotfiles/.config/nvim/lua/options.lua b/dotfiles/.config/nvim/lua/options.lua new file mode 100644 index 0000000..7da887e --- /dev/null +++ b/dotfiles/.config/nvim/lua/options.lua @@ -0,0 +1,91 @@ +-- ── options.lua ─────────────────────────────────────────────────────────────── +local o = vim.opt +local g = vim.g + +-- Leader (set before plugins load) +g.mapleader = " " +g.maplocalleader = "\\" + +-- ── Appearance ──────────────────────────────────────────────────────────────── +-- termguicolors emits RGB escape codes. Termius on mobile renders these +-- as purple/magenta backgrounds. Disable when MOBILE=1. +o.termguicolors = (vim.env.MOBILE ~= "1") +o.number = true +o.relativenumber = true +o.cursorline = true +o.cursorcolumn = false +o.signcolumn = "yes:1" +o.scrolloff = 8 +o.sidescrolloff = 8 +o.wrap = false +o.showmode = false -- lualine handles this +o.showcmd = false +o.cmdheight = 1 +o.pumheight = 12 -- max completion menu items +o.conceallevel = 0 + +-- ── Editing ─────────────────────────────────────────────────────────────────── +o.expandtab = true +o.shiftwidth = 4 +o.tabstop = 4 +o.softtabstop = 4 +o.smartindent = true +o.autoindent = true +o.breakindent = true + +-- ── Search ──────────────────────────────────────────────────────────────────── +o.ignorecase = true +o.smartcase = true +o.hlsearch = true +o.incsearch = true + +-- ── Files ───────────────────────────────────────────────────────────────────── +o.undofile = true +o.undodir = vim.fn.stdpath("data") .. "/undo" +o.backup = false +o.swapfile = false +o.autoread = true + +-- ── Behavior ────────────────────────────────────────────────────────────────── +o.clipboard = "unnamedplus" +o.updatetime = 250 +o.timeoutlen = 300 +o.splitright = true +o.splitbelow = true +o.completeopt = "menu,menuone,noselect" +o.mouse = "a" +o.virtualedit = "block" + +-- ── Folding (treesitter) ────────────────────────────────────────────────────── +o.foldmethod = "expr" +o.foldexpr = "nvim_treesitter#foldexpr()" +o.foldenable = false -- open by default, fold manually + +-- ── Netrw (disabled — using oil.nvim) ──────────────────────────────────────── +g.loaded_netrw = 1 +g.loaded_netrwPlugin = 1 + +-- ── Per-filetype tweaks ─────────────────────────────────────────────────────── +vim.api.nvim_create_autocmd("FileType", { + pattern = { "lua", "json", "yaml", "toml" }, + callback = function() + vim.opt_local.shiftwidth = 2 + vim.opt_local.tabstop = 2 + end, +}) + +-- Perl: use perltidy indenting convention +vim.api.nvim_create_autocmd("FileType", { + pattern = { "perl" }, + callback = function() + vim.opt_local.shiftwidth = 4 + vim.opt_local.tabstop = 4 + end, +}) + +-- ── Highlight on yank ───────────────────────────────────────────────────────── +vim.api.nvim_create_autocmd("TextYankPost", { + callback = function() + vim.highlight.on_yank({ higroup = "IncSearch", timeout = 150 }) + end, +}) diff --git a/dotfiles/.config/nvim/lua/plugins.lua b/dotfiles/.config/nvim/lua/plugins.lua new file mode 100644 index 0000000..f1be8e6 --- /dev/null +++ b/dotfiles/.config/nvim/lua/plugins.lua @@ -0,0 +1,48 @@ +-- ── plugins.lua — lazy.nvim bootstrap ──────────────────────────────────────── +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" + +if not vim.loop.fs_stat(lazypath) then + vim.fn.system({ + "git", "clone", "--filter=blob:none", + "https://github.com/folke/lazy.nvim.git", + "--branch=stable", + lazypath, + }) +end +vim.opt.rtp:prepend(lazypath) + +require("lazy").setup({ + { import = "plugins.ui" }, + { import = "plugins.lsp" }, + { import = "plugins.treesitter" }, + { import = "plugins.tools" }, +}, { + -- Baked into the image — don't check for updates at runtime + checker = { enabled = false }, + change_detection = { enabled = false, notify = false }, + -- Performance + performance = { + rtp = { + disabled_plugins = { + "gzip", "matchit", "matchparen", + "netrwPlugin", "tarPlugin", "tohtml", + "tutor", "zipPlugin", + }, + }, + }, + ui = { + border = "rounded", + -- Kanagawa Wave colors in lazy UI + icons = { + cmd = "⌘ ", + config = " ", + event = " ", + ft = " ", + init = " ", + plugin = " ", + source = " ", + start = "▶ ", + task = "✔ ", + }, + }, +}) diff --git a/dotfiles/.config/nvim/lua/plugins/lsp.lua b/dotfiles/.config/nvim/lua/plugins/lsp.lua new file mode 100644 index 0000000..7b72d20 --- /dev/null +++ b/dotfiles/.config/nvim/lua/plugins/lsp.lua @@ -0,0 +1,269 @@ +-- ── plugins/lsp.lua ─────────────────────────────────────────────────────────── +-- nvim 0.11+ native LSP API: vim.lsp.config / vim.lsp.enable +-- lspconfig still used as a config data source (server cmd/root detection) +-- but we drive it through vim.lsp.config, not lspconfig.setup() +-- No vim.lsp.with() — border config goes through vim.lsp.config globals + +return { + + -- ── LSP ─────────────────────────────────────────────────────────────────── + { + "neovim/nvim-lspconfig", + dependencies = { + "hrsh7th/nvim-cmp", + "hrsh7th/cmp-nvim-lsp", + "hrsh7th/cmp-buffer", + "hrsh7th/cmp-path", + "hrsh7th/cmp-cmdline", + "L3MON4D3/LuaSnip", + "saadparwaiz1/cmp_luasnip", + "rafamadriz/friendly-snippets", + "onsails/lspkind.nvim", + }, + config = function() + + -- ── Diagnostics ─────────────────────────────────────────────────── + vim.diagnostic.config({ + virtual_text = { prefix = "●", spacing = 4 }, + signs = true, + underline = true, + update_in_insert = false, + severity_sort = true, + float = { border = "rounded", source = true }, + }) + + -- Diagnostic gutter icons + local signs = { Error = " ", Warn = " ", Hint = "󰌵 ", Info = " " } + for type, icon in pairs(signs) do + local hl = "DiagnosticSign" .. type + vim.fn.sign_define(hl, { text = icon, texthl = hl, numhl = hl }) + end + + -- ── Borders on hover/signature (0.11 way, no vim.lsp.with) ─────── + vim.lsp.config("*", { + handlers = { + ["textDocument/hover"] = function(err, result, ctx, config) + config = config or {} + config.border = "rounded" + vim.lsp.handlers.hover(err, result, ctx, config) + end, + ["textDocument/signatureHelp"] = function(err, result, ctx, config) + config = config or {} + config.border = "rounded" + vim.lsp.handlers.signature_help(err, result, ctx, config) + end, + }, + }) + + -- ── Capabilities (nvim-cmp) ─────────────────────────────────────── + local caps = vim.tbl_deep_extend( + "force", + vim.lsp.protocol.make_client_capabilities(), + require("cmp_nvim_lsp").default_capabilities() + ) + + -- ── on_attach via LspAttach autocmd (0.11 pattern) ─────────────── + vim.api.nvim_create_autocmd("LspAttach", { + group = vim.api.nvim_create_augroup("arch-dev-lsp", { clear = true }), + callback = function(event) + local buf = event.buf + local m = function(k, fn, d) + vim.keymap.set("n", k, fn, { buffer = buf, desc = d, silent = true }) + end + m("gd", vim.lsp.buf.definition, "Definition") + m("gD", vim.lsp.buf.declaration, "Declaration") + m("gi", vim.lsp.buf.implementation, "Implementation") + m("gt", vim.lsp.buf.type_definition, "Type definition") + m("K", vim.lsp.buf.hover, "Hover docs") + m("", vim.lsp.buf.signature_help, "Signature") + m("rn", vim.lsp.buf.rename, "Rename") + m("ca", vim.lsp.buf.code_action, "Code action") + m("f", function() vim.lsp.buf.format({ async = true }) end, "Format") + m("gr", "Telescope lsp_references", "References") + m("ds", "Telescope lsp_document_symbols", "Doc symbols") + end, + }) + + -- ── Servers ─────────────────────────────────────────────────────── + -- pyright — from pacman + vim.lsp.config("pyright", { + capabilities = caps, + settings = { + python = { + analysis = { + typeCheckingMode = "basic", + autoSearchPaths = true, + useLibraryCodeForTypes = true, + diagnosticMode = "openFilesOnly", + }, + }, + }, + }) + vim.lsp.enable("pyright") + + -- bash-language-server — from pacman + vim.lsp.config("bashls", { + capabilities = caps, + filetypes = { "sh", "bash", "zsh" }, + }) + vim.lsp.enable("bashls") + + -- lua_ls — for editing neovim config inside arch-dev + vim.lsp.config("lua_ls", { + capabilities = caps, + settings = { + Lua = { + runtime = { version = "LuaJIT" }, + diagnostics = { globals = { "vim" } }, + workspace = { + library = vim.api.nvim_get_runtime_file("", true), + checkThirdParty = false, + }, + telemetry = { enable = false }, + }, + }, + }) + vim.lsp.enable("lua_ls") + + end, + }, + + -- ── Completion ──────────────────────────────────────────────────────────── + { + "hrsh7th/nvim-cmp", + event = { "InsertEnter", "CmdlineEnter" }, + dependencies = { + "L3MON4D3/LuaSnip", + "saadparwaiz1/cmp_luasnip", + "rafamadriz/friendly-snippets", + "onsails/lspkind.nvim", + "hrsh7th/cmp-nvim-lsp", + "hrsh7th/cmp-buffer", + "hrsh7th/cmp-path", + "hrsh7th/cmp-cmdline", + }, + config = function() + local cmp = require("cmp") + local luasnip = require("luasnip") + local lspkind = require("lspkind") + + require("luasnip.loaders.from_vscode").lazy_load() + + cmp.setup({ + snippet = { + expand = function(args) luasnip.lsp_expand(args.body) end, + }, + window = { + completion = cmp.config.window.bordered(), + documentation = cmp.config.window.bordered(), + }, + mapping = cmp.mapping.preset.insert({ + [""] = cmp.mapping.scroll_docs(-4), + [""] = cmp.mapping.scroll_docs(4), + [""] = cmp.mapping.complete(), + [""] = cmp.mapping.abort(), + [""] = cmp.mapping.confirm({ select = false }), + [""] = cmp.mapping(function(fallback) + if cmp.visible() then + cmp.select_next_item() + elseif luasnip.expand_or_jumpable() then + luasnip.expand_or_jump() + else + fallback() + end + end, { "i", "s" }), + [""] = cmp.mapping(function(fallback) + if cmp.visible() then + cmp.select_prev_item() + elseif luasnip.jumpable(-1) then + luasnip.jump(-1) + else + fallback() + end + end, { "i", "s" }), + }), + sources = cmp.config.sources({ + { name = "nvim_lsp", priority = 1000 }, + { name = "luasnip", priority = 750 }, + { name = "buffer", priority = 500 }, + { name = "path", priority = 250 }, + }), + formatting = { + format = lspkind.cmp_format({ + mode = "symbol_text", + maxwidth = 50, + ellipsis_char = "…", + menu = { + nvim_lsp = "[LSP]", + luasnip = "[Snip]", + buffer = "[Buf]", + path = "[Path]", + }, + }), + }, + }) + + cmp.setup.cmdline({ "/", "?" }, { + mapping = cmp.mapping.preset.cmdline(), + sources = { { name = "buffer" } }, + }) + cmp.setup.cmdline(":", { + mapping = cmp.mapping.preset.cmdline(), + sources = cmp.config.sources( + { { name = "path" } }, + { { name = "cmdline" } } + ), + }) + end, + }, + + -- ── Linting ─────────────────────────────────────────────────────────────── + { + "mfussenegger/nvim-lint", + event = { "BufReadPre", "BufNewFile" }, + config = function() + local lint = require("lint") + lint.linters_by_ft = { + python = { "ruff" }, + sh = { "shellcheck" }, + bash = { "shellcheck" }, + zsh = { "shellcheck" }, + } + vim.api.nvim_create_autocmd( + { "BufWritePost", "BufReadPost", "InsertLeave" }, + { callback = function() lint.try_lint() end } + ) + end, + }, + + -- ── Formatting ──────────────────────────────────────────────────────────── + { + "stevearc/conform.nvim", + event = { "BufWritePre" }, + config = function() + require("conform").setup({ + formatters_by_ft = { + python = { "black" }, + sh = { "shfmt" }, + bash = { "shfmt" }, + lua = { "stylua" }, + }, + format_on_save = { timeout_ms = 1000, lsp_fallback = true }, + notify_on_error = false, + }) + end, + }, + + -- ── Trouble: diagnostics panel ──────────────────────────────────────────── + { + "folke/trouble.nvim", + dependencies = { "nvim-tree/nvim-web-devicons" }, + config = function() + require("trouble").setup({ use_diagnostic_signs = true }) + vim.keymap.set("n", "xx", "TroubleToggle", + { desc = "Toggle trouble" }) + vim.keymap.set("n", "xd", "TroubleToggle document_diagnostics", + { desc = "Document diagnostics" }) + end, + }, +} diff --git a/dotfiles/.config/nvim/lua/plugins/tools.lua b/dotfiles/.config/nvim/lua/plugins/tools.lua new file mode 100644 index 0000000..082dee9 --- /dev/null +++ b/dotfiles/.config/nvim/lua/plugins/tools.lua @@ -0,0 +1,104 @@ +-- ── plugins/tools.lua ───────────────────────────────────────────────────────── +return { + + -- ── LazyGit inside neovim ───────────────────────────────────────────────── + { + "kdheepak/lazygit.nvim", + dependencies = { "nvim-lua/plenary.nvim" }, + }, + + -- ── Diffview ────────────────────────────────────────────────────────────── + { + "sindrets/diffview.nvim", + dependencies = { "nvim-lua/plenary.nvim" }, + config = function() + require("diffview").setup() + vim.keymap.set("n", "gd", "DiffviewOpen", { desc = "Diff view" }) + vim.keymap.set("n", "gh", "DiffviewFileHistory", { desc = "File history" }) + end, + }, + + -- ── Leap: 2-char jump ───────────────────────────────────────────────────── + -- Repo moved GitHub → Codeberg Jan 2026. url= required. + -- API: plain mappings. No setup(), no add/set_default_mappings(). + { + url = "https://codeberg.org/andyg/leap.nvim", + config = function() + vim.keymap.set({ "n", "x", "o" }, "s", "(leap)") + vim.keymap.set("n", "S", "(leap-from-window)") + end, + }, + + -- ── Auto pairs ──────────────────────────────────────────────────────────── + { + "windwp/nvim-autopairs", + event = "InsertEnter", + config = function() + require("nvim-autopairs").setup({ + check_ts = true, + fast_wrap = { map = "" }, + }) + local cmp_autopairs = require("nvim-autopairs.completion.cmp") + require("cmp").event:on("confirm_done", cmp_autopairs.on_confirm_done()) + end, + }, + + -- ── Comment toggling ────────────────────────────────────────────────────── + { + "numToStr/Comment.nvim", + event = "BufReadPost", + dependencies = { "JoosepAlviste/nvim-ts-context-commentstring" }, + config = function() + require("Comment").setup({ + pre_hook = require("ts_context_commentstring.integrations.comment_nvim").create_pre_hook(), + }) + end, + }, + + -- ── Surround ────────────────────────────────────────────────────────────── + -- v4: keymaps table removed from setup(). Use default ys/ds/cs mappings. + -- leap owns `s` but ys/ds/cs don't conflict with it. + { + "kylechui/nvim-surround", + event = "VeryLazy", + config = function() + require("nvim-surround").setup() + end, + }, + + -- ── Todo comments ───────────────────────────────────────────────────────── + { + "folke/todo-comments.nvim", + dependencies = { "nvim-lua/plenary.nvim" }, + config = function() + require("todo-comments").setup() + vim.keymap.set("n", "ft", "TodoTelescope", { desc = "Find TODOs" }) + end, + }, + + -- ── Marks ───────────────────────────────────────────────────────────────── + { + "chentoast/marks.nvim", + event = "BufReadPost", + config = true, + }, + + -- ── Better quickfix ─────────────────────────────────────────────────────── + { + "kevinhwang91/nvim-bqf", + ft = "qf", + config = true, + }, + + -- ── Zen mode ────────────────────────────────────────────────────────────── + { + "folke/zen-mode.nvim", + config = function() + require("zen-mode").setup({ window = { width = 0.85 } }) + vim.keymap.set("n", "z", "ZenMode", { desc = "Zen mode" }) + end, + }, + + -- ── Plenary ─────────────────────────────────────────────────────────────── + { "nvim-lua/plenary.nvim", lazy = true }, +} diff --git a/dotfiles/.config/nvim/lua/plugins/treesitter.lua b/dotfiles/.config/nvim/lua/plugins/treesitter.lua new file mode 100644 index 0000000..3df39ad --- /dev/null +++ b/dotfiles/.config/nvim/lua/plugins/treesitter.lua @@ -0,0 +1,81 @@ +-- ── plugins/treesitter.lua ──────────────────────────────────────────────────── +-- nvim-treesitter rewrote its API (Oct 2025). +-- - nvim-treesitter.configs is GONE +-- - New API: require("nvim-treesitter").setup{} +-- - Does NOT support lazy loading: lazy = false required +-- - treesitter-textobjects is now a fully separate module + +return { + -- ── Core treesitter ─────────────────────────────────────────────────────── + { + "nvim-treesitter/nvim-treesitter", + branch = "main", + lazy = false, + build = ":TSUpdate", + config = function() + require("nvim-treesitter").setup({ + ensure_installed = { + "python", + "bash", "lua", "luadoc", + "json", "yaml", "toml", + "markdown", "markdown_inline", + "regex", + "vim", "vimdoc", + "diff", "gitcommit", "git_rebase", + "ini", "comment", + }, + auto_install = false, -- stateless image, no runtime installs + }) + + -- Highlight: enable per filetype via autocmd (new API pattern) + vim.api.nvim_create_autocmd("FileType", { + callback = function(ev) + -- Skip very large files + local ok, stats = pcall(vim.loop.fs_stat, vim.api.nvim_buf_get_name(ev.buf)) + if ok and stats and stats.size > 500 * 1024 then return end + pcall(vim.treesitter.start) + end, + }) + end, + }, + + -- ── Textobjects: separate plugin, separate module ───────────────────────── + { + "nvim-treesitter/nvim-treesitter-textobjects", + branch = "main", + lazy = false, + dependencies = { + { "nvim-treesitter/nvim-treesitter", branch = "main" }, + }, + config = function() + require("nvim-treesitter-textobjects").setup({ + select = { + enable = true, + lookahead = true, + keymaps = { + ["af"] = "@function.outer", + ["if"] = "@function.inner", + ["ac"] = "@class.outer", + ["ic"] = "@class.inner", + ["aa"] = "@parameter.outer", + ["ia"] = "@parameter.inner", + ["ab"] = "@block.outer", + ["ib"] = "@block.inner", + }, + }, + move = { + enable = true, + set_jumps = true, + goto_next_start = { + ["]f"] = "@function.outer", + ["]c"] = "@class.outer", + }, + goto_previous_start = { + ["[f"] = "@function.outer", + ["[c"] = "@class.outer", + }, + }, + }) + end, + }, +} diff --git a/dotfiles/.config/nvim/lua/plugins/ui.lua b/dotfiles/.config/nvim/lua/plugins/ui.lua new file mode 100644 index 0000000..0241227 --- /dev/null +++ b/dotfiles/.config/nvim/lua/plugins/ui.lua @@ -0,0 +1,285 @@ +-- ── plugins/ui.lua ──────────────────────────────────────────────────────────── +return { + + -- ── Colorscheme: Kanagawa Wave (desktop) / habamax (mobile) ─────────────── + -- kanagawa requires termguicolors which Termius mangles as purple. + -- habamax is built-in, renders cleanly in 256-color (retrobox caused + -- redraw corruption on Termius, habamax does not). + { + "rebelot/kanagawa.nvim", + priority = 1000, + config = function() + if vim.env.MOBILE == "1" then + vim.cmd("colorscheme habamax") + return + end + require("kanagawa").setup({ + compile = true, + undercurl = true, + theme = "wave", + background = { + dark = "wave", + light = "lotus", + }, + colors = { + theme = { + wave = { + ui = { + bg_gutter = "none", + }, + }, + }, + }, + overrides = function(colors) + local theme = colors.theme + return { + Pmenu = { fg = theme.ui.shade0, bg = theme.ui.bg_p1 }, + PmenuSbar = { bg = theme.ui.bg_m1 }, + PmenuThumb = { bg = theme.ui.bg_p2 }, + CursorLine = { bg = theme.ui.bg_p1 }, + } + end, + }) + vim.cmd("colorscheme kanagawa-wave") + end, + }, + + -- ── Statusline: lualine ─────────────────────────────────────────────────── + -- theme = "auto" derives from active colorscheme — works for both + -- kanagawa (desktop) and habamax (mobile) without hex colors + { + "nvim-lualine/lualine.nvim", + dependencies = { "nvim-tree/nvim-web-devicons" }, + config = function() + require("lualine").setup({ + options = { + theme = "auto", + component_separators = { left = "│", right = "│" }, + section_separators = { left = "", right = "" }, + globalstatus = true, + }, + sections = { + lualine_a = { { "mode", icon = "" } }, + lualine_b = { + { "branch", icon = "" }, + { "diff", + symbols = { added = " ", modified = " ", removed = " " }, + }, + }, + lualine_c = { + { "filename", path = 1, symbols = { modified = "●", readonly = "" } }, + }, + lualine_x = { + { "diagnostics", + symbols = { error = " ", warn = " ", info = " ", hint = "󰌵 " }, + }, + "filetype", + }, + lualine_y = { "progress" }, + lualine_z = { { "location", icon = "" } }, + }, + }) + end, + }, + + -- ── Bufferline (desktop only) ───────────────────────────────────────────── + -- Renders truecolor backgrounds for tabs which Termius shows as purple bars. + { + "akinsho/bufferline.nvim", + enabled = function() return vim.env.MOBILE ~= "1" end, + dependencies = { "nvim-tree/nvim-web-devicons" }, + config = function() + require("bufferline").setup({ + options = { + mode = "buffers", + separator_style = "slant", + show_close_icon = false, + show_buffer_close_icons = false, + diagnostics = "nvim_lsp", + diagnostics_indicator = function(_, _, diag) + local icons = { error = " ", warning = " " } + local ret = (diag.error and icons.error .. diag.error .. " " or "") + .. (diag.warning and icons.warning .. diag.warning or "") + return vim.trim(ret) + end, + offsets = { + { filetype = "oil", text = "File Explorer", separator = true }, + }, + }, + }) + end, + }, + + -- ── File explorer: oil.nvim ─────────────────────────────────────────────── + -- Edit the filesystem like a buffer. Very Arch: does one thing, perfectly. + { + "stevearc/oil.nvim", + dependencies = { "nvim-tree/nvim-web-devicons" }, + config = function() + require("oil").setup({ + default_file_explorer = true, + columns = { + "icon", + "permissions", + "size", + "mtime", + }, + view_options = { + show_hidden = true, + }, + keymaps = { + [""] = false, -- don't hijack window nav + [""] = false, + ["q"] = "actions.close", + }, + }) + end, + }, + + -- ── Fuzzy finder: Telescope ─────────────────────────────────────────────── + { + "nvim-telescope/telescope.nvim", + dependencies = { + "nvim-lua/plenary.nvim", + -- Native FZF sorter — faster than the lua one + { "nvim-telescope/telescope-fzf-native.nvim", build = "make" }, + }, + config = function() + local telescope = require("telescope") + local actions = require("telescope.actions") + + telescope.setup({ + defaults = { + prompt_prefix = "❯ ", + selection_caret = "▶ ", + -- Kanagawa-inspired layout + layout_strategy = "horizontal", + layout_config = { prompt_position = "top", width = 0.9 }, + sorting_strategy = "ascending", + mappings = { + i = { + [""] = actions.close, + [""] = actions.move_selection_next, + [""] = actions.move_selection_previous, + }, + }, + }, + }) + + telescope.load_extension("fzf") + end, + }, + + -- ── Which-key ───────────────────────────────────────────────────────────── + { + "folke/which-key.nvim", + event = "VeryLazy", + config = function() + require("which-key").setup({ + preset = "modern", + delay = 400, + icons = { separator = "→" }, + }) + end, + }, + + -- ── Git signs in gutter ─────────────────────────────────────────────────── + { + "lewis6991/gitsigns.nvim", + config = function() + require("gitsigns").setup({ + signs = { + add = { text = "▎" }, + change = { text = "▎" }, + delete = { text = "" }, + topdelete = { text = "" }, + changedelete = { text = "▎" }, + untracked = { text = "▎" }, + }, + on_attach = function(buf) + local gs = require("gitsigns") + local m = function(k, fn, d) + vim.keymap.set("n", k, fn, { buffer = buf, desc = d }) + end + m("]c", gs.next_hunk, "Next hunk") + m("[c", gs.prev_hunk, "Prev hunk") + m("hs", gs.stage_hunk, "Stage hunk") + m("hr", gs.reset_hunk, "Reset hunk") + m("hp", gs.preview_hunk, "Preview hunk") + m("hb", gs.blame_line, "Blame line") + end, + }) + end, + }, + + -- ── Indent guides ───────────────────────────────────────────────────────── + { + "lukas-reineke/indent-blankline.nvim", + main = "ibl", + config = function() + require("ibl").setup({ + indent = { char = "│" }, + scope = { char = "│" }, + }) + -- Link to existing highlight groups instead of hex (mobile-safe) + vim.cmd("hi! link IblIndent Comment") + vim.cmd("hi! link IblScope LineNr") + end, + }, + + + -- ── Dashboard ───────────────────────────────────────────────────────────── + { + "goolord/alpha-nvim", + event = "VimEnter", + config = function() + local alpha = require("alpha") + local dashboard = require("alpha.themes.dashboard") + + -- Mobile-friendly ASCII (heavy blocks render as red bars in Termius) + dashboard.section.header.val = { + " ", + " ┌─┐┬─┐┌─┐┬ ┬ ┌┬┐┌─┐┬ ┬ ", + " ├─┤├┬┘│ ├─┤───│││├┤ └┐┌┘ ", + " ┴ ┴┴└─└─┘┴ ┴ ─┴┘└─┘ └┘ ", + " ", + " riced neovim ide · arch linux · v1.7 ", + } + + dashboard.section.buttons.val = { + dashboard.button("f", " Find file", "Telescope find_files"), + dashboard.button("r", " Recent files", "Telescope oldfiles"), + dashboard.button("g", " Live grep", "Telescope live_grep"), + dashboard.button("e", " Explorer", "Oil"), + dashboard.button("l", " Lazy", "Lazy"), + dashboard.button("q", " Quit", "qa"), + } + + dashboard.section.header.opts.hl = "Comment" + dashboard.section.footer.opts.hl = "NonText" + + alpha.setup(dashboard.opts) + end, + }, + + -- ── Smooth scrolling ────────────────────────────────────────────────────── + { + "karb94/neoscroll.nvim", + config = function() + require("neoscroll").setup({ mappings = { "","","","" } }) + end, + }, + + -- ── Color previews inline (desktop only) ────────────────────────────────── + -- Renders hex as RGB which Termius mangles. Skip on mobile. + { + "NvChad/nvim-colorizer.lua", + enabled = function() return vim.env.MOBILE ~= "1" end, + config = function() + require("colorizer").setup({ "*" }, { mode = "virtualtext" }) + end, + }, + + -- Web devicons (used everywhere) + { "nvim-tree/nvim-web-devicons", lazy = true }, +} diff --git a/dotfiles/.config/roaming/hosts b/dotfiles/.config/roaming/hosts new file mode 100644 index 0000000..1c82271 --- /dev/null +++ b/dotfiles/.config/roaming/hosts @@ -0,0 +1,17 @@ +# ── arch-dev roaming :: host registry ───────────────────────────────────────── +# One host per line. Lines starting with # are ignored. +# +# Fields (whitespace-separated): +# name short alias used for the shell function (e.g. `mail`) +# fqdn tailnet hostname or any reachable host (e.g. mail.tailnet.ts) +# user ssh user (optional, defaults to $ROAMING_DEFAULT_USER or current user) +# mount remote path to mount via sshfs (optional, e.g. / or /srv) +# +# Examples (delete these, add your own): +# +# name fqdn user mount +# mail mail.tailnet.ts root / +# proxy proxy.tailnet.ts admin /etc/caddy +# dev dev-server.tailnet.ts youruser /srv + +# name fqdn user mount diff --git a/dotfiles/.config/roaming/roaming.zsh b/dotfiles/.config/roaming/roaming.zsh new file mode 100644 index 0000000..3d42cfb --- /dev/null +++ b/dotfiles/.config/roaming/roaming.zsh @@ -0,0 +1,153 @@ +# ╔══════════════════════════════════════════════════════════════════╗ +# ║ arch-dev roaming :: engine ║ +# ║ Roaming ~ over the tailnet. Source this from .zshrc. ║ +# ║ ║ +# ║ Reads ~/.config/roaming/hosts and generates a function per ║ +# ║ host so you can run: ║ +# ║ mail → interactive shell on mail ║ +# ║ mail tail -f /var/log/x → run with TTY (works for -f, sudo) ║ +# ║ mount_host mail → sshfs the host's mount at ║ +# ║ /workspace/mail ║ +# ╚══════════════════════════════════════════════════════════════════╝ + +export ROAMING_DIR="${ROAMING_DIR:-$HOME/.config/roaming}" +export ROAMING_HOSTS="${ROAMING_HOSTS:-$ROAMING_DIR/hosts}" +export ROAMING_MOUNT_BASE="${ROAMING_MOUNT_BASE:-/workspace}" +export ROAMING_DEFAULT_USER="${ROAMING_DEFAULT_USER:-$USER}" + +# sshfs options that prevent the dreaded indefinite-freeze on network blips. +# ServerAliveInterval=15 + CountMax=3 → I/O errors out after ~45s instead of +# hanging forever. reconnect re-establishes when the host comes back. +export ROAMING_SSHFS_OPTS="reconnect,ServerAliveInterval=15,ServerAliveCountMax=3,follow_symlinks" + +# ── Internal: resolve a host name to its registry fields ────────────────────── +# Sets globals: _RH_NAME _RH_FQDN _RH_USER _RH_MOUNT +# Returns 1 if not found. +_roaming_lookup() { + local want="$1" + [[ -f "$ROAMING_HOSTS" ]] || return 1 + local name fqdn user mount + while read -r name fqdn user mount; do + [[ -z "$name" || "$name" == \#* ]] && continue + if [[ "$name" == "$want" ]]; then + _RH_NAME="$name" + _RH_FQDN="$fqdn" + _RH_USER="${user:-$ROAMING_DEFAULT_USER}" + _RH_MOUNT="$mount" + return 0 + fi + done < "$ROAMING_HOSTS" + return 1 +} + +# ── Internal: the actual remote-exec logic ──────────────────────────────────── +# No args → interactive shell. Args → run with a TTY (so tail -f / sudo work). +# "Dumb" by design: pipes/redirects run LOCALLY, exactly like plain ssh. +_roaming_exec() { + local name="$1"; shift + if ! _roaming_lookup "$name"; then + echo "roaming: unknown host '$name' (check $ROAMING_HOSTS)" >&2 + return 1 + fi + local target="${_RH_USER}@${_RH_FQDN}" + if [[ $# -eq 0 ]]; then + ssh -t "$target" + else + ssh -t "$target" "$@" + fi +} + +# ── mount_host ───────────────────────────────────────────────────────── +mount_host() { + local name="$1" + if [[ -z "$name" ]]; then + echo "usage: mount_host " >&2 + return 1 + fi + if ! _roaming_lookup "$name"; then + echo "roaming: unknown host '$name'" >&2 + return 1 + fi + if [[ -z "$_RH_MOUNT" ]]; then + echo "roaming: '$name' has no mount path in registry" >&2 + return 1 + fi + local mp="$ROAMING_MOUNT_BASE/$name" + # Already mounted? + if mountpoint -q "$mp" 2>/dev/null; then + echo "roaming: $name already mounted at $mp" + return 0 + fi + mkdir -p "$mp" + echo "roaming: mounting ${_RH_USER}@${_RH_FQDN}:${_RH_MOUNT} → $mp" + sshfs "${_RH_USER}@${_RH_FQDN}:${_RH_MOUNT}" "$mp" \ + -o "$ROAMING_SSHFS_OPTS" \ + && echo "roaming: ✓ mounted" \ + || echo "roaming: ✗ mount failed" >&2 +} + +# ── unmount_host ─────────────────────────────────────────────────────── +unmount_host() { + local name="$1" + if [[ -z "$name" ]]; then + echo "usage: unmount_host " >&2 + return 1 + fi + local mp="$ROAMING_MOUNT_BASE/$name" + if ! mountpoint -q "$mp" 2>/dev/null; then + echo "roaming: $name not mounted" + return 0 + fi + # fusermount3 first; -z (lazy) as fallback for stale mounts + fusermount3 -u "$mp" 2>/dev/null \ + || fusermount3 -uz "$mp" 2>/dev/null \ + || umount "$mp" 2>/dev/null + if mountpoint -q "$mp" 2>/dev/null; then + echo "roaming: ✗ failed to unmount $name (stale? try: fusermount3 -uz $mp)" >&2 + return 1 + fi + rmdir "$mp" 2>/dev/null + echo "roaming: ✓ unmounted $name" +} + +# ── roaming-status ──────────────────────────────────────────────────────────── +roaming-status() { + echo "── registered hosts ──" + if [[ -f "$ROAMING_HOSTS" ]]; then + local name fqdn user mount + while read -r name fqdn user mount; do + [[ -z "$name" || "$name" == \#* ]] && continue + local mp="$ROAMING_MOUNT_BASE/$name" + local mflag="" + mountpoint -q "$mp" 2>/dev/null && mflag=" [mounted: $mp]" + printf " %-10s %s@%s%s\n" "$name" "${user:-$ROAMING_DEFAULT_USER}" "$fqdn" "$mflag" + done < "$ROAMING_HOSTS" + else + echo " (no registry at $ROAMING_HOSTS)" + fi +} + +# ── Generate a function per host ────────────────────────────────────────────── +# After this loop, each registry entry `mail` becomes a `mail` command. +_roaming_generate() { + [[ -f "$ROAMING_HOSTS" ]] || return 0 + local name fqdn user mount + while read -r name fqdn user mount; do + [[ -z "$name" || "$name" == \#* ]] && continue + # Skip if it would clobber an existing command (safety) + if command -v "$name" >/dev/null 2>&1 && ! typeset -f "$name" >/dev/null 2>&1; then + echo "roaming: skipping '$name' — conflicts with existing command" >&2 + continue + fi + eval "$name() { _roaming_exec '$name' \"\$@\"; }" + done < "$ROAMING_HOSTS" +} + +# Reload registry + regenerate functions (call after editing hosts) +roaming-reload() { + _roaming_generate + echo "roaming: reloaded $(grep -cvE '^\s*#|^\s*$' "$ROAMING_HOSTS" 2>/dev/null || echo 0) host(s)" +} + +# Generate on source +_roaming_generate diff --git a/dotfiles/.config/starship-mobile.toml b/dotfiles/.config/starship-mobile.toml new file mode 100644 index 0000000..d1e2127 --- /dev/null +++ b/dotfiles/.config/starship-mobile.toml @@ -0,0 +1,45 @@ +# ╔══════════════════════════════════════════════════════════════════╗ +# ║ arch-dev :: starship-mobile.toml ║ +# ║ Single line · no icons · 256-color · Termius-safe ║ +# ╚══════════════════════════════════════════════════════════════════╝ + +format = "$directory$git_branch$git_status$python $character" +right_format = "$cmd_duration" + +[directory] +style = "bold fg:173" +truncation_length = 3 +truncate_to_repo = true +read_only = "[ro]" +format = "[$path]($style)[$read_only](fg:167) " + +[git_branch] +symbol = "" +style = "fg:106" +format = "[$symbol$branch]($style) " + +[git_status] +style = "fg:167" +conflicted = "!" +ahead = "+${count}" +behind = "-${count}" +modified = "*" +untracked = "?" +staged = "+" +format = "([$all_status$ahead_behind]($style)) " + +[python] +symbol = "py:" +style = "fg:66" +format = "[$symbol$version]($style) " +detect_files = ["*.py", "requirements.txt", "pyproject.toml"] + +[cmd_duration] +min_time = 3000 +style = "fg:242" +format = "[$duration]($style)" + +[character] +success_symbol = "[>](bold fg:106)" +error_symbol = "[>](bold fg:167)" +vimcmd_symbol = "[<](bold fg:139)" diff --git a/dotfiles/.config/starship.toml b/dotfiles/.config/starship.toml new file mode 100644 index 0000000..ee775a6 --- /dev/null +++ b/dotfiles/.config/starship.toml @@ -0,0 +1,76 @@ +# ╔══════════════════════════════════════════════════════════════════╗ +# ║ arch-dev :: starship.toml (desktop) ║ +# ║ Kanagawa Wave · 256-color only · no hex RGB ║ +# ╚══════════════════════════════════════════════════════════════════╝ +# 256-color Kanagawa approximations: +# 110 = crystalBlue 106 = springGreen 139 = oniViolet +# 173 = boatYellow 167 = peachRed 66 = waveAqua +# 242 = fujiGray 250 = fujiWhite 236 = waveBlue + +format = """ +╭─$os$username$hostname$directory$git_branch$git_status$python +╰─$character""" + +right_format = """$cmd_duration$time""" + +[os] +disabled = false +style = "bold fg:139" + +[os.symbols] +Arch = " " + +[username] +show_always = true +style_user = "bold fg:110" +style_root = "bold fg:167" +format = "[$user]($style)" + +[hostname] +ssh_only = false +style = "fg:242" +format = "@[$hostname]($style) " + +[directory] +style = "bold fg:173" +truncation_length = 5 +truncate_to_repo = true +read_only = " " +format = "in [$path]($style)[$read_only](fg:167) " + +[git_branch] +symbol = " " +style = "bold fg:106" +format = "on [$symbol$branch]($style) " + +[git_status] +style = "fg:167" +conflicted = "✖ " +ahead = "⇡${count} " +behind = "⇣${count} " +modified = "✎${count} " +untracked = "?${count} " +staged = "✔${count} " +format = "([$all_status$ahead_behind]($style))" + +[python] +symbol = " " +style = "fg:66" +format = " [$symbol$version( $virtualenv)]($style)" +detect_files = ["*.py", "requirements.txt", "pyproject.toml"] + +[cmd_duration] +min_time = 2000 +style = "fg:242" +format = " took [$duration]($style)" + +[time] +disabled = false +style = "fg:236" +format = " [$time]($style)" +time_format = "%H:%M" + +[character] +success_symbol = "[❯](bold fg:106)" +error_symbol = "[❯](bold fg:167)" +vimcmd_symbol = "[❮](bold fg:139)" diff --git a/dotfiles/.config/tmux/tmux.conf b/dotfiles/.config/tmux/tmux.conf new file mode 100644 index 0000000..47ecf55 --- /dev/null +++ b/dotfiles/.config/tmux/tmux.conf @@ -0,0 +1,92 @@ +# ╔══════════════════════════════════════════════════════════════════╗ +# ║ arch-dev :: tmux.conf (desktop) ║ +# ║ Kanagawa Wave · 256-color · Ctrl+Space prefix · vim-nav ║ +# ╚══════════════════════════════════════════════════════════════════╝ + +# ── Prefix ──────────────────────────────────────────────────────────────────── +unbind C-b +set -g prefix C-Space +bind C-Space send-prefix + +# ── Core behavior ───────────────────────────────────────────────────────────── +set -g mouse on +set -g base-index 1 +setw -g pane-base-index 1 +set -g renumber-windows on +set -sg escape-time 5 +set -g history-limit 50000 +set -g focus-events on +set -g display-time 2000 + +# ── 256 color (no truecolor — Termius mangles it as purple) ─────────────────── +set -g default-terminal "tmux-256color" + +# ── Clipboard (OSC 52) ──────────────────────────────────────────────────────── +set -g set-clipboard on + +# ── Splits in cwd ───────────────────────────────────────────────────────────── +bind | split-window -h -c "#{pane_current_path}" +bind - split-window -v -c "#{pane_current_path}" +bind c new-window -c "#{pane_current_path}" +unbind '"' +unbind % + +# ── Vim-style pane navigation ───────────────────────────────────────────────── +bind h select-pane -L +bind j select-pane -D +bind k select-pane -U +bind l select-pane -R + +# ── Alt+number window switching ─────────────────────────────────────────────── +bind -n M-1 select-window -t 1 +bind -n M-2 select-window -t 2 +bind -n M-3 select-window -t 3 +bind -n M-4 select-window -t 4 +bind -n M-5 select-window -t 5 + +# ── Pane resize ─────────────────────────────────────────────────────────────── +bind -r H resize-pane -L 5 +bind -r J resize-pane -D 5 +bind -r K resize-pane -U 5 +bind -r L resize-pane -R 5 + +# ── Zoom & scroll mode ──────────────────────────────────────────────────────── +bind z resize-pane -Z +bind Enter copy-mode +bind -T copy-mode-vi v send -X begin-selection +bind -T copy-mode-vi y send -X copy-selection-and-cancel +setw -g mode-keys vi + +# ── Reload & detach ─────────────────────────────────────────────────────────── +bind r source-file ~/.tmux.conf \; display "↻ tmux reloaded" +bind d detach-client + +# ── Status bar: 256-color Kanagawa Wave approximations ──────────────────────── +# 234=sumiInk0, 236=sumiInk3, 237=waveBlue1, 110=crystalBlue +# 173=boatYellow2, 106=springGreen, 242=fujiGray +# 250=fujiWhite, 139=oniViolet +set -g status on +set -g status-interval 5 +set -g status-position bottom +set -g status-style "bg=colour234,fg=colour250" + +set -g status-left-length 40 +set -g status-left \ + "#[bold,fg=colour139,bg=colour234] arch-dev#[fg=colour236] │ #[default]" + +set -g status-right-length 60 +set -g status-right \ + "#[fg=colour106]%H:%M #[fg=colour236]│ #[fg=colour110]%a %d %b" + +# Window tabs +set -g window-status-format " #I #W " +set -g window-status-current-format "#[bold,fg=colour173,bg=colour237] #I #W #[default]" +set -g window-status-style "fg=colour242" +set -g window-status-current-style "fg=colour173,bg=colour237" + +# Pane borders +set -g pane-border-style "fg=colour236" +set -g pane-active-border-style "fg=colour110" + +# Message bar +set -g message-style "bg=colour237,fg=colour250,bold" diff --git a/dotfiles/.screenrc b/dotfiles/.screenrc new file mode 100644 index 0000000..316b32e --- /dev/null +++ b/dotfiles/.screenrc @@ -0,0 +1,57 @@ +# ╔══════════════════════════════════════════════════════════════════╗ +# ║ arch-dev :: screenrc (mobile multiplexer) ║ +# ║ Termius-optimized · clean status · keyboard-friendly ║ +# ╚══════════════════════════════════════════════════════════════════╝ + +# ── Basics ──────────────────────────────────────────────────────────────────── +startup_message off +vbell off +defscrollback 10000 +defutf8 on +defencoding utf8 +autodetach on +shell -zsh + +# ── 256 color (no truecolor — Termius mangles RGB as purple) ───────────────── +term xterm-256color +termcapinfo xterm-256color|xterm 'Co#256:AB=\E[48;5;%dm:AF=\E[38;5;%dm' +termcapinfo xterm-256color ti@:te@ + +# ── Status bar ──────────────────────────────────────────────────────────────── +# Keep it readable on a small screen +hardstatus alwayslastline +hardstatus string '%{= kw}[ %{= kc}arch-dev%{= kw} ][ %{= kg}%w%{= kw} ]%=%{= kw}[ %{= kY}%D %d %M%{= kw} ][ %{= kc}%0c%{= kw} ]' + +# ── Keybindings (tearless mobile ref) ──────────────────────────────────────── +# Prefix stays Ctrl+a (default) — familiar and easy on mobile + +# New window: Ctrl+a c +# Next window: Ctrl+a n (or Ctrl+a Space) +# Prev window: Ctrl+a p +# List windows: Ctrl+a " +# Scroll mode: Ctrl+a [ +# Exit scroll: q +# Detach: Ctrl+a d +# Reattach: screen -RD (alias: mux) + +# Make scroll mode vi-friendly +markkeys h=^H:l=^L:$=^E + +# ── Mouse ───────────────────────────────────────────────────────────────────── +# Disabled — Termius touch handles this better directly +# mousetrack on + +# ── Windows ─────────────────────────────────────────────────────────────────── +# Number pad for quick window switch +bind 1 select 1 +bind 2 select 2 +bind 3 select 3 +bind 4 select 4 +bind 5 select 5 + +# Reload +bind r source ~/.screenrc + +# ── Logging ─────────────────────────────────────────────────────────────────── +# deflog on +# logfile $HOME/.screen/screen-%Y%m%d-%n.log diff --git a/dotfiles/.zshrc b/dotfiles/.zshrc new file mode 100644 index 0000000..f055ca3 --- /dev/null +++ b/dotfiles/.zshrc @@ -0,0 +1,207 @@ +# ╔══════════════════════════════════════════════════════════════════╗ +# ║ arch-dev :: zshrc ║ +# ║ Kanagawa Wave · 256-color · mobile-aware · AUR-powered ║ +# ╚══════════════════════════════════════════════════════════════════╝ + +# ── Environment ─────────────────────────────────────────────────────────────── +export EDITOR=nvim +export VISUAL=nvim +export PAGER="less" +export MANPAGER="nvim +Man!" +export TERM=xterm-256color +export PATH="$HOME/.local/bin:$HOME/perl5/bin:/usr/local/bin:$PATH" +export DOCKER_BUILDKIT=1 + +# NO truecolor — Termius renders hex RGB as purple +unset COLORTERM + +# ── History ─────────────────────────────────────────────────────────────────── +HISTSIZE=50000 +SAVEHIST=50000 +HISTFILE="${HISTFILE:-$HOME/.zsh_history}" +setopt share_history hist_ignore_dups hist_ignore_space +setopt hist_reduce_blanks extended_history + +# ── ZSH Options ─────────────────────────────────────────────────────────────── +setopt auto_cd correct interactive_comments no_beep + +# ── Completion ──────────────────────────────────────────────────────────────── +autoload -Uz compinit && compinit +zstyle ':completion:*' menu select +zstyle ':completion:*' matcher-list 'm:{a-z}={A-Z}' +zstyle ':completion:*:descriptions' format '%F{yellow}── %d ──%f' + +# ── Plugins ─────────────────────────────────────────────────────────────────── +source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh +source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh +source /usr/share/zsh/plugins/zsh-history-substring-search/zsh-history-substring-search.zsh + +bindkey '^[[A' history-substring-search-up +bindkey '^[[B' history-substring-search-down + +# ── zsh-syntax-highlighting: 256-color only (no hex/truecolor) ──────────────── +# Kanagawa Wave approximations in 256-color palette +# 110 = steel blue (crystalBlue) 106 = olive green (springGreen) +# 139 = medium purple (oniViolet) 173 = tan (boatYellow) +# 167 = indian red (peachRed) 66 = cadet blue (waveAqua) +ZSH_HIGHLIGHT_STYLES[default]='none' +ZSH_HIGHLIGHT_STYLES[unknown-token]='fg=167,bold' +ZSH_HIGHLIGHT_STYLES[reserved-word]='fg=139' +ZSH_HIGHLIGHT_STYLES[command]='fg=110' +ZSH_HIGHLIGHT_STYLES[builtin]='fg=110' +ZSH_HIGHLIGHT_STYLES[function]='fg=110' +ZSH_HIGHLIGHT_STYLES[alias]='fg=106' +ZSH_HIGHLIGHT_STYLES[path]='fg=66' +ZSH_HIGHLIGHT_STYLES[single-quoted-argument]='fg=173' +ZSH_HIGHLIGHT_STYLES[double-quoted-argument]='fg=173' +ZSH_HIGHLIGHT_STYLES[comment]='fg=242' +ZSH_HIGHLIGHT_STYLES[globbing]='fg=139' + +# ── Autosuggestion style ────────────────────────────────────────────────────── +ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE='fg=242' +ZSH_AUTOSUGGEST_STRATEGY=(history completion) +ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE=20 + +# ── Smart Tools ─────────────────────────────────────────────────────────────── +eval "$(zoxide init zsh)" +source <(fzf --zsh) + +# FZF — 256-color theme +export FZF_DEFAULT_OPTS=" + --color=bg+:236,bg:234,spinner:139,hl:173 + --color=fg:250,header:242,info:110,pointer:167 + --color=marker:139,fg+:250,prompt:110,hl+:167 + --border=rounded --prompt='❯ ' --pointer='▶' --marker='✔' +" +export FZF_DEFAULT_COMMAND='fd --type f --hidden --follow --exclude .git' + +# ── Mobile Detection ────────────────────────────────────────────────────────── +if [[ "${MOBILE:-0}" == "1" ]]; then + export STARSHIP_CONFIG="$HOME/.config/starship-mobile.toml" + alias mux='screen -RD' + alias muxls='screen -ls' + if [[ -z "$STY" && -z "$TMUX" ]]; then + screen -RD arch-dev 2>/dev/null || screen -S arch-dev + fi +else + export STARSHIP_CONFIG="$HOME/.config/starship.toml" + alias mux='tmux new-session -A -s main' + alias muxls='tmux ls' +fi + +# ── Prompt ──────────────────────────────────────────────────────────────────── +eval "$(starship init zsh)" + +# ── Aliases ─────────────────────────────────────────────────────────────────── +source ~/.aliases + +# ── arch-dev snapshot system ────────────────────────────────────────────────── +# Git-backed rollback for /home/dev. State lives in ~/.arch-dev-state/ +# (separate git dir so it doesn't conflict with project git repos). +export ARCH_DEV_GITDIR="$HOME/.arch-dev-state" + +_archdev_git() { + git --git-dir="$ARCH_DEV_GITDIR" --work-tree="$HOME" "$@" +} + +# Take a named snapshot +snapshot() { + if [[ -z "$1" ]]; then + echo "usage: snapshot [message]" + echo " e.g. snapshot node-working 'NodeJS environment with nvm + pnpm'" + return 1 + fi + local name="$1" + local msg="${2:-snapshot: $name}" + _archdev_git add -A + _archdev_git commit -q -m "$msg" || { echo "nothing to snapshot"; return 1; } + _archdev_git tag -f "$name" + echo "✓ snapshot '$name' saved" +} + +# List snapshots +snapshots() { + echo "── arch-dev snapshots ──" + _archdev_git tag -l --format=' %(refname:short) %(taggerdate:short) %(subject)' + echo "" + echo "── recent commits ──" + _archdev_git log --oneline -10 +} + +# Roll back to a snapshot +rollback() { + if [[ -z "$1" ]]; then + echo "usage: rollback " + snapshots + return 1 + fi + local name="$1" + if ! _archdev_git rev-parse "$name" >/dev/null 2>&1; then + echo "✗ snapshot '$name' not found" + snapshots + return 1 + fi + echo "⚠ rolling back to '$name' will discard uncommitted changes" + echo -n " proceed? [y/N] " + read -r reply + [[ "$reply" =~ ^[Yy]$ ]] || { echo "cancelled"; return 1; } + + # Auto-snapshot current state before rollback + _archdev_git add -A 2>/dev/null + _archdev_git commit -q -m "auto: pre-rollback $(date +%Y%m%d-%H%M%S)" 2>/dev/null || true + + _archdev_git reset --hard "$name" + echo "✓ rolled back to '$name'" +} + +# What's changed since last snapshot +diff-state() { + _archdev_git status --short + echo "" + _archdev_git diff --stat HEAD +} + +# Delete a snapshot +unsnapshot() { + if [[ -z "$1" ]]; then + echo "usage: unsnapshot " + return 1 + fi + _archdev_git tag -d "$1" && echo "✓ snapshot '$1' removed" +} + +# Show what's in a snapshot +show-snapshot() { + if [[ -z "$1" ]]; then + echo "usage: show-snapshot " + return 1 + fi + _archdev_git show --stat "$1" +} + +# ── Key Bindings ────────────────────────────────────────────────────────────── +bindkey -e +bindkey '^[[1;5C' forward-word +bindkey '^[[1;5D' backward-word + +# ── nvm (lazy-loaded for fast shell startup) ────────────────────────────────── +export NVM_DIR="$HOME/.nvm" +if [[ -s "$NVM_DIR/nvm.sh" ]]; then + # Add default node to PATH immediately so node CLIs work pre-load + if [[ -f "$NVM_DIR/alias/default" ]]; then + _def="$(cat "$NVM_DIR/alias/default" 2>/dev/null)" + [[ -d "$NVM_DIR/versions/node/v$_def/bin" ]] && \ + export PATH="$NVM_DIR/versions/node/v$_def/bin:$PATH" + fi + # Lazy-load full nvm on first explicit use + nvm() { + unset -f nvm + . "$NVM_DIR/nvm.sh" + nvm "$@" + } +fi + +# ── Roaming ~ (v3) ──────────────────────────────────────────────────────────── +# Fleet-wide remote control + sshfs mounts over the tailnet. +# Registry: ~/.config/roaming/hosts Add hosts: add_new_machine.sh +[[ -f "$HOME/.config/roaming/roaming.zsh" ]] && source "$HOME/.config/roaming/roaming.zsh" diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..04895e4 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# ╔══════════════════════════════════════════════════════════════════╗ +# ║ arch-dev :: entrypoint ║ +# ║ Seeds /home/dev from skel template on first run. ║ +# ║ Updates dotfiles when image is newer than home. ║ +# ║ Initializes git for snapshot/rollback functionality. ║ +# ╚══════════════════════════════════════════════════════════════════╝ + +set -e + +SKEL=/etc/skel-arch-dev +HOME_DIR=/home/dev +MARKER="$HOME_DIR/.arch-dev-initialized" +GITDIR="$HOME_DIR/.arch-dev-state" + +# ── First run: seed everything from skel ────────────────────────────────────── +if [[ ! -f "$MARKER" ]]; then + echo "🌱 arch-dev: first run — seeding home from skeleton..." + # Copy everything except things that should not exist yet + rsync -a --chown=dev:dev "$SKEL/" "$HOME_DIR/" + + # Initialize git repo for snapshots + cd "$HOME_DIR" + git init --quiet --separate-git-dir="$GITDIR" . + git --git-dir="$GITDIR" --work-tree="$HOME_DIR" config user.name "arch-dev" + git --git-dir="$GITDIR" --work-tree="$HOME_DIR" config user.email "dev@arch-dev.local" + + # Write .gitignore to exclude noise + cat > "$HOME_DIR/.gitignore" <<'GITIGNORE' +# Caches and logs +.cache/ +.local/state/ +.local/share/nvim/log +.local/share/nvim/lazy-lock.json.bak +.npm/ +__pycache__/ +*.pyc + +# History (intentional — too noisy to track) +.zsh_history +.lesshst +.bash_history +.python_history + +# Compiled neovim plugin caches +.local/share/nvim/lazy/*/plugin/packer_compiled.lua + +# Trash +.local/share/Trash/ +GITIGNORE + + git --git-dir="$GITDIR" --work-tree="$HOME_DIR" add -A + git --git-dir="$GITDIR" --work-tree="$HOME_DIR" commit -q -m "initial: arch-dev skeleton baked" + git --git-dir="$GITDIR" --work-tree="$HOME_DIR" tag -a "skeleton" -m "factory state" + + touch "$MARKER" + echo "✓ arch-dev: home initialized with snapshot 'skeleton'" + +# ── Subsequent runs: refresh dotfiles only if image is newer ────────────────── +else + SKEL_TIME=$(stat -c %Y "$SKEL/.zshrc" 2>/dev/null || echo 0) + HOME_TIME=$(stat -c %Y "$HOME_DIR/.zshrc" 2>/dev/null || echo 0) + + if [[ "$SKEL_TIME" -gt "$HOME_TIME" ]]; then + echo "🔄 arch-dev: image dotfiles newer than home — updating..." + # Auto-snapshot before updating, in case user wants to revert + if [[ -d "$GITDIR" ]]; then + git --git-dir="$GITDIR" --work-tree="$HOME_DIR" add -A 2>/dev/null + git --git-dir="$GITDIR" --work-tree="$HOME_DIR" \ + commit -q -m "auto: pre-update snapshot $(date +%Y%m%d-%H%M%S)" 2>/dev/null || true + fi + # Refresh only the dotfiles, not user data + rsync -a --chown=dev:dev \ + --exclude='.local/share/nvim/lazy' \ + --exclude='.cache' \ + "$SKEL/" "$HOME_DIR/" + echo "✓ arch-dev: dotfiles updated (auto-snapshot taken first)" + fi +fi + +# ── Hand off ────────────────────────────────────────────────────────────────── +cd "$HOME_DIR" +exec "$@" diff --git a/nvm.zsh.snippet b/nvm.zsh.snippet new file mode 100644 index 0000000..e5b8949 --- /dev/null +++ b/nvm.zsh.snippet @@ -0,0 +1,33 @@ +# ── nvm (added in v2.0) ─────────────────────────────────────────────────────── +# Lazy-load nvm to keep shell startup fast +export NVM_DIR="$HOME/.nvm" +if [[ -s "$NVM_DIR/nvm.sh" ]]; then + # Stub functions that load nvm on first use + nvm() { + unset -f nvm node npm npx + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" + nvm "$@" + } + node() { + unset -f nvm node npm npx + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + node "$@" + } + npm() { + unset -f nvm node npm npx + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + npm "$@" + } + npx() { + unset -f nvm node npm npx + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + npx "$@" + } + # Add default node bin to PATH so node-installed CLIs work without lazy trigger + if [[ -d "$NVM_DIR/alias" ]] && [[ -f "$NVM_DIR/alias/default" ]]; then + DEFAULT_NODE="$(cat "$NVM_DIR/alias/default" 2>/dev/null)" + [[ -d "$NVM_DIR/versions/node/v$DEFAULT_NODE/bin" ]] && \ + PATH="$NVM_DIR/versions/node/v$DEFAULT_NODE/bin:$PATH" + fi +fi diff --git a/roaming-zshrc.snippet b/roaming-zshrc.snippet new file mode 100644 index 0000000..e9a9cd3 --- /dev/null +++ b/roaming-zshrc.snippet @@ -0,0 +1,4 @@ +# ── Roaming ~ (v3) ──────────────────────────────────────────────────────────── +# Fleet-wide remote control + sshfs mounts over the tailnet. +# Registry: ~/.config/roaming/hosts Add hosts: add_new_machine.sh +[[ -f "$HOME/.config/roaming/roaming.zsh" ]] && source "$HOME/.config/roaming/roaming.zsh" diff --git a/scripts/add_new_machine.sh b/scripts/add_new_machine.sh new file mode 100755 index 0000000..b4f0c55 --- /dev/null +++ b/scripts/add_new_machine.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# ╔══════════════════════════════════════════════════════════════════╗ +# ║ arch-dev roaming :: add_new_machine ║ +# ║ Onboard a new host into the roaming fleet: ║ +# ║ 1. push your SSH key (ssh-copy-id) ║ +# ║ 2. register it in ~/.config/roaming/hosts ║ +# ║ 3. optionally test the connection + mount ║ +# ╚══════════════════════════════════════════════════════════════════╝ +set -euo pipefail + +ROAMING_DIR="${ROAMING_DIR:-$HOME/.config/roaming}" +ROAMING_HOSTS="${ROAMING_HOSTS:-$ROAMING_DIR/hosts}" +ROAMING_DEFAULT_USER="${ROAMING_DEFAULT_USER:-$USER}" + +usage() { + cat < [user] [mount] + + name short alias for the host (e.g. mail) + fqdn reachable hostname (e.g. mail.tailnet.ts) + user ssh user (default: $ROAMING_DEFAULT_USER) + mount remote path to expose via sshfs (optional, e.g. / or /srv) + +Examples: + add_new_machine.sh mail mail.tailnet.ts root / + add_new_machine.sh proxy proxy.tailnet.ts admin /etc/caddy + add_new_machine.sh dev dev-server.tailnet.ts youruser +EOF + exit 1 +} + +[[ $# -lt 2 ]] && usage + +NAME="$1" +FQDN="$2" +USER_ARG="${3:-$ROAMING_DEFAULT_USER}" +MOUNT="${4:-}" + +mkdir -p "$ROAMING_DIR" +touch "$ROAMING_HOSTS" + +# ── Guard: already registered? ──────────────────────────────────────────────── +if grep -qE "^\s*${NAME}\s" "$ROAMING_HOSTS" 2>/dev/null; then + echo "✗ '$NAME' is already in $ROAMING_HOSTS" + echo " edit the file directly to change it" + exit 1 +fi + +# ── Step 1: SSH key ─────────────────────────────────────────────────────────── +echo "── Step 1: pushing SSH key to ${USER_ARG}@${FQDN} ──" +echo " (you'll be prompted for the remote password once)" +if ssh-copy-id "${USER_ARG}@${FQDN}"; then + echo " ✓ key installed" +else + echo " ✗ ssh-copy-id failed — aborting, nothing registered" + exit 1 +fi + +# ── Step 2: register ────────────────────────────────────────────────────────── +echo "── Step 2: registering in $ROAMING_HOSTS ──" +printf "%-10s %-26s %-8s %s\n" "$NAME" "$FQDN" "$USER_ARG" "$MOUNT" >> "$ROAMING_HOSTS" +echo " ✓ registered" + +# ── Step 3: verify ──────────────────────────────────────────────────────────── +echo "── Step 3: verifying connection ──" +if ssh -o BatchMode=yes -o ConnectTimeout=8 "${USER_ARG}@${FQDN}" 'echo ok' >/dev/null 2>&1; then + echo " ✓ key-based SSH works" +else + echo " ⚠ key-based SSH test failed (host may still be fine — check manually)" +fi + +echo "" +echo "✓ Done. In a new shell (or after 'roaming-reload'):" +echo " $NAME # interactive shell" +echo " $NAME sudo systemctl status # run a command" +[[ -n "$MOUNT" ]] && echo " mount_host $NAME # sshfs ${MOUNT} → /workspace/$NAME"