From def0eb41047792f6cfd73a94575fdf44ed6f389c Mon Sep 17 00:00:00 2001 From: chodak166 Date: Tue, 13 Oct 2026 05:23:04 +0200 Subject: [PATCH] Added core initializers: zsh, tmux, fzf, neovim, lazygit + basic set --- .gitignore | 3 + PLAN.md | 418 ++++++++++++++++++ README.md | 110 +++++ config/lazygit/config.yml | 25 ++ config/nvim/bootstrap.lua | 26 ++ config/nvim/init.lua | 3 + config/nvim/lua/config/autocmds.lua | 1 + config/nvim/lua/config/clipboard.lua | 53 +++ config/nvim/lua/config/keymaps.lua | 2 + config/nvim/lua/config/lazy.lua | 43 ++ config/nvim/lua/config/options.lua | 32 ++ config/nvim/lua/config/treesitter_parsers.lua | 47 ++ config/nvim/lua/plugins/colorscheme.lua | 28 ++ config/nvim/lua/plugins/indent-blankline.lua | 34 ++ config/nvim/lua/plugins/snacks.lua | 51 +++ config/nvim/lua/plugins/terminal.lua | 8 + config/nvim/lua/plugins/treesitter.lua | 8 + config/tmux/osc52-copy.sh | 24 + config/tmux/tmux.conf | 77 ++++ config/zsh/prompt.zsh | 79 ++++ config/zsh/zshenv | 19 + config/zsh/zshrc | 67 +++ core/fzf.sh | 63 +++ core/lazygit.sh | 72 +++ core/neovim.sh | 148 +++++++ core/tmux.sh | 71 +++ core/zsh.sh | 96 ++++ dockerfiles/_template.build.sh | 121 +++++ dockerfiles/_template.dockerfile | 54 +++ dockerfiles/entrypoint.sh | 8 + dockerfiles/zsh-tmux-neovim.build.sh | 121 +++++ dockerfiles/zsh-tmux-neovim.dockerfile | 54 +++ lib/asset.sh | 87 ++++ lib/common.sh | 114 +++++ lib/distro.sh | 20 + lib/fetch.sh | 61 +++ lib/pkg.sh | 49 ++ lib/pkgmaps/ubuntu-26.04.sh | 20 + lib/run-set.sh | 65 +++ lib/state.sh | 35 ++ sets/zsh-tmux-neovim.sh | 63 +++ versions.env | 5 + 42 files changed, 2485 insertions(+) create mode 100644 .gitignore create mode 100644 PLAN.md create mode 100644 config/lazygit/config.yml create mode 100644 config/nvim/bootstrap.lua create mode 100644 config/nvim/init.lua create mode 100644 config/nvim/lua/config/autocmds.lua create mode 100644 config/nvim/lua/config/clipboard.lua create mode 100644 config/nvim/lua/config/keymaps.lua create mode 100644 config/nvim/lua/config/lazy.lua create mode 100644 config/nvim/lua/config/options.lua create mode 100644 config/nvim/lua/config/treesitter_parsers.lua create mode 100644 config/nvim/lua/plugins/colorscheme.lua create mode 100644 config/nvim/lua/plugins/indent-blankline.lua create mode 100644 config/nvim/lua/plugins/snacks.lua create mode 100644 config/nvim/lua/plugins/terminal.lua create mode 100644 config/nvim/lua/plugins/treesitter.lua create mode 100755 config/tmux/osc52-copy.sh create mode 100644 config/tmux/tmux.conf create mode 100644 config/zsh/prompt.zsh create mode 100644 config/zsh/zshenv create mode 100644 config/zsh/zshrc create mode 100755 core/fzf.sh create mode 100755 core/lazygit.sh create mode 100755 core/neovim.sh create mode 100755 core/tmux.sh create mode 100755 core/zsh.sh create mode 100755 dockerfiles/_template.build.sh create mode 100644 dockerfiles/_template.dockerfile create mode 100755 dockerfiles/entrypoint.sh create mode 100755 dockerfiles/zsh-tmux-neovim.build.sh create mode 100644 dockerfiles/zsh-tmux-neovim.dockerfile create mode 100755 lib/asset.sh create mode 100755 lib/common.sh create mode 100755 lib/distro.sh create mode 100755 lib/fetch.sh create mode 100755 lib/pkg.sh create mode 100755 lib/pkgmaps/ubuntu-26.04.sh create mode 100755 lib/run-set.sh create mode 100755 lib/state.sh create mode 100755 sets/zsh-tmux-neovim.sh create mode 100644 versions.env diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3fc2b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/ +*.pyc +__pycache__/ diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..27361b8 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,418 @@ +# shell-inits - Repository Plan + +One-command bootstrap of fully configured shell environments on fresh VMs and in +Docker containers, plus pre-baked Docker images. + +``` +# on any fresh VM (ubuntu 24.04/26.04, alpine 3.21): +curl -fsSL https://git.example.com/shell-inits/raw/main/sets/zsh-tmux-neovim.sh | bash + +# pre-baked image: +./dockerfiles/zsh-tmux-neovim.build.sh # -> local image +docker run -it shell-inits/zsh-tmux-neovim:ubuntu-24.04 # lands in tmux session 'main' +``` + +## 1. Goals + +1. Single command to get a working, configured environment on a new VM or in a + running container (tmux, zsh+fzf, lazyvim+lazygit, custom yazi build, ...). +2. Distro-agnostic entry points: same URL works on `ubuntu:24.04`, + `ubuntu:26.04`, `alpine:3.21` (auto-detected). +3. Layered composition: atomic core initializers, combined into toolsets, + reusable in Dockerfiles. +4. Pre-built, fully initialized Docker images (nvim plugins already synced, + tmux plugins installed) that need no network at runtime. A private Docker + registry will distribute them in the future; until then `build.sh` only + builds locally. +5. Source portability: scripts/configs can be served from GitHub, a private git + server, or a company internal HTTP server without changing any code. +6. Trivially extensible: adding a new tool, a new toolset, or a new distro + should each be a small, localized addition. + +Non-goals (for now): managing dotfiles unrelated to these tools, secret +management, multi-user provisioning, X/GUI tooling. + +## 2. Design principles + +- **Layered**: core (one tool) -> set (composition) -> image (docker). Each + layer only knows the one below it through stable contracts. +- **Idempotent**: every script can be re-run safely; already-satisfied steps + are skipped based on state markers. +- **Same URL everywhere**: distro differences never leak into entry points; + they live in `lib/distro.sh` + `lib/pkgmaps/`. +- **Everything is a parameter**: base URL, uid, force/unattended flags are env + vars/flags with sane defaults; nothing is hardcoded to one host. +- **Plain shell only**: bash + curl/wget. No build step, no dependencies beyond + what the target distro provides or the scripts themselves install. +- **Simple over featureful**: no zsh plugin manager, no elaborate framework. + These environments exist to get jobs done on remote servers - well-configured + and nice looking, with minimal moving parts. +- **Fail loudly and early**: `set -euo pipefail` everywhere, unsupported distros + abort with a clear message listing supported ones. + +## 3. Architecture + +``` + +--------------------------------------------+ + | lib/ (shared runtime, fetched on demand) | + | common, distro, pkg, fetch, asset, state | + +----------------------+---------------------+ + | + Layer 1: core/ Layer 2: sets/ Layer 3: dockerfiles/ + one tool per script ordered composition .dockerfile + tmux, zsh, fzf, neovim, --> zsh-fzf, zsh-tmux, --> .build.sh + lazygit, yazi ... zsh-tmux-neovim ... (http server + build) +``` + +- `core/*.sh` can be run standalone (`curl BASE/core/tmux.sh | bash`) or as + building blocks. +- `sets/*.sh` are thin, self-bootstrapping wrappers that declare an ordered + component list and delegate to a shared runner in `lib/`. Curl-able directly, + so each toolset keeps a stable URL. +- `dockerfiles/*` build images by running one set script inside the build, then + executing headless bootstraps (nvim `Lazy! sync`, tmux plugin install, ...) so + images work fully offline at runtime. + +## 4. Directory tree + +``` +shell-inits/ +├── README.md # usage, quickstart, supported matrix +├── PLAN.md # this document +├── Makefile # convenience: serve, build SET=.. DISTRO=.., test +├── versions.env # versions of tools fetched as release binaries +│ # (only where pinning is free; see 8.4) +│ +├── lib/ # shared shell library (sourced by every script) +│ ├── common.sh # strict mode, logging (log/warn/die), arg parsing, +│ │ # sudo/root abstraction (as_root), HOME resolution +│ │ # (honors SUDO_USER), BASE_URL resolution +│ ├── distro.sh # /etc/os-release -> DISTRO_SLUG (ubuntu-24.04, +│ │ # ubuntu-26.04, alpine-3.21); unsupported -> abort +│ ├── pkg.sh # pkg_update / pkg_install dispatching to apt|apk +│ ├── pkgmaps/ # per-distro package name tables (abstract -> real) +│ │ ├── ubuntu-24.04.sh +│ │ ├── ubuntu-26.04.sh +│ │ └── alpine-3.21.sh +│ ├── fetch.sh # fetch [dest]: curl|wget fallback, +│ │ # local-checkout mode, optional checksum verify +│ ├── asset.sh # install GitHub release binaries, arch-aware +│ │ # (x86_64/aarch64), musl vs glibc asset selection +│ ├── state.sh # idempotency: mark/query component versions in +│ │ # ~/.local/state/shell-inits/ +│ └── run-set.sh # shared set runner (executes COMPONENTS in order, +│ # applies post hooks, prints summary) +│ +├── core/ # LAYER 1: one atomic initializer per tool +│ ├── tmux.sh # tmux + config (plugin-free pastel theme, +│ │ # vi copy mode, OSC52 clipboard over ssh) +│ ├── zsh.sh # zsh + config + vendored plugins via plain +│ │ # git clones, no plugin manager +│ # (autosuggestions, syntax-highlighting) +│ │ # prompt: cached git info, big-repo safe +│ ├── fzf.sh # fzf (distro pkg or release binary) +│ ├── neovim.sh # nvim + lazyvim starter + personal config layer +│ ├── lazygit.sh # lazygit binary + config +│ └── yazi.sh # custom yazi build: prebuilt release by default, +│ # SHELL_INITS_BUILD_FROM_SOURCE=1 -> cargo build +│ +├── sets/ # LAYER 2: toolsets (stable curl-able entry points) +│ ├── zsh-fzf.sh +│ ├── zsh-tmux.sh +│ ├── zsh-tmux-neovim.sh # + lazyvim + lazygit +│ └── zsh-tmux-neovim-yazi.sh # the "everything" example +│ +├── config/ # config payloads installed by core scripts +│ │ # (single source of truth: this repo) +│ ├── tmux/ # tmux.conf, tpm plugin list +│ ├── zsh/ # .zshrc, .zshenv, prompt, plugin list +│ ├── nvim/ # lazyvim starter + lua/ personal layer +│ ├── lazygit/ # config.yml +│ └── yazi/ # yazi.toml, keymap.toml, theme.toml +│ +├── dockerfiles/ # LAYER 3: image targets (set <-> dockerfile 1:1) +│ ├── _template.dockerfile # copy & edit to add a new image target +│ ├── _template.build.sh +│ ├── entrypoint.sh # shared entrypoint: exec tmux new -As main +│ ├── zsh-tmux.dockerfile +│ ├── zsh-tmux.build.sh +│ ├── zsh-tmux-neovim.dockerfile +│ ├── zsh-tmux-neovim.build.sh +│ └── ... +│ +└── tests/ # see section 10 for the strategy + ├── run.sh # builds target image(s) + runs asserts, distro matrix + ├── core/ # one dockerfile+test per core initializer + │ ├── tmux.dockerfile # minimal image: distro + ONE core initializer + │ ├── tmux.test.sh # asserts only that tool's outcomes + │ ├── zsh.dockerfile / zsh.test.sh + │ ├── fzf.dockerfile / fzf.test.sh + │ ├── neovim.dockerfile / neovim.test.sh + │ ├── lazygit.dockerfile / lazygit.test.sh + │ └── yazi.dockerfile / yazi.test.sh + └── sets/ # ONLY sets with steps beyond the sum of cores + ├── zsh-tmux-neovim.dockerfile + └── zsh-tmux-neovim.test.sh # asserts only set extras (plugin sync, session boot) +``` + +## 5. Layer 1: core initializers (contract) + +Every `core/.sh`: + +1. Starts with the standard preamble (see 8.1): resolves BASE_URL, ensures a + downloader exists, fetches and sources `lib/common.sh`, `lib/distro.sh`, + `lib/pkg.sh`, `lib/fetch.sh`, `lib/state.sh`. +2. Declares its contract in a header comment: what it installs, env vars it + honors, which distros need special handling. +3. Is idempotent: checks state markers first, skips if satisfied unless + `SHELL_INITS_FORCE=1`. +4. Declares dependencies via `ensure_core ` (e.g. `zsh.sh` ensures + `fzf.sh`; `neovim.sh` ensures `lazygit.sh` as a lazyvim dependency) so + standalone execution always converges. +5. Installs distro-specific bits behind `case "$DISTRO_SLUG"` guards: + - `fzf`: apt/apk package where new enough, otherwise release binary. + - `neovim`: release binary or distro package. + - `yazi`: release binary (musl/glibc asset per distro, arch-aware) or + optional from-source cargo build. +6. Deploys config from `${BASE_URL}/config//` (configs live in this repo, + no external dotfiles source) into the target user's home, never root's home + when invoked with sudo. +7. Ends with `state_mark ` and a one-line summary. + +## 6. Layer 2: sets / toolsets + +`sets/.sh` is a thin curl-able wrapper with the same preamble, then: + +```sh +SET_NAME="zsh-tmux-neovim" +COMPONENTS=(fzf zsh tmux lazygit neovim) # order matters +# optional: SET_POSTHOOKS=(set_default_shell print_next_steps) +source_run_set # fetches lib/run-set.sh and executes COMPONENTS +``` + +- The runner reuses core state markers, so sets over already-partially + provisioned machines only fill gaps. +- Post hooks handle cross-cutting finishers: `chsh -s zsh`, shell-completion + warmup, motd/summary with next steps. +- Naming convention: components joined with `-` (`zsh-tmux-neovim`). The set + name doubles as the docker image target name in `dockerfiles/`. + +## 7. Layer 3: docker images + +Every image target is a pair (exact naming as requested): + +- `dockerfiles/.dockerfile` +- `dockerfiles/.build.sh` + +### 7.1 Dockerfile sketch (template) + +```dockerfile +ARG DISTRO=ubuntu-24.04 +FROM ${DISTRO} # ubuntu:24.04 | ubuntu:26.04 | alpine:3.21 +ARG SHELL_INITS_BASE_URL +ARG USERNAME=dev # non-root user, never root-only images +ARG USER_UID=1000 # owner of everything the build produces +ARG USER_GID=1000 # override for company setups (1001, ...) +# 1. downloader + ca-certificates (apt or apk depending on DISTRO) +# 2. create group/user with USER_UID:USER_GID + sudo/doas equivalent +# 3. RUN curl -fsSL ${SHELL_INITS_BASE_URL}/sets/.sh | \ +# SHELL_INITS_BASE_URL=${SHELL_INITS_BASE_URL} bash (as USERNAME) +# 4. headless bootstraps (as USERNAME) so the image is fully initialized: +# nvim --headless "+Lazy! sync" +qa +# tmux new-session -d '~/.tmux/plugins/tpm/bin/install_plugins' || true +# yazi --version; lazygit --version # warm first-run dirs +# 5. cleanup package caches to slim the image +COPY dockerfiles/entrypoint.sh /usr/local/bin/shell-inits-entrypoint +USER ${USERNAME} +WORKDIR /home/${USERNAME} +ENTRYPOINT ["/usr/local/bin/shell-inits-entrypoint"] # exec tmux new -As main +``` + +The UID contract: because the user and every file in its home are created with +`USER_UID`/`USER_GID` at build time, anything the container later produces +(volumes, mounted workspaces, files shared with the host) is owned by the uid +the image was built for. + +### 7.2 build.sh responsibilities + +1. Resolve repo root; refuse to run outside a checkout. +2. Start a local HTTP server on a free port serving the repo root + (`python3 -m http.server `), `trap` cleanup. +3. `docker build --network=host + --build-arg SHELL_INITS_BASE_URL=http://127.0.0.1: + --build-arg USER_UID= --build-arg USER_GID= + --build-arg DISTRO=` (host networking avoids + `host.docker.internal` issues on Linux; documented fallback for + Docker Desktop). +4. `--uid N` flag (default 1000) so images can be baked for the user's real uid; + `--distro` repeatable, `--all` for the whole matrix. +5. Done: image is tagged locally as `shell-inits/:`. Print the + exact `docker run` command. No export step - a private registry will be added + later and publishing becomes a `--push` flag (naming is already + registry-ready). + +## 8. Cross-cutting mechanics + +### 8.1 Base URL resolution (portability core) + +Resolution order, evaluated in every entry script: + +1. `--base-url ` CLI arg (passed via `bash -s -- ...` when piped), +2. `SHELL_INITS_BASE_URL` env, +3. local-checkout autodetect: if the running script exists on disk, use repo + root directly (`fetch` then reads files instead of HTTP). This makes + `./sets/zsh-tmux.sh` work for development and inside `COPY`-based builds + without a server. +4. embedded `DEFAULT_BASE_URL` constant (set once per deployment of this repo; + points at the private git server raw endpoint by default). + +`lib/fetch.sh` normalizes the difference between "raw file root" URL shapes +(github raw, gitea/gitlab `raw/main`, plain http dir) with a small +`raw_url ` helper; base is always stored as the raw root. Configs +are always fetched relative to BASE_URL from this repo's `config/`. + +### 8.2 Distro detection and package abstraction + +- `lib/distro.sh` parses `/etc/os-release` -> `DISTRO_SLUG`: + `ubuntu-24.04`, `ubuntu-26.04`, `alpine-3.21`. Unknown -> hard error listing + supported slugs and where to add a new `lib/pkgmaps/.sh`. +- `pkg_install ` looks up `lib/pkgmaps/${DISTRO_SLUG}.sh` + (associative `PKG[abstract]=real-package`) and calls apt/apk. When a package + is missing or too old, core scripts fall back to `asset_install_github` + from `lib/asset.sh`. +- Alpine specifics handled in one place: musl asset selection, busybox wget + quirks, root-by-default (no sudo), openrc absence. + +### 8.3 Root vs user + +- Containers: run as root, config goes to the created user's home (with the + requested `USER_UID`/`USER_GID`). +- VMs: run as regular user; privileged ops go through `as_root` (sudo on + ubuntu, doas/su on alpine), config always lands in the invoking user's home + (via `SUDO_USER`), default shell switched for that user only. + +### 8.4 State, idempotency, versions + +- Markers: `~/.local/state/shell-inits/.ver` (content = installed + version) for user-scope, `/var/lib/shell-inits/` for system-scope. +- `SHELL_INITS_FORCE=1` reinstalls; `SHELL_INITS_SKIP` list excludes + components from a set run. +- Pinning is kept only where it costs nothing: `versions.env` pins just the + GitHub-release binaries that `asset.sh` downloads (where the version is a + natural part of the URL anyway). apt/apk packages are used as-is from the + distro - pinning those would add maintenance overhead for no real benefit. + +### 8.5 Logging and robustness + +- Uniform prefix `[[set|tool] distro]` per line; `SHELL_INITS_LOG` writes a + full transcript next to state markers. +- Unattended mode (`SHELL_INITS_UNATTENDED=1`, default inside docker builds) + suppresses prompts; interactive confirmations only for destructive steps. + +## 9. End-to-end flows + +### 9.1 Fresh VM (online) + +``` +curl -fsSL $BASE/sets/zsh-tmux-neovim.sh | bash -s -- --base-url $BASE + -> preamble resolves BASE_URL, ensures downloader, sources lib + -> distro detect (e.g. ubuntu-26.04) + -> for c in fzf zsh tmux lazygit neovim: fetch+run core/$c.sh (idempotent) + -> post hooks: chsh, summary + next steps +``` + +No args needed when the embedded DEFAULT_BASE_URL is correct; `--base-url` (or +env) covers company-internal mirrors without any code change. + +### 9.2 Inside a running container (online) + +Same command as 9.1; scripts detect root, skip sudo, install into root or +`SHELL_INITS_USER` if provided. + +### 9.3 Building images + +``` +./dockerfiles/zsh-tmux-neovim.build.sh --distro ubuntu-24.04 --distro alpine-3.21 --uid 1001 + -> local http.server serves repo (trap cleanup) + -> docker build --network=host (set script + headless bootstraps inside, + everything owned by uid 1001) + -> tagged shell-inits/zsh-tmux-neovim:ubuntu-24.04 etc., run command printed +``` + +### 9.4 Running pre-built images + +``` +docker run -it shell-inits/zsh-tmux-neovim:ubuntu-24.04 + -> entrypoint execs: tmux new -As main (attaches to or creates session 'main') +``` + +Plugins, configs, binaries: all already baked in; no network needed at runtime. +Until the registry exists, ad-hoc offline transfer with a manual `docker save` +is still possible - it is just not build.sh's job. + +### 9.5 Development / testing loop + +``` +./sets/zsh-tmux.sh # local mode, no HTTP server needed +make test-core TOOL=tmux # core assert for every distro +make test-set SET=zsh-tmux-neovim # set-specific extras only +``` + +## 10. Testing strategy + +Principle: **test each behavior exactly once, at the lowest layer that owns +it.** Overlapping set tests would re-verify what core tests already cover. + +- **Core tests (always, full distro matrix)**: `tests/core/.dockerfile` + builds a minimal image (base distro + that single core initializer, served + via the same local-http flow). `tests/core/.test.sh` asserts only that + tool's outcomes: binary present and runs (`tmux -V`, `zsh -c true`, + `nvim --headless +qa`, `lazygit --version`, `yazi --version`), config files + deployed into the right home, and correct file ownership. +- **Set tests (only sets with steps beyond the sum of their cores)**: + currently just `zsh-tmux-neovim`, because of the headless plugin + initialization. Its test asserts only the extras: lazyvim plugins exist under + `~/.local/share/nvim`, nvim starts clean headlessly, tmux session named + `main` boots via the entrypoint. It never re-checks tmux/zsh/fzf basics - if + those broke, the corresponding core test fails first. +- **Sets without extra steps** (e.g. `zsh-fzf`, `zsh-tmux`) get no image and no + test of their own; their correctness is composition, already implied by core + tests, and they remain curl-able for interactive use. +- Runner: `tests/run.sh / [--distro ...]` orchestrates build + + assert + cleanup for the whole matrix; Makefile wraps the common cases. + +## 11. Extensibility recipes + +- **New core initializer**: add `core/.sh` following the contract (5), + optionally `config//`, pkgmap entries, a line in `versions.env` if it + uses release binaries, and `tests/core/.{dockerfile,test.sh}`. + Immediately usable standalone and from any set. +- **New toolset**: add `sets/.sh` wrapper listing `COMPONENTS`; stable + URL appears automatically. Add a dockerfile pair / tests only if the set + performs extra initialization steps. +- **New image target**: copy `dockerfiles/_template.*` to `.*`. +- **New distro**: add `lib/pkgmaps/.sh`, extend `lib/distro.sh` allow + list, add alpine-style special cases only where truly needed. + +## 12. Naming conventions summary + +| Thing | Pattern | Example | +|-------------------|-------------------------------------------|----------------------------------| +| distro slug | `-` | `ubuntu-24.04`, `alpine-3.21` | +| core script | `core/.sh` | `core/lazygit.sh` | +| core test | `tests/core/.{dockerfile,test.sh}` | `tests/core/yazi.test.sh` | +| set script | `sets/.sh` | `sets/zsh-tmux-neovim.sh` | +| set test | `tests/sets/.{dockerfile,test.sh}` | only for sets with extra steps | +| dockerfile pair | `dockerfiles/.{dockerfile,build.sh}` | `dockerfiles/zsh-tmux.build.sh` | +| image | `shell-inits/:` | `shell-inits/dev:alpine-3.21` | +| env prefix | `SHELL_INITS_*` | `SHELL_INITS_BASE_URL` | + +## 13. Future work + +- Private Docker registry: `build.sh --push` once the registry exists; image + naming (12) is already registry-ready. +- Async prompt git info (zle -F / zpty worker) instead of the current cached + synchronous approach; big-repo performance is already covered by + core.untrackedCache + core.fsmonitor. +- Multi-arch image builds (x86_64 + aarch64) if arm hosts or apple silicon + docker desktops become a target. diff --git a/README.md b/README.md index e69de29..4335dde 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,110 @@ +# shell-inits + +One-command bootstrap of a configured shell environment (tmux + zsh/fzf + +LazyVim/lazygit) on fresh VMs and containers. See [PLAN.md](PLAN.md) for the +full architecture. + +## Supported distros + +| distro | status | +|---------------|--------------| +| ubuntu-26.04 | implemented | +| ubuntu-24.04 | planned | +| alpine-3.21 | planned | + +## Usage + +On a fresh VM (as a regular user with sudo, or as root): + +```sh +curl -fsSL /sets/zsh-tmux-neovim.sh | bash -s -- --base-url +``` + +`` is the raw-files root of this repository (private git server, +GitHub mirror or any HTTP server serving a checkout). Alternative forms: + +```sh +SHELL_INITS_BASE_URL= curl -fsSL /sets/zsh-tmux-neovim.sh | bash +# or from a checkout: +./sets/zsh-tmux-neovim.sh +``` + +Individual components can be installed the same way (`core/tmux.sh`, +`core/zsh.sh`, `core/fzf.sh`, `core/lazygit.sh`, `core/neovim.sh`). + +### Environment variables + +| var | effect | +|------------------------------|---------------------------------------------------| +| `SHELL_INITS_BASE_URL` | base URL of the scripts (raw-files root) | +| `SHELL_INITS_USER` | configure this user when running as root (docker) | +| `SHELL_INITS_SKIP` | comma-separated components to skip (e.g. `tmux`) | +| `SHELL_INITS_FORCE` | reinstall even if state markers say "done" | +| `SHELL_INITS_NVIM_BOOTSTRAP` | `1` = sync nvim plugins headlessly right away | + +## Docker images + +Build a fully initialized image (nvim plugins synced at build time, works +offline at runtime) and drop straight into tmux session `main`: + +```sh +./dockerfiles/zsh-tmux-neovim.build.sh # -> shell-inits/zsh-tmux-neovim:ubuntu-26.04 +./dockerfiles/zsh-tmux-neovim.build.sh --uid 1001 # bake everything for uid 1001 +docker run -it shell-inits/zsh-tmux-neovim:ubuntu-26.04 +``` + +The image is fully initialized at build time (all lazyvim plugins, the +treesitter parser set compiled in, spell files and the tree-sitter CLI), so it +needs no network at runtime. For offline distribution: + +```sh +./dockerfiles/zsh-tmux-neovim.build.sh --save # also writes dist/zsh-tmux-neovim_ubuntu-26.04.tar.gz +docker load -i dist/zsh-tmux-neovim_ubuntu-26.04.tar.gz +``` + +The build script starts a temporary local HTTP server serving this repository +and builds with `--network=host`, so the image is produced by the exact same +`curl | bash` flow used on VMs. Useful options: `--distro ` (repeatable), +`--all`, `--gid`, `--tag`, `--no-cache`, and `--base-url ` for remote +docker hosts or Docker Desktop (where `--network=host` does not work, serve the +repo yourself and pass `http://host.docker.internal:`). + +Inside the container: non-root user `dev` with passwordless sudo, entrypoint +runs `tmux new -As main` (set `SHELL_INITS_NO_TMUX=1` for a plain zsh), and +because the user and all files are created with the requested uid/gid, anything +the container writes (mounted volumes included) matches your host user. + +### Clipboard sharing (no X11/Wayland needed) + +Copy/paste between the container/VM and your workstation uses the OSC 52 +terminal escape sequence, which travels through the same byte stream as the +terminal display - it works over ssh, `docker run -it` and inside tmux without +mounting any sockets. Requirements: + +- your terminal must support OSC 52 (kitty, wezterm, iTerm2, alacritty, + windows terminal do; kitty has it enabled by default), +- tmux runs with a UTF-8 locale (set automatically; an already-running tmux + server keeps its original environment - restart it after upgrading). + +How it works: + +- tmux copy mode: `y` copies into the tmux buffer (paste with `prefix ]`) and + writes the selection to your workstation clipboard via + `~/.config/tmux/osc52-copy.sh`, which talks to the client terminals directly, +- nvim: yanks go to the `+` register through neovim's built-in OSC 52 provider + (activated whenever no local clipboard tool exists); tmux forwards it. + +## What you get + +- **zsh**: two-line pastel prompt (`login@host ../end/of/path `), git + info cached per command (refreshed only after commands or directory changes) + and kept fast in huge repos via `core.untrackedCache` + `core.fsmonitor`, + autosuggestions, syntax highlighting, fzf bindings - no plugin manager, no + nerd fonts required. +- **tmux**: vi copy mode, clipboard shared with your workstation over ssh via + OSC 52, pastel (catppuccin macchiato) status theme, no plugins. +- **LazyVim**: catppuccin theme, snacks explorer tweaks, OSC52 clipboard over + ssh, 42 treesitter parsers + spell files precompiled during image builds + (works fully offline). Language-specific plugins are intentionally out of + scope for now. +- **lazygit**: release binary with matching pastel theme. diff --git a/config/lazygit/config.yml b/config/lazygit/config.yml new file mode 100644 index 0000000..ace231b --- /dev/null +++ b/config/lazygit/config.yml @@ -0,0 +1,25 @@ +# shell-inits lazygit config - catppuccin macchiato pastel theme, no icons +gui: + showRandomTip: false + border: rounded + theme: + activeBorderColor: + - "#c6a0f6" # mauve + searchingActiveBorderColor: + - "#8aadf4" # blue + inactiveBorderColor: + - "#6e738d" # overlay0 + optionsTextColor: + - "#8aadf4" # blue + selectedLineBgColor: + - "#363a4f" # surface0 + selectedRangeBgColor: + - "#494d64" # surface1 + cherryPickedCommitBgColor: + - "#494d64" # surface1 + cherryPickedCommitFgColor: + - "#a6da95" # green + unstagedChangesColor: + - "#ed8796" # red + defaultFgColor: + - "#cad3f5" # text diff --git a/config/nvim/bootstrap.lua b/config/nvim/bootstrap.lua new file mode 100644 index 0000000..b3ed2c8 --- /dev/null +++ b/config/nvim/bootstrap.lua @@ -0,0 +1,26 @@ +-- shell-inits bootstrap.lua - headless initialization used by image builds +-- and SHELL_INITS_NVIM_BOOTSTRAP=1 (see core/neovim.sh). Lives outside lua/ +-- so it never loads during normal startup. Run AFTER "+Lazy! sync". +local parsers = require("config.treesitter_parsers") + +local ok_ts, ts = pcall(require, "nvim-treesitter") +if ok_ts then + pcall(ts.install, parsers) +end + +-- prebuilt parsers download asynchronously; wait (max 5 min) until every +-- language on the list is usable +local missing = #parsers +local timed_out = not vim.wait(480000, function() + missing = 0 + for _, lang in ipairs(parsers) do + if not pcall(vim.treesitter.language.inspect, lang) then + missing = missing + 1 + end + end + return missing == 0 +end, 3000) + +local found = #vim.api.nvim_get_runtime_file("parser/*.so", true) +print(("shell-inits bootstrap: %d/%d parsers ready, %d parser files in runtime, timed out: %s") + :format(#parsers - missing, #parsers, found, tostring(timed_out))) diff --git a/config/nvim/init.lua b/config/nvim/init.lua new file mode 100644 index 0000000..2a864f5 --- /dev/null +++ b/config/nvim/init.lua @@ -0,0 +1,3 @@ +-- shell-inits: early OSC52 clipboard setup must run before lazy.nvim +require("config.clipboard") +require("config.lazy") diff --git a/config/nvim/lua/config/autocmds.lua b/config/nvim/lua/config/autocmds.lua new file mode 100644 index 0000000..b2f665f --- /dev/null +++ b/config/nvim/lua/config/autocmds.lua @@ -0,0 +1 @@ +-- shell-inits: autocmds on top of LazyVim defaults diff --git a/config/nvim/lua/config/clipboard.lua b/config/nvim/lua/config/clipboard.lua new file mode 100644 index 0000000..a04ffc5 --- /dev/null +++ b/config/nvim/lua/config/clipboard.lua @@ -0,0 +1,53 @@ +-- shell-inits: OSC 52 clipboard whenever there is no local clipboard tool - +-- that covers ssh sessions (SSH_TTY) and containers/VMs without xclip/wl-copy. +-- +-- Inside tmux, copies are routed through ~/.config/tmux/osc52-copy.sh, which +-- writes the escape sequence to the attached client terminals directly: +-- tmux's own pane-OSC52 forwarding is unreliable across versions. Outside +-- tmux the sequence simply goes to stdout (the terminal itself). +local has_clipboard_tool = function() + local tools = { "xclip", "xsel", "wl-copy", "wl-paste", "pbcopy", "win32yank", "clip.exe" } + for _, tool in ipairs(tools) do + if vim.fn.executable(tool) == 1 then + return true + end + end + return false +end + +if vim.env.SSH_TTY or not has_clipboard_tool() then + local osc52 = require("vim.ui.clipboard.osc52") + local helper = vim.fn.expand("~/.config/tmux/osc52-copy.sh") + + local make_copy + if vim.env.TMUX and vim.fn.executable(helper) == 1 and vim.system then + make_copy = function() + return function(lines, _) + -- errors here would break yanking; the helper is best effort + pcall(function() + vim.system({ helper }, { stdin = table.concat(lines, "\n") }):wait() + end) + end + end + else + make_copy = function(reg) + return osc52.copy(reg) + end + end + + vim.g.clipboard = { + name = "OSC 52", + copy = { + ["+"] = make_copy("+"), + ["*"] = make_copy("*"), + }, + paste = { + -- paste needs the terminal to answer an OSC 52 query; unsupported + -- terminals just yield an empty paste. + ["+"] = osc52.paste("+"), + ["*"] = osc52.paste("*"), + }, + } +end + +vim.opt.clipboard = "unnamedplus" diff --git a/config/nvim/lua/config/keymaps.lua b/config/nvim/lua/config/keymaps.lua new file mode 100644 index 0000000..6c9c72e --- /dev/null +++ b/config/nvim/lua/config/keymaps.lua @@ -0,0 +1,2 @@ +-- shell-inits: keymaps on top of LazyVim defaults +-- (terminal keymaps are registered by lua/plugins/terminal.lua) diff --git a/config/nvim/lua/config/lazy.lua b/config/nvim/lua/config/lazy.lua new file mode 100644 index 0000000..99fc8c4 --- /dev/null +++ b/config/nvim/lua/config/lazy.lua @@ -0,0 +1,43 @@ +-- shell-inits: lazy.nvim bootstrap + LazyVim +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" +if not (vim.uv or vim.loop).fs_stat(lazypath) then + local lazyrepo = "https://github.com/folke/lazy.nvim.git" + local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath }) + if vim.v.shell_error ~= 0 then + vim.api.nvim_echo({ + { "Failed to clone lazy.nvim:\n", "ErrorMsg" }, + { out, "WarningMsg" }, + { "\nPress any key to exit..." }, + }, true, {}) + vim.fn.getchar() + os.exit(1) + end +end +vim.opt.rtp:prepend(lazypath) + +require("lazy").setup({ + spec = { + { "LazyVim/LazyVim", import = "lazyvim.plugins" }, + { import = "plugins" }, + }, + defaults = { + lazy = false, + version = false, + }, + install = { colorscheme = { "catppuccin", "habamax" } }, + checker = { + enabled = true, + notify = false, + }, + performance = { + rtp = { + disabled_plugins = { + "gzip", + "tarPlugin", + "tohtml", + "tutor", + "zipPlugin", + }, + }, + }, +}) diff --git a/config/nvim/lua/config/options.lua b/config/nvim/lua/config/options.lua new file mode 100644 index 0000000..05dc385 --- /dev/null +++ b/config/nvim/lua/config/options.lua @@ -0,0 +1,32 @@ +-- shell-inits: options on top of LazyVim defaults + +-- sync clipboard with the + register by default (OSC52 over ssh, see clipboard.lua) +vim.opt.clipboard = "unnamedplus" + +-- spellcheck: polish + english, change runtime by :opt.spelllang = { "en_gb" } +vim.opt.spelllang = { "pl", "en" } + +-- do not hide markup (**, ``` etc) +vim.opt.conceallevel = 0 + +-- break lines at word boundaries +vim.opt.wrap = true +vim.opt.linebreak = true + +-- copy the indent of the current line when inserting a new line +vim.opt.autoindent = true + +-- disable inlay hints (they clutter the view) +vim.g.lazyvim_no_inlay_hints = true + +-- disable autoformatting not to generate large diffs +vim.g.autoformat = false + +-- list mode characters: dots for spaces/tabs and trailing spaces +vim.opt.listchars = { + tab = "· ", + trail = "·", + nbsp = "+", + extends = ">", + precedes = "<", +} diff --git a/config/nvim/lua/config/treesitter_parsers.lua b/config/nvim/lua/config/treesitter_parsers.lua new file mode 100644 index 0000000..0325f27 --- /dev/null +++ b/config/nvim/lua/config/treesitter_parsers.lua @@ -0,0 +1,47 @@ +-- shell-inits: single source of truth for treesitter parsers to install. +-- LazyVim's default set; kept explicit so image builds can pre-install +-- exactly this list and nvim works fully offline. +return { + "bash", + "c", + "cpp", + "css", + "diff", + "dockerfile", + "gitcommit", + "go", + "gomod", + "gosum", + "gowork", + "gpg", + "html", + "http", + "ini", + "json", + "json5", + "lua", + "luadoc", + "luap", + "make", + "markdown", + "markdown_inline", + "nginx", + "passwd", + "pem", + "printf", + "python", + "query", + "readline", + "regex", + "requirements", + "rust", + "sql", + "toml", + "tsx", + "javascript", + "typescript", + "vim", + "vimdoc", + "xml", + "yaml", +} diff --git a/config/nvim/lua/plugins/colorscheme.lua b/config/nvim/lua/plugins/colorscheme.lua new file mode 100644 index 0000000..9ec3ebb --- /dev/null +++ b/config/nvim/lua/plugins/colorscheme.lua @@ -0,0 +1,28 @@ +-- shell-inits: catppuccin (macchiato) as the default colorscheme +return { + { + "catppuccin/nvim", + name = "catppuccin", + lazy = false, + priority = 1000, + opts = { + flavour = "macchiato", + integrations = { + cmp = true, + gitsigns = true, + treesitter = true, + notify = true, + mini = true, + snacks = true, + telescope = { enabled = true }, + which_key = true, + }, + }, + }, + { + "LazyVim/LazyVim", + opts = { + colorscheme = "catppuccin", + }, + }, +} diff --git a/config/nvim/lua/plugins/indent-blankline.lua b/config/nvim/lua/plugins/indent-blankline.lua new file mode 100644 index 0000000..3a50ebc --- /dev/null +++ b/config/nvim/lua/plugins/indent-blankline.lua @@ -0,0 +1,34 @@ +-- shell-inits: subtle pastel rainbow indent guides, no scope line. +-- The IblIndentN groups are defined here (catppuccin does not ship them and +-- ibl refuses to start when they are missing). Static catppuccin macchiato +-- colors, re-applied on every colorscheme switch. +local define_highlights = function() + local palette = { "#8aadf4", "#c6a0f6", "#f5bde6", "#a6da95", "#eed49f", "#8bd5ca" } + for i, color in ipairs(palette) do + vim.api.nvim_set_hl(0, ("IblIndent%d"):format(i), { fg = color, nocombine = true }) + end + vim.api.nvim_set_hl(0, "IblIndent", { fg = "#6e738d", nocombine = true }) +end + +return { + "lukas-reineke/indent-blankline.nvim", + main = "ibl", + init = function() + define_highlights() + vim.api.nvim_create_autocmd("ColorScheme", { callback = define_highlights }) + end, + opts = { + indent = { + highlight = { + "IblIndent", + "IblIndent1", + "IblIndent2", + "IblIndent3", + "IblIndent4", + "IblIndent5", + "IblIndent6", + }, + }, + scope = { enabled = false }, + }, +} diff --git a/config/nvim/lua/plugins/snacks.lua b/config/nvim/lua/plugins/snacks.lua new file mode 100644 index 0000000..d4795a4 --- /dev/null +++ b/config/nvim/lua/plugins/snacks.lua @@ -0,0 +1,51 @@ +-- shell-inits: snacks.nvim tweaks - explorer/picker visibility, notifications +return { + "folke/snacks.nvim", + opts = { + notifier = { + timeout = 10000, + }, + picker = { + hidden = true, + ignored = true, + sources = { + explorer = { + lazy = true, + hidden = true, + ignored = true, + exclude = { + "**/.git", + "**/.DS_Store", + }, + -- Yank paths from the explorer: Y relative, gy absolute + win = { + list = { + keys = { + ["Y"] = "copy_relative", + ["gy"] = "copy_absolute", + ["L"] = { { "pick_win", "jump" }, mode = { "n", "i" } }, + }, + }, + }, + actions = { + copy_absolute = function(_, item) + local path = item.file + vim.fn.setreg('"', path) + vim.fn.setreg("+", path) + Snacks.notify.info("Copied absolute path: " .. path) + end, + copy_relative = function(_, item) + local path = vim.fn.fnamemodify(item.file, ":.") + vim.fn.setreg('"', path) + vim.fn.setreg("+", path) + Snacks.notify.info("Copied relative path: " .. path) + end, + }, + }, + files = { + hidden = true, + }, + }, + }, + }, +} diff --git a/config/nvim/lua/plugins/terminal.lua b/config/nvim/lua/plugins/terminal.lua new file mode 100644 index 0000000..25d6ec0 --- /dev/null +++ b/config/nvim/lua/plugins/terminal.lua @@ -0,0 +1,8 @@ +-- shell-inits: terminal UX layered on snacks (tt, ft, ) +return { + "chodak166/snacks-terminal-ctl.nvim", + name = "snacks-terminal-ctl.nvim", + dependencies = { "folke/snacks.nvim" }, + event = "VeryLazy", + opts = {}, +} diff --git a/config/nvim/lua/plugins/treesitter.lua b/config/nvim/lua/plugins/treesitter.lua new file mode 100644 index 0000000..5590941 --- /dev/null +++ b/config/nvim/lua/plugins/treesitter.lua @@ -0,0 +1,8 @@ +-- shell-inits: pin the treesitter parser list (see config/treesitter_parsers); +-- image builds pre-install all of them so no downloads are needed at runtime. +return { + "nvim-treesitter/nvim-treesitter", + opts = { + ensure_installed = require("config.treesitter_parsers"), + }, +} diff --git a/config/tmux/osc52-copy.sh b/config/tmux/osc52-copy.sh new file mode 100755 index 0000000..a7b4304 --- /dev/null +++ b/config/tmux/osc52-copy.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# shell-inits osc52-copy.sh - copy stdin to the workstation clipboard via OSC 52. +# Works over ssh, docker run -it and nested sessions: the escape sequence is +# plain terminal output, no X11/Wayland sockets required (the terminal itself +# must support OSC 52 - kitty, wezterm, iterm2, alacritty, windows terminal). +# +# Used by the tmux copy-mode `y` binding. Writing directly to the tmux client +# ttys makes it independent of tmux's own clipboard emission (which is buggy +# in some versions). +set -eu + +data=$(base64 | tr -d '\n') +seq=$(printf '\033]52;c;%s\007' "$data") + +if [ -n "${TMUX:-}" ] && command -v tmux >/dev/null 2>&1; then + # write to every attached tmux client's terminal + for tty in $(tmux list-clients -F '#{client_tty}' 2>/dev/null); do + if [ -w "$tty" ]; then + printf '%s' "$seq" >"$tty" 2>/dev/null || true + fi + done +elif [ -w /dev/tty ]; then + printf '%s' "$seq" >/dev/tty 2>/dev/null || true +fi diff --git a/config/tmux/tmux.conf b/config/tmux/tmux.conf new file mode 100644 index 0000000..0e41a2a --- /dev/null +++ b/config/tmux/tmux.conf @@ -0,0 +1,77 @@ +# shell-inits tmux.conf +# Pastel theme (catppuccin macchiato palette), no plugins, no patched fonts. +# Works over ssh: clipboard is shared with the local terminal via OSC 52 +# (supported by kitty, wezterm, iterm2, alacritty, windows terminal, ...). + +# --- base -------------------------------------------------------------------- +set -g default-command /usr/bin/zsh +set -g default-terminal "tmux-256color" +# UTF-8: the tmux server must inherit a UTF-8 locale (zshenv exports a +# C.UTF-8 fallback); for tmux < 3.1 force the flag explicitly (3.1+ dropped it) +set -gq utf on +# truecolor + OSC52 passthrough for terminals whose terminfo lacks the entries +set -ga terminal-overrides ",*:Tc" +set -as terminal-overrides ',*:Ms=\E]52;c;%p2%s\7' + +set -g escape-time 10 +set -g history-limit 100000 +set -g focus-events on +set -g display-time 2000 + +# --- copy mode (vi) ------------------------------------------------------------ +setw -g mode-keys vi +set -g set-clipboard on +set -g allow-passthrough on + +bind -T copy-mode-vi v send -X begin-selection +# yank -> tmux buffer (paste with prefix ]) AND the workstation clipboard via +# OSC 52; the helper writes to the client terminals directly, so it does not +# depend on tmux's own (version-dependent) clipboard emission +bind -T copy-mode-vi y send -X copy-pipe-and-cancel "~/.config/tmux/osc52-copy.sh" + +# single-line scrolling in copy mode +bind -T copy-mode-vi WheelUpPane send-keys -X scroll-up +bind -T copy-mode-vi WheelDownPane send-keys -X scroll-down + +# scrolling with shift + pgup/pgdn +bind -n S-Pageup copy-mode -u +bind -n S-Pagedown send-keys Pagedown + +# Home/End for line navigation (emacs-style regardless of app) +bind -n Home send-key C-a +bind -n End send-key C-e + +# --- windows / panes ----------------------------------------------------------- +set -g mouse off +set -g status-interval 5 +set -g automatic-rename on +set -g automatic-rename-format '#{b:pane_current_command}:#{b:pane_current_path}' +set -g renumber-windows on + +# --- pastel theme (catppuccin macchiato) --------------------------------------- +# base #24273a mantle #1e2030 crust #181926 surface0 #363a4f surface1 #494d64 +# text #cad3f5 muted #6e738d mauve #c6a0f6 blue #8aadf4 green #a6da95 +# peach #f5a97f rosewater #f4dbd6 +set -g status on +set -g status-position bottom +set -g status-style "bg=#24273a,fg=#cad3f5" + +set -g status-left-length 40 +set -g status-left "#[fg=#181926,bg=#c6a0f6,bold] #S #[fg=#c6a0f6,bg=#24273a] " + +set -g status-right-length 80 +set -g status-right "#[fg=#6e738d]#{s|$HOME|~|:pane_current_path} #[fg=#8aadf4]%H #[fg=#a6da95]%H:%M " + +setw -g window-status-format "#[fg=#6e738d] #I #W " +setw -g window-status-current-format "#[fg=#181926,bg=#8aadf4,bold] #I #W " +setw -g window-status-separator "" +setw -g window-status-activity-style "fg=#f5a97f,bg=#24273a" +setw -g monitor-activity on +set -g visual-activity off + +set -g mode-style "bg=#363a4f,fg=#cad3f5" +set -g message-style "bg=#363a4f,fg=#c6a0f6" +set -g message-command-style "bg=#363a4f,fg=#8aadf4" +set -g pane-border-style "fg=#6e738d" +set -g pane-active-border-style "fg=#c6a0f6" +set -g clock-mode-colour "#a6da95" diff --git a/config/zsh/prompt.zsh b/config/zsh/prompt.zsh new file mode 100644 index 0000000..50e71dc --- /dev/null +++ b/config/zsh/prompt.zsh @@ -0,0 +1,79 @@ +# shell-inits prompt.zsh - two-line pastel prompt with cached git info. +# +# Big-repo safety strategy (synchronous by design, async planned for later): +# - git info is computed only when the directory changed or a command ran +# (preexec), never on plain prompt redraws; +# - a single `git status --porcelain=v2 --branch` call provides branch + +# dirty (incl. untracked) + ahead/behind at once; +# - core/zsh.sh enables core.untrackedCache and core.fsmonitor globally, which +# is what keeps `git status` fast even in very large repositories. +# +# No patched/nerd fonts needed (plain glyphs: box drawing, ‹› ↑↓ ✱ ❯ …). +# NOTE: prompt strings must never nest %F{...} (or any braced escape) inside +# ${var:-...} substitutions - the prompt parser ends the substitution at the +# first '}'. Colored segments are precomputed in precmd instead. + +setopt PROMPT_SUBST + +typeset -g _si_git_info="" +typeset -g _si_git_pwd="" +typeset -g _si_git_refresh=1 +typeset -g _si_venv="" + +_si_git_render() { + local input=$1 branch="" oid="" line dirty=0 ahead=0 behind=0 + while IFS= read -r line; do + case $line in + '# branch.head '*) branch=${line##'# branch.head '} ;; + '# branch.oid '*) oid=${line##'# branch.oid '} ;; + '# branch.ab +'*) + ahead=${line#'# branch.ab +'} + ahead=${ahead%% -*} + behind=${line##*' -'} + ;; + [12u?]*) dirty=1 ;; + esac + done <<<"$input" + + local out=" %F{176}" + if [[ -n $branch && $branch != "(detached)" ]]; then + out+=${branch//\%/%%} + elif [[ -n $oid ]]; then + out+="@${oid[1,8]}" + else + printf '' + return + fi + out+="%f" + (( dirty )) && out+=" %F{210}✱%f" + (( ahead )) && out+=" %F{158}↑${ahead}%f" + (( behind )) && out+=" %F{216}↓${behind}%f" + printf '%s' "$out" +} + +_si_prompt_precmd() { + if [[ -n ${VIRTUAL_ENV:-} ]]; then + _si_venv=" %F{114}‹${VIRTUAL_ENV:t}›%f" + else + _si_venv="" + fi + + if (( _si_git_refresh )) || [[ $PWD != $_si_git_pwd ]]; then + _si_git_refresh=0 + _si_git_pwd=$PWD + _si_git_info=$(_si_git_render "$(git status --porcelain=v2 --branch 2>/dev/null)") + fi +} + +_si_prompt_preexec() { + _si_git_refresh=1 +} + +autoload -Uz add-zsh-hook +add-zsh-hook precmd _si_prompt_precmd +add-zsh-hook preexec _si_prompt_preexec + +# pastel palette (256-color): 110 sapphire, 75 blue, 176 mauve, 114 mint, +# 158 spring, 216 peach, 213 pink, 210 rose +PROMPT=$'╭─%(!.%F{210}.%F{110})%n@%m%f %F{75}%40<…<%~%<<%f${_si_venv}${_si_git_info}\n╰─%F{213}%(!.#.❯)%f ' +RPROMPT='%(?..%F{210}exit %?%f)' diff --git a/config/zsh/zshenv b/config/zsh/zshenv new file mode 100644 index 0000000..16028d8 --- /dev/null +++ b/config/zsh/zshenv @@ -0,0 +1,19 @@ +# shell-inits zshenv - minimal, sourced by every zsh instance (keep it fast) + +# UTF-8 fallback: fresh VMs/containers have no locale set, and a non-UTF-8 +# locale makes tmux render box-drawing/unicode prompts as underscores. +# C.UTF-8 is always available (glibc >= 2.35, musl) and needs no locale-gen. +if [[ -z ${LANG:-} ]]; then + export LANG=C.UTF-8 +fi + +typeset -U path +for _d in "$HOME/.local/bin" "$HOME/.cargo/bin" "$HOME/go/bin"; do + [[ -d "$_d" ]] && path=("$_d" $path) +done +unset _d + +if (( $+commands[nvim] )); then + export EDITOR=nvim + export VISUAL=nvim +fi diff --git a/config/zsh/zshrc b/config/zsh/zshrc new file mode 100644 index 0000000..5c3a784 --- /dev/null +++ b/config/zsh/zshrc @@ -0,0 +1,67 @@ +# shell-inits zshrc - interactive configuration (no plugin manager) + +# --- input -------------------------------------------------------------------- +bindkey -e +WORDCHARS=${WORDCHARS//[\/]} + +# --- history -------------------------------------------------------------------- +setopt CLOBBER +setopt EXTENDED_HISTORY +setopt INC_APPEND_HISTORY +setopt HIST_IGNORE_ALL_DUPS +unsetopt SHARE_HISTORY + +export HISTTIMEFORMAT="[%F %T] " +export HISTFILE=$HOME/.zsh_history +export HISTSIZE=1000000 +export SAVEHIST=1000000 + +# --- completion ------------------------------------------------------------------- +autoload -Uz compinit +compinit + +# --- fzf (key bindings + completion + pastel look) --------------------------------- +if (( $+commands[fzf] )); then + # fzf >= 0.48 ships its own integration + if ! source <(fzf --zsh 2>/dev/null); then + [[ -r /usr/share/doc/fzf/examples/key-bindings.zsh ]] && \ + source /usr/share/doc/fzf/examples/key-bindings.zsh + [[ -r /usr/share/doc/fzf/examples/completion.zsh ]] && \ + source /usr/share/doc/fzf/examples/completion.zsh + fi + export FZF_DEFAULT_OPTS="\ +--height=40% --layout=reverse --border=rounded --info=inline \ +--color=fg:#cad3f5,bg:#24273a,hl:#c6a0f6 \ +--color=fg+:#cad3f5,bg+:#363a4f,hl+:#c6a0f6 \ +--color=info:#91d7e3,prompt:#8aadf4,pointer:#f5bde6 \ +--color=marker:#a6da95,spinner:#f5a97f,header:#8bd5ca" +fi + +# --- vendored plugins (plain git clones, see core/zsh.sh) --------------------------- +if [[ -f $HOME/.zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh ]]; then + ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE='fg=242' + source $HOME/.zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh +fi + +# --- prompt -------------------------------------------------------------------------- +[[ -f $HOME/.zsh/prompt.zsh ]] && source $HOME/.zsh/prompt.zsh + +# zsh-syntax-highlighting must be sourced last +if [[ -f $HOME/.zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh ]]; then + ZSH_HIGHLIGHT_HIGHLIGHTERS=(main brackets) + typeset -A ZSH_HIGHLIGHT_STYLES + ZSH_HIGHLIGHT_STYLES[command]='fg=#666666' + source $HOME/.zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh +fi + +# --- aliases ---------------------------------------------------------------------------- +alias ..='cd ..' +alias ...='cd ../..' +if (( $+commands[eza] )); then + alias ll='eza -lah --group-directories-first --color=always | less' + alias l='eza -lah --group-directories-first --color=always | less' +else + alias ll='ls -lah --group-directories-first --color=auto' + alias l='ls -lah --group-directories-first --color=auto' +fi +alias gl="git log --graph --abbrev-commit --decorate --format=format:'%C(bold blue)%h%C(reset) - %C(bold green)(%ar)%C(reset) %C(white)%s%C(reset) %C(dim white)- %an%C(reset)%C(bold yellow)%d%C(reset)' --all" diff --git a/core/fzf.sh b/core/fzf.sh new file mode 100755 index 0000000..1babb7d --- /dev/null +++ b/core/fzf.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# core/fzf.sh - fuzzy finder, deployed from distro packages when available. +# Standalone: curl -fsSL /core/fzf.sh | bash -s -- --base-url + +set -euo pipefail + +# --- shell-inits preamble v1 (identical across core/ and sets/ scripts) ----- +DEFAULT_BASE_URL="" # per-deployment fallback, e.g. https://git.example.com/shell-inits/raw/main +SI_BASE_URL=${SHELL_INITS_BASE_URL:-} +_si_self=${BASH_SOURCE[0]:-} +while [[ $# -gt 0 ]]; do + case $1 in + --base-url) SI_BASE_URL=${2:?--base-url needs a value}; shift 2 ;; + *) shift ;; + esac +done +if [[ -z $SI_BASE_URL && -n $_si_self && -f $_si_self ]]; then + SI_MODE=local + SI_ROOT=$(cd -- "$(dirname -- "$_si_self")/.." && pwd) +elif [[ -z $SI_BASE_URL && -n $DEFAULT_BASE_URL ]]; then + SI_MODE=remote + SI_BASE_URL=$DEFAULT_BASE_URL +else + SI_MODE=remote +fi +export SI_BASE_URL SI_MODE SI_ROOT +if [[ -z $SI_BASE_URL && $SI_MODE != local ]]; then + echo "shell-inits: base URL required (--base-url URL, SHELL_INITS_BASE_URL env, or run from a checkout)" >&2 + exit 1 +fi +if [[ $SI_MODE == local ]]; then + source "$SI_ROOT/lib/common.sh" +else + command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 \ + || { echo "shell-inits: curl or wget required" >&2; exit 1; } + _si_tmp=$(mktemp -d) + for _si_lib in common distro fetch pkg state asset; do + if command -v curl >/dev/null 2>&1; then + curl -fsSL "${SI_BASE_URL%/}/lib/${_si_lib}.sh" -o "${_si_tmp}/${_si_lib}.sh" + else + wget -qO "${_si_tmp}/${_si_lib}.sh" "${SI_BASE_URL%/}/lib/${_si_lib}.sh" + fi + source "${_si_tmp}/${_si_lib}.sh" + done + unset _si_lib _si_tmp +fi +# --- end preamble ------------------------------------------------------------ + +FZF_REV=1 + +si_banner "core/fzf" +si_distro_detect +si_pkgmap_load + +if si_state_has fzf "$FZF_REV" && si_have_cmd fzf; then + si_log "fzf already initialized (rev ${FZF_REV}), skipping" + exit 0 +fi + +si_pkg_install fzf + +si_state_mark fzf "$FZF_REV" +si_log "fzf $(fzf --version | cut -d' ' -f1) ready at $(command -v fzf)" diff --git a/core/lazygit.sh b/core/lazygit.sh new file mode 100755 index 0000000..15d340d --- /dev/null +++ b/core/lazygit.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# core/lazygit.sh - lazygit release binary + pastel config (terminal git UI). +# Standalone: curl -fsSL /core/lazygit.sh | bash -s -- --base-url + +set -euo pipefail + +# --- shell-inits preamble v1 (identical across core/ and sets/ scripts) ----- +DEFAULT_BASE_URL="" # per-deployment fallback, e.g. https://git.example.com/shell-inits/raw/main +SI_BASE_URL=${SHELL_INITS_BASE_URL:-} +_si_self=${BASH_SOURCE[0]:-} +while [[ $# -gt 0 ]]; do + case $1 in + --base-url) SI_BASE_URL=${2:?--base-url needs a value}; shift 2 ;; + *) shift ;; + esac +done +if [[ -z $SI_BASE_URL && -n $_si_self && -f $_si_self ]]; then + SI_MODE=local + SI_ROOT=$(cd -- "$(dirname -- "$_si_self")/.." && pwd) +elif [[ -z $SI_BASE_URL && -n $DEFAULT_BASE_URL ]]; then + SI_MODE=remote + SI_BASE_URL=$DEFAULT_BASE_URL +else + SI_MODE=remote +fi +export SI_BASE_URL SI_MODE SI_ROOT +if [[ -z $SI_BASE_URL && $SI_MODE != local ]]; then + echo "shell-inits: base URL required (--base-url URL, SHELL_INITS_BASE_URL env, or run from a checkout)" >&2 + exit 1 +fi +if [[ $SI_MODE == local ]]; then + source "$SI_ROOT/lib/common.sh" +else + command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 \ + || { echo "shell-inits: curl or wget required" >&2; exit 1; } + _si_tmp=$(mktemp -d) + for _si_lib in common distro fetch pkg state asset; do + if command -v curl >/dev/null 2>&1; then + curl -fsSL "${SI_BASE_URL%/}/lib/${_si_lib}.sh" -o "${_si_tmp}/${_si_lib}.sh" + else + wget -qO "${_si_tmp}/${_si_lib}.sh" "${SI_BASE_URL%/}/lib/${_si_lib}.sh" + fi + source "${_si_tmp}/${_si_lib}.sh" + done + unset _si_lib _si_tmp +fi +# --- end preamble ------------------------------------------------------------ + +LAZYGIT_REV=1 + +si_banner "core/lazygit" +si_distro_detect +si_pkgmap_load + +# optional pinning: versions.env next to the scripts +_v=$(si_try_fetch_file versions.env) && source "$_v" + +if si_state_has lazygit && si_have_cmd lazygit; then + si_log "lazygit already initialized ($(si_state_file lazygit | xargs head -n1)), skipping" + exit 0 +fi + +si_pkg_install unzip tar + +_version=$(si_asset_github jesseduffield/lazygit "${LAZYGIT_VERSION:-latest}" \ + 'lazygit_{ver}_linux_{arch}.tar.gz' 'checksums.txt') + +_home=$(si_target_home) +si_deploy_file config/lazygit/config.yml "${_home}/.config/lazygit/config.yml" + +si_state_mark lazygit "${LAZYGIT_REV}-${_version}" +si_log "lazygit ${_version} ready" diff --git a/core/neovim.sh b/core/neovim.sh new file mode 100755 index 0000000..6f9ff33 --- /dev/null +++ b/core/neovim.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# core/neovim.sh - neovim + LazyVim with a personal, language-agnostic layer: +# catppuccin theme, snacks explorer tweaks, OSC52 clipboard over ssh. +# Language-specific plugins (C++/rust/go/debuggers) are intentionally out of scope. +# Standalone: curl -fsSL /core/neovim.sh | bash -s -- --base-url +# Env: SHELL_INITS_NVIM_BOOTSTRAP=1 -> also sync plugins headlessly (docker builds) + +set -euo pipefail + +# --- shell-inits preamble v1 (identical across core/ and sets/ scripts) ----- +DEFAULT_BASE_URL="" # per-deployment fallback, e.g. https://git.example.com/shell-inits/raw/main +SI_BASE_URL=${SHELL_INITS_BASE_URL:-} +_si_self=${BASH_SOURCE[0]:-} +while [[ $# -gt 0 ]]; do + case $1 in + --base-url) SI_BASE_URL=${2:?--base-url needs a value}; shift 2 ;; + *) shift ;; + esac +done +if [[ -z $SI_BASE_URL && -n $_si_self && -f $_si_self ]]; then + SI_MODE=local + SI_ROOT=$(cd -- "$(dirname -- "$_si_self")/.." && pwd) +elif [[ -z $SI_BASE_URL && -n $DEFAULT_BASE_URL ]]; then + SI_MODE=remote + SI_BASE_URL=$DEFAULT_BASE_URL +else + SI_MODE=remote +fi +export SI_BASE_URL SI_MODE SI_ROOT +if [[ -z $SI_BASE_URL && $SI_MODE != local ]]; then + echo "shell-inits: base URL required (--base-url URL, SHELL_INITS_BASE_URL env, or run from a checkout)" >&2 + exit 1 +fi +if [[ $SI_MODE == local ]]; then + source "$SI_ROOT/lib/common.sh" +else + command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 \ + || { echo "shell-inits: curl or wget required" >&2; exit 1; } + _si_tmp=$(mktemp -d) + for _si_lib in common distro fetch pkg state asset; do + if command -v curl >/dev/null 2>&1; then + curl -fsSL "${SI_BASE_URL%/}/lib/${_si_lib}.sh" -o "${_si_tmp}/${_si_lib}.sh" + else + wget -qO "${_si_tmp}/${_si_lib}.sh" "${SI_BASE_URL%/}/lib/${_si_lib}.sh" + fi + source "${_si_tmp}/${_si_lib}.sh" + done + unset _si_lib _si_tmp +fi +# --- end preamble ------------------------------------------------------------ + +NEOVIM_REV=4 +NVIM_MIN_MAJOR=0 +NVIM_MIN_MINOR=10 + +si_banner "core/neovim" +si_distro_detect +si_pkgmap_load + +if si_state_has neovim "$NEOVIM_REV" && si_have_cmd nvim; then + si_log "neovim already initialized (rev ${NEOVIM_REV}), skipping" + exit 0 +fi + +# lazyvim opens lazygit; make sure it is there first +si_ensure_core lazygit + +# gcc + libc headers: nvim-treesitter compiles parsers locally (libc6-dev is +# only a Recommends of gcc, so it must be explicit with --no-install-recommends) +si_pkg_install neovim ripgrep fd-find gcc libc6-dev ca-certificates + +# LazyVim requires nvim >= 0.10 +read -r _maj _min <<<"$(nvim --version | head -n1 | sed -n 's/.*v\([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1 \2/p')" +[[ ${_maj:-0} -gt $NVIM_MIN_MAJOR || (${_maj:-0} -eq $NVIM_MIN_MAJOR && ${_min:-0} -ge $NVIM_MIN_MINOR) ]] \ + || si_die "neovim ${_maj:-?}.${_min:-?} is too old for LazyVim (needs >= ${NVIM_MIN_MAJOR}.${NVIM_MIN_MINOR})" + +_home=$(si_target_home) + +# ubuntu ships the binary as fdfind; LazyVim/telescope expect `fd` +if ! si_have_cmd fd && si_have_cmd fdfind; then + si_as_user 'mkdir -p ~/.local/bin && ln -sf "$(command -v fdfind)" ~/.local/bin/fd' +fi + +# treesitter parsers are compiled locally; without a C compiler they fail +if ! si_have_cmd cc && ! si_have_cmd gcc; then + si_warn "no C compiler found; nvim-treesitter parsers may fail to build (apt install gcc)" +fi + +# replace any pre-existing nvim config (backed up), keep plugin data as-is +if [[ -e ${_home}/.config/nvim ]]; then + si_backup_existing "${_home}/.config/nvim" + rm -rf "${_home}/.config/nvim" +fi + +_nvim_config=${_home}/.config/nvim +for _f in \ + init.lua \ + bootstrap.lua \ + lua/config/clipboard.lua \ + lua/config/lazy.lua \ + lua/config/options.lua \ + lua/config/keymaps.lua \ + lua/config/autocmds.lua \ + lua/config/treesitter_parsers.lua \ + lua/plugins/colorscheme.lua \ + lua/plugins/snacks.lua \ + lua/plugins/indent-blankline.lua \ + lua/plugins/terminal.lua \ + lua/plugins/treesitter.lua +do + si_deploy_file "config/nvim/${_f}" "${_nvim_config}/${_f}" +done +unset _f + +# pre-fetch spell files so the first open of a text/markdown file does not +# prompt "Download it?" (best effort - skipped silently when offline) +si_as_user 'mkdir -p ~/.local/share/nvim/site/spell && cd ~/.local/share/nvim/site/spell \ + && for _f in en.utf-8.spl en.utf-8.sug pl.utf-8.spl; do \ + [ -f "$_f" ] || curl -fsSLO "https://ftp.nluug.nl/pub/vim/runtime/spell/${_f}" 2>/dev/null || true; \ + done' + +# tree-sitter CLI as a release binary: stops LazyVim from trying to install it +# through mason (which needs node) on every start; nvim-treesitter main branch +# ships prebuilt parsers, the CLI is only needed for edge cases +case $(uname -m) in + x86_64) _ts_asset='tree-sitter-linux-x64.gz' ;; + aarch64|arm64) _ts_asset='tree-sitter-linux-arm64.gz' ;; + *) _ts_asset='' ;; +esac +if [[ -n $_ts_asset ]] && ! si_have_cmd tree-sitter; then + si_asset_github tree-sitter/tree-sitter "${TREE_SITTER_VERSION:-latest}" "$_ts_asset" || \ + si_warn "tree-sitter CLI install failed; LazyVim may attempt a mason install" +fi +unset _ts_asset + +# optional headless plugin sync (docker builds / explicit request). +# Two separate nvim invocations on purpose: treesitter parser installs are +# unreliable when run in the same session as the very first Lazy sync. +if [[ ${SHELL_INITS_NVIM_BOOTSTRAP:-0} == 1 ]]; then + si_log "bootstrapping lazyvim + treesitter parsers headlessly (this can take a while)" + si_as_user 'nvim --headless "+Lazy! sync" +qa' \ + || si_warn "headless plugin sync reported errors; first interactive start will retry" + si_as_user 'nvim --headless "+lua dofile(vim.fn.stdpath(\"config\")..\"/bootstrap.lua\")" +qa' \ + || si_warn "headless bootstrap reported errors; first interactive start will retry" +fi + +si_state_mark neovim "$NEOVIM_REV" +si_log "neovim $(nvim --version | head -n1) ready with LazyVim at ${_nvim_config}" diff --git a/core/tmux.sh b/core/tmux.sh new file mode 100755 index 0000000..63ff8de --- /dev/null +++ b/core/tmux.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# core/tmux.sh - tmux with vi copy mode, OSC52 clipboard sharing over ssh and a +# plugin-free pastel theme (no patched/nerd fonts needed). +# Standalone: curl -fsSL /core/tmux.sh | bash -s -- --base-url + +set -euo pipefail + +# --- shell-inits preamble v1 (identical across core/ and sets/ scripts) ----- +DEFAULT_BASE_URL="" # per-deployment fallback, e.g. https://git.example.com/shell-inits/raw/main +SI_BASE_URL=${SHELL_INITS_BASE_URL:-} +_si_self=${BASH_SOURCE[0]:-} +while [[ $# -gt 0 ]]; do + case $1 in + --base-url) SI_BASE_URL=${2:?--base-url needs a value}; shift 2 ;; + *) shift ;; + esac +done +if [[ -z $SI_BASE_URL && -n $_si_self && -f $_si_self ]]; then + SI_MODE=local + SI_ROOT=$(cd -- "$(dirname -- "$_si_self")/.." && pwd) +elif [[ -z $SI_BASE_URL && -n $DEFAULT_BASE_URL ]]; then + SI_MODE=remote + SI_BASE_URL=$DEFAULT_BASE_URL +else + SI_MODE=remote +fi +export SI_BASE_URL SI_MODE SI_ROOT +if [[ -z $SI_BASE_URL && $SI_MODE != local ]]; then + echo "shell-inits: base URL required (--base-url URL, SHELL_INITS_BASE_URL env, or run from a checkout)" >&2 + exit 1 +fi +if [[ $SI_MODE == local ]]; then + source "$SI_ROOT/lib/common.sh" +else + command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 \ + || { echo "shell-inits: curl or wget required" >&2; exit 1; } + _si_tmp=$(mktemp -d) + for _si_lib in common distro fetch pkg state asset; do + if command -v curl >/dev/null 2>&1; then + curl -fsSL "${SI_BASE_URL%/}/lib/${_si_lib}.sh" -o "${_si_tmp}/${_si_lib}.sh" + else + wget -qO "${_si_tmp}/${_si_lib}.sh" "${SI_BASE_URL%/}/lib/${_si_lib}.sh" + fi + source "${_si_tmp}/${_si_lib}.sh" + done + unset _si_lib _si_tmp +fi +# --- end preamble ------------------------------------------------------------ + +TMUX_REV=3 + +si_banner "core/tmux" +si_distro_detect +si_pkgmap_load + +if si_state_has tmux "$TMUX_REV" && si_have_cmd tmux; then + si_log "tmux already initialized (rev ${TMUX_REV}), skipping" + exit 0 +fi + +si_pkg_install tmux + +_home=$(si_target_home) +si_deploy_file config/tmux/osc52-copy.sh "${_home}/.config/tmux/osc52-copy.sh" +chmod +x "${_home}/.config/tmux/osc52-copy.sh" +si_chown_target "${_home}/.config/tmux/osc52-copy.sh" +si_backup_existing "${_home}/.tmux.conf" +si_deploy_file config/tmux/tmux.conf "${_home}/.tmux.conf" + +si_state_mark tmux "$TMUX_REV" +si_log "tmux $(tmux -V | cut -d' ' -f2) ready, config at ${_home}/.tmux.conf" diff --git a/core/zsh.sh b/core/zsh.sh new file mode 100755 index 0000000..7e0ff7e --- /dev/null +++ b/core/zsh.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# core/zsh.sh - zsh with a fast pastel prompt (async git info, big-repo safe), +# fzf integration and two vendored plugins (autosuggestions, syntax highlighting) +# cloned as plain git repos. No plugin manager. +# Standalone: curl -fsSL /core/zsh.sh | bash -s -- --base-url + +set -euo pipefail + +# --- shell-inits preamble v1 (identical across core/ and sets/ scripts) ----- +DEFAULT_BASE_URL="" # per-deployment fallback, e.g. https://git.example.com/shell-inits/raw/main +SI_BASE_URL=${SHELL_INITS_BASE_URL:-} +_si_self=${BASH_SOURCE[0]:-} +while [[ $# -gt 0 ]]; do + case $1 in + --base-url) SI_BASE_URL=${2:?--base-url needs a value}; shift 2 ;; + *) shift ;; + esac +done +if [[ -z $SI_BASE_URL && -n $_si_self && -f $_si_self ]]; then + SI_MODE=local + SI_ROOT=$(cd -- "$(dirname -- "$_si_self")/.." && pwd) +elif [[ -z $SI_BASE_URL && -n $DEFAULT_BASE_URL ]]; then + SI_MODE=remote + SI_BASE_URL=$DEFAULT_BASE_URL +else + SI_MODE=remote +fi +export SI_BASE_URL SI_MODE SI_ROOT +if [[ -z $SI_BASE_URL && $SI_MODE != local ]]; then + echo "shell-inits: base URL required (--base-url URL, SHELL_INITS_BASE_URL env, or run from a checkout)" >&2 + exit 1 +fi +if [[ $SI_MODE == local ]]; then + source "$SI_ROOT/lib/common.sh" +else + command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 \ + || { echo "shell-inits: curl or wget required" >&2; exit 1; } + _si_tmp=$(mktemp -d) + for _si_lib in common distro fetch pkg state asset; do + if command -v curl >/dev/null 2>&1; then + curl -fsSL "${SI_BASE_URL%/}/lib/${_si_lib}.sh" -o "${_si_tmp}/${_si_lib}.sh" + else + wget -qO "${_si_tmp}/${_si_lib}.sh" "${SI_BASE_URL%/}/lib/${_si_lib}.sh" + fi + source "${_si_tmp}/${_si_lib}.sh" + done + unset _si_lib _si_tmp +fi +# --- end preamble ------------------------------------------------------------ + +ZSH_REV=2 +ZSH_PLUGINS=( + zsh-users/zsh-autosuggestions + zsh-users/zsh-syntax-highlighting +) + +si_banner "core/zsh" +si_distro_detect +si_pkgmap_load + +if si_state_has zsh "$ZSH_REV" && si_have_cmd zsh; then + si_log "zsh already initialized (rev ${ZSH_REV}), skipping" + exit 0 +fi + +# fzf provides key bindings and completion integrated by the .zshrc below +si_ensure_core fzf + +si_pkg_install zsh git ca-certificates + +_home=$(si_target_home) + +# git: make `git status` cheap even in very large repositories +si_as_user 'git config --global core.untrackedCache true' +si_as_user 'git config --global core.fsmonitor true' + +# vendored plugins (plain clones, no manager) +for _repo in "${ZSH_PLUGINS[@]}"; do + _name=${_repo##*/} + si_as_user "mkdir -p ~/.zsh/plugins && \ + if [ -d ~/.zsh/plugins/${_name}/.git ]; then \ + git -C ~/.zsh/plugins/${_name} pull -q --ff-only || true; \ + else \ + git clone -q --depth 1 https://github.com/${_repo} ~/.zsh/plugins/${_name}; \ + fi" || si_warn "failed to clone ${_repo}" +done +unset _repo _name + +si_backup_existing "${_home}/.zshrc" +si_backup_existing "${_home}/.zshenv" +si_deploy_file config/zsh/zshenv "${_home}/.zshenv" +si_deploy_file config/zsh/zshrc "${_home}/.zshrc" +si_deploy_file config/zsh/prompt.zsh "${_home}/.zsh/prompt.zsh" + +si_state_mark zsh "$ZSH_REV" +si_log "zsh $(zsh --version | cut -d' ' -f2) ready, config in ${_home}" diff --git a/dockerfiles/_template.build.sh b/dockerfiles/_template.build.sh new file mode 100755 index 0000000..777828e --- /dev/null +++ b/dockerfiles/_template.build.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# dockerfiles/{{SET}}.build.sh +# Build fully-initialized shell-inits/{{SET}}: images. +# +# Serves this repository over a temporary local HTTP server (so the image build +# uses the exact same curl | bash flow as a VM) and then runs docker build with +# --network=host so the container can reach it. +# +# Usage: +# ./dockerfiles/{{SET}}.build.sh [options] +# +# Options: +# --distro repeatable; default: ubuntu-26.04 +# --all build the whole supported distro matrix +# --uid uid of the image user, owns everything (default 1000) +# --gid gid of the image user (default: same as --uid) +# --tag image tag (default: distro slug) +# --no-cache pass --no-cache to docker build +# --base-url fetch scripts from this URL instead of the local server +# (useful for remote docker hosts or Docker Desktop: +# serve the repo yourself and pass +# http://host.docker.internal:) +# --save also export the image to dist/_[_].tar.gz +# for offline use (docker load -i ) +# -h, --help show this help +# +# Note: --network=host does not exist on Docker Desktop (macOS/Windows); +# there use --base-url with a server reachable from the build container. + +set -euo pipefail + +SET="{{SET}}" +SUPPORTED_DISTROS=(ubuntu-26.04) +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd -- "${SCRIPT_DIR}/.." && pwd) + +DISTROS=() +USER_UID=1000 +USER_GID=0 +TAG="" +NO_CACHE=0 +BASE_URL_OVERRIDE="" +SAVE=0 + +usage() { + grep '^#' "$0" | sed 's/^# \{0,1\}//' | tail -n +2 + exit 0 +} + +die() { echo "[shell-inits][error] $*" >&2; exit 1; } + +while [[ $# -gt 0 ]]; do + case $1 in + --distro) DISTROS+=("${2:?--distro needs a value}"); shift 2 ;; + --all) DISTROS=("${SUPPORTED_DISTROS[@]}"); shift ;; + --uid) USER_UID="${2:?--uid needs a value}"; shift 2 ;; + --gid) USER_GID="${2:?--gid needs a value}"; shift 2 ;; + --tag) TAG="${2:?--tag needs a value}"; shift 2 ;; + --no-cache) NO_CACHE=1; shift ;; + --base-url) BASE_URL_OVERRIDE="${2:?--base-url needs a value}"; shift 2 ;; + --save) SAVE=1; shift ;; + -h|--help) usage ;; + *) die "unknown option: $1 (see --help)" ;; + esac +done + +((${#DISTROS[@]})) || DISTROS=(ubuntu-26.04) +[[ $USER_GID -eq 0 ]] && USER_GID=$USER_UID + +command -v docker >/dev/null 2>&1 || die "docker is not available" +command -v python3 >/dev/null 2>&1 || die "python3 is not available" + +SRV_PID="" +cleanup() { [[ -n $SRV_PID ]] && kill "$SRV_PID" 2>/dev/null || true; } +trap cleanup EXIT + +BASE_URL=$BASE_URL_OVERRIDE +if [[ -z $BASE_URL ]]; then + PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()') + python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$REPO_ROOT" >/dev/null 2>&1 & + SRV_PID=$! + for _ in $(seq 1 50); do + if (exec 3<>"/dev/tcp/127.0.0.1/${PORT}") 2>/dev/null; then + exec 3>&- 3<&- + break + fi + sleep 0.1 + done + BASE_URL="http://127.0.0.1:${PORT}" +fi + +echo "==> building set '${SET}' (scripts served from: ${BASE_URL})" +for DISTRO in "${DISTROS[@]}"; do + # distro slug (ubuntu-26.04) -> image reference (ubuntu:26.04) + DISTRO_REF="${DISTRO/-/:}" + IMAGE="shell-inits/${SET}:${TAG:-${DISTRO}}" + echo "==> image: ${IMAGE} (distro: ${DISTRO_REF}, uid/gid: ${USER_UID}:${USER_GID})" + # shellcheck disable=SC2086 + docker build \ + ${NO_CACHE:+--no-cache} \ + --network=host \ + --build-arg SHELL_INITS_BASE_URL="${BASE_URL}" \ + --build-arg DISTRO="${DISTRO_REF}" \ + --build-arg USER_UID="${USER_UID}" \ + --build-arg USER_GID="${USER_GID}" \ + -f "${SCRIPT_DIR}/${SET}.dockerfile" \ + -t "${IMAGE}" \ + "${REPO_ROOT}" + echo "==> built: ${IMAGE}" + echo " run: docker run -it ${IMAGE}" + if (( SAVE )); then + _suffix="${DISTRO}" + [[ -n $TAG ]] && _suffix="${DISTRO}_${TAG}" + _file="${REPO_ROOT}/dist/${SET}_${_suffix}.tar.gz" + mkdir -p "${REPO_ROOT}/dist" + docker save "${IMAGE}" | gzip >"${_file}" + echo " saved: ${_file}" + echo " load: docker load -i ${_file}" + fi +done +echo "==> done" diff --git a/dockerfiles/_template.dockerfile b/dockerfiles/_template.dockerfile new file mode 100644 index 0000000..267f88a --- /dev/null +++ b/dockerfiles/_template.dockerfile @@ -0,0 +1,54 @@ +# dockerfiles/{{SET}}.dockerfile +# Fully initialized zsh + fzf + tmux + lazyvim + lazygit image, ready to use +# offline. Built by dockerfiles/{{SET}}.build.sh, which serves this +# repository over a local HTTP server and passes SHELL_INITS_BASE_URL. + +# DISTRO is a docker image reference (build.sh maps the distro slug, e.g. +# ubuntu-26.04, to ubuntu:26.04) +ARG DISTRO=ubuntu:26.04 +FROM ${DISTRO} + +ARG SHELL_INITS_BASE_URL +ARG USERNAME=dev +ARG USER_UID=1000 +ARG USER_GID=1000 + +# downloader + sudo for the dev user +RUN apt-get update -qq \ + && apt-get install -y -qq --no-install-recommends curl ca-certificates sudo \ + && rm -rf /var/lib/apt/lists/* + +# non-root user with the requested uid/gid; evict a colliding stock user +# (ubuntu:26.04 ships an 'ubuntu' user at 1000:1000) and make sure every file +# produced from here on is owned by the requested uid +RUN set -eu; \ + if getent passwd "${USER_UID}" >/dev/null; then \ + userdel -r "$(getent passwd "${USER_UID}" | cut -d: -f1)" || true; fi; \ + if getent group "${USER_GID}" >/dev/null; then \ + groupdel "$(getent group "${USER_GID}" | cut -d: -f1)" || true; fi; \ + groupadd -g "${USER_GID}" "${USERNAME}" \ + && useradd -m -s /bin/bash -u "${USER_UID}" -g "${USER_GID}" "${USERNAME}" \ + && echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/${USERNAME}" \ + && chmod 440 "/etc/sudoers.d/${USERNAME}" + +# run the toolset as root, provisioning the user's environment; +# SHELL_INITS_NVIM_BOOTSTRAP=1 syncs lazyvim plugins headlessly inside the +# build so no network is needed when the container runs +RUN curl -fsSL "${SHELL_INITS_BASE_URL}/sets/{{SET}}.sh" \ + | env SHELL_INITS_BASE_URL="${SHELL_INITS_BASE_URL}" \ + SHELL_INITS_USER="${USERNAME}" \ + SHELL_INITS_NVIM_BOOTSTRAP=1 \ + bash + +# slim down +RUN rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +COPY dockerfiles/entrypoint.sh /usr/local/bin/shell-inits-entrypoint +RUN chmod 0755 /usr/local/bin/shell-inits-entrypoint + +USER ${USERNAME} +WORKDIR /home/${USERNAME} +ENV SHELL=/usr/bin/zsh +# UTF-8 locale for the tmux server and everything else in the container +ENV LANG=C.UTF-8 +ENTRYPOINT ["/usr/local/bin/shell-inits-entrypoint"] diff --git a/dockerfiles/entrypoint.sh b/dockerfiles/entrypoint.sh new file mode 100755 index 0000000..de69108 --- /dev/null +++ b/dockerfiles/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# shell-inits container entrypoint: attach to or create the default tmux +# session 'main'. Set SHELL_INITS_NO_TMUX=1 for a plain login zsh. +set -e +if [ "${SHELL_INITS_NO_TMUX:-0}" = "1" ]; then + exec "${SHELL:-/usr/bin/zsh}" -l +fi +exec tmux new -As main diff --git a/dockerfiles/zsh-tmux-neovim.build.sh b/dockerfiles/zsh-tmux-neovim.build.sh new file mode 100755 index 0000000..293fdaf --- /dev/null +++ b/dockerfiles/zsh-tmux-neovim.build.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# dockerfiles/zsh-tmux-neovim.build.sh +# Build fully-initialized shell-inits/zsh-tmux-neovim: images. +# +# Serves this repository over a temporary local HTTP server (so the image build +# uses the exact same curl | bash flow as a VM) and then runs docker build with +# --network=host so the container can reach it. +# +# Usage: +# ./dockerfiles/zsh-tmux-neovim.build.sh [options] +# +# Options: +# --distro repeatable; default: ubuntu-26.04 +# --all build the whole supported distro matrix +# --uid uid of the image user, owns everything (default 1000) +# --gid gid of the image user (default: same as --uid) +# --tag image tag (default: distro slug) +# --no-cache pass --no-cache to docker build +# --base-url fetch scripts from this URL instead of the local server +# (useful for remote docker hosts or Docker Desktop: +# serve the repo yourself and pass +# http://host.docker.internal:) +# --save also export the image to dist/_[_].tar.gz +# for offline use (docker load -i ) +# -h, --help show this help +# +# Note: --network=host does not exist on Docker Desktop (macOS/Windows); +# there use --base-url with a server reachable from the build container. + +set -euo pipefail + +SET="zsh-tmux-neovim" +SUPPORTED_DISTROS=(ubuntu-26.04) +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd -- "${SCRIPT_DIR}/.." && pwd) + +DISTROS=() +USER_UID=1000 +USER_GID=0 +TAG="" +NO_CACHE=0 +BASE_URL_OVERRIDE="" +SAVE=0 + +usage() { + grep '^#' "$0" | sed 's/^# \{0,1\}//' | tail -n +2 + exit 0 +} + +die() { echo "[shell-inits][error] $*" >&2; exit 1; } + +while [[ $# -gt 0 ]]; do + case $1 in + --distro) DISTROS+=("${2:?--distro needs a value}"); shift 2 ;; + --all) DISTROS=("${SUPPORTED_DISTROS[@]}"); shift ;; + --uid) USER_UID="${2:?--uid needs a value}"; shift 2 ;; + --gid) USER_GID="${2:?--gid needs a value}"; shift 2 ;; + --tag) TAG="${2:?--tag needs a value}"; shift 2 ;; + --no-cache) NO_CACHE=1; shift ;; + --base-url) BASE_URL_OVERRIDE="${2:?--base-url needs a value}"; shift 2 ;; + --save) SAVE=1; shift ;; + -h|--help) usage ;; + *) die "unknown option: $1 (see --help)" ;; + esac +done + +((${#DISTROS[@]})) || DISTROS=(ubuntu-26.04) +[[ $USER_GID -eq 0 ]] && USER_GID=$USER_UID + +command -v docker >/dev/null 2>&1 || die "docker is not available" +command -v python3 >/dev/null 2>&1 || die "python3 is not available" + +SRV_PID="" +cleanup() { [[ -n $SRV_PID ]] && kill "$SRV_PID" 2>/dev/null || true; } +trap cleanup EXIT + +BASE_URL=$BASE_URL_OVERRIDE +if [[ -z $BASE_URL ]]; then + PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()') + python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$REPO_ROOT" >/dev/null 2>&1 & + SRV_PID=$! + for _ in $(seq 1 50); do + if (exec 3<>"/dev/tcp/127.0.0.1/${PORT}") 2>/dev/null; then + exec 3>&- 3<&- + break + fi + sleep 0.1 + done + BASE_URL="http://127.0.0.1:${PORT}" +fi + +echo "==> building set '${SET}' (scripts served from: ${BASE_URL})" +for DISTRO in "${DISTROS[@]}"; do + # distro slug (ubuntu-26.04) -> image reference (ubuntu:26.04) + DISTRO_REF="${DISTRO/-/:}" + IMAGE="shell-inits/${SET}:${TAG:-${DISTRO}}" + echo "==> image: ${IMAGE} (distro: ${DISTRO_REF}, uid/gid: ${USER_UID}:${USER_GID})" + # shellcheck disable=SC2086 + docker build \ + ${NO_CACHE:+--no-cache} \ + --network=host \ + --build-arg SHELL_INITS_BASE_URL="${BASE_URL}" \ + --build-arg DISTRO="${DISTRO_REF}" \ + --build-arg USER_UID="${USER_UID}" \ + --build-arg USER_GID="${USER_GID}" \ + -f "${SCRIPT_DIR}/${SET}.dockerfile" \ + -t "${IMAGE}" \ + "${REPO_ROOT}" + echo "==> built: ${IMAGE}" + echo " run: docker run -it ${IMAGE}" + if (( SAVE )); then + _suffix="${DISTRO}" + [[ -n $TAG ]] && _suffix="${DISTRO}_${TAG}" + _file="${REPO_ROOT}/dist/${SET}_${_suffix}.tar.gz" + mkdir -p "${REPO_ROOT}/dist" + docker save "${IMAGE}" | gzip >"${_file}" + echo " saved: ${_file}" + echo " load: docker load -i ${_file}" + fi +done +echo "==> done" diff --git a/dockerfiles/zsh-tmux-neovim.dockerfile b/dockerfiles/zsh-tmux-neovim.dockerfile new file mode 100644 index 0000000..c46c1ef --- /dev/null +++ b/dockerfiles/zsh-tmux-neovim.dockerfile @@ -0,0 +1,54 @@ +# dockerfiles/zsh-tmux-neovim.dockerfile +# Fully initialized zsh + fzf + tmux + lazyvim + lazygit image, ready to use +# offline. Built by dockerfiles/zsh-tmux-neovim.build.sh, which serves this +# repository over a local HTTP server and passes SHELL_INITS_BASE_URL. + +# DISTRO is a docker image reference (build.sh maps the distro slug, e.g. +# ubuntu-26.04, to ubuntu:26.04) +ARG DISTRO=ubuntu:26.04 +FROM ${DISTRO} + +ARG SHELL_INITS_BASE_URL +ARG USERNAME=dev +ARG USER_UID=1000 +ARG USER_GID=1000 + +# downloader + sudo for the dev user +RUN apt-get update -qq \ + && apt-get install -y -qq --no-install-recommends curl ca-certificates sudo \ + && rm -rf /var/lib/apt/lists/* + +# non-root user with the requested uid/gid; evict a colliding stock user +# (ubuntu:26.04 ships an 'ubuntu' user at 1000:1000) and make sure every file +# produced from here on is owned by the requested uid +RUN set -eu; \ + if getent passwd "${USER_UID}" >/dev/null; then \ + userdel -r "$(getent passwd "${USER_UID}" | cut -d: -f1)" || true; fi; \ + if getent group "${USER_GID}" >/dev/null; then \ + groupdel "$(getent group "${USER_GID}" | cut -d: -f1)" || true; fi; \ + groupadd -g "${USER_GID}" "${USERNAME}" \ + && useradd -m -s /bin/bash -u "${USER_UID}" -g "${USER_GID}" "${USERNAME}" \ + && echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/${USERNAME}" \ + && chmod 440 "/etc/sudoers.d/${USERNAME}" + +# run the toolset as root, provisioning the user's environment; +# SHELL_INITS_NVIM_BOOTSTRAP=1 syncs lazyvim plugins headlessly inside the +# build so no network is needed when the container runs +RUN curl -fsSL "${SHELL_INITS_BASE_URL}/sets/zsh-tmux-neovim.sh" \ + | env SHELL_INITS_BASE_URL="${SHELL_INITS_BASE_URL}" \ + SHELL_INITS_USER="${USERNAME}" \ + SHELL_INITS_NVIM_BOOTSTRAP=1 \ + bash + +# slim down +RUN rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +COPY dockerfiles/entrypoint.sh /usr/local/bin/shell-inits-entrypoint +RUN chmod 0755 /usr/local/bin/shell-inits-entrypoint + +USER ${USERNAME} +WORKDIR /home/${USERNAME} +ENV SHELL=/usr/bin/zsh +# UTF-8 locale for the tmux server and everything else in the container +ENV LANG=C.UTF-8 +ENTRYPOINT ["/usr/local/bin/shell-inits-entrypoint"] diff --git a/lib/asset.sh b/lib/asset.sh new file mode 100755 index 0000000..086abee --- /dev/null +++ b/lib/asset.sh @@ -0,0 +1,87 @@ +# lib/asset.sh - install prebuilt binaries from GitHub releases +# Asset pattern placeholders: {tag}=full tag (v1.2.3), {ver}=tag without 'v', +# {arch}=x86_64 | arm64 (aarch64 is mapped to arm64, the common release naming). + +si_arch() { + case $(uname -m) in + x86_64) printf x86_64 ;; + aarch64 | arm64) printf arm64 ;; + *) si_die "unsupported architecture: $(uname -m)" ;; + esac +} + +si_gh_latest_tag() { + local repo=$1 url tag + url=$(curl -fsSLI -o /dev/null -w '%{url_effective}' \ + "https://github.com/${repo}/releases/latest") \ + || si_die "failed to resolve latest release of ${repo}" + tag=${url##*/} + [[ -n $tag ]] || si_die "could not parse latest tag for ${repo}" + printf '%s' "$tag" +} + +# si_install_bin - install an executable to /usr/local/bin when +# possible, otherwise to the target user's ~/.local/bin. +si_install_bin() { + local src=$1 name dest dir + name=$(basename "$src") + if si_is_root || si_have_cmd sudo; then + dest="/usr/local/bin/${name}" + si_as_root install -m 0755 "$src" "$dest" + else + dir="$(si_target_home)/.local/bin" + mkdir -p "$dir" + install -m 0755 "$src" "$dir/$name" + si_chown_target "$dir" + dest="$dir/$name" + fi + si_log "installed binary: $dest" +} + +# si_asset_github [checksums-file] +# Downloads, verifies (when a checksums file is given), extracts and installs +# the single executable contained in the asset. +si_asset_github() { + local repo=$1 version=$2 pattern=$3 checksums=${4:-} + local arch tag base asset tmp want candidate cks + + arch=$(si_arch) + if [[ $version == latest ]]; then + tag=$(si_gh_latest_tag "$repo") + else + tag=$version + fi + base="https://github.com/${repo}/releases/download/${tag}" + asset=$pattern + asset=${asset//\{tag\}/$tag} + asset=${asset//\{ver\}/${tag#v}} + asset=${asset//\{arch\}/$arch} + + tmp=$(mktemp -d) + si_log "downloading ${repo} ${tag} (${asset})" + si_fetch_raw "${base}/${asset}" >"${tmp}/${asset}" || si_die "download failed: ${asset}" + + if [[ -n $checksums ]]; then + cks=$(basename "$checksums") + si_fetch_raw "${base}/${checksums}" >"${tmp}/${cks}" || si_die "checksums download failed" + (cd "$tmp" && grep " ${asset}\$" "$cks" | sha256sum -c - >/dev/null) \ + || si_die "checksum mismatch for ${asset}" + si_log "checksum verified: ${asset}" + fi + + case $asset in + *.tar.gz | *.tgz) tar -xzf "${tmp}/${asset}" -C "$tmp" ;; + *.zip) unzip -qo "${tmp}/${asset}" -d "$tmp" ;; + *.gz) gzip -dc "${tmp}/${asset}" >"${tmp}/${repo##*/}" \ + && chmod +x "${tmp}/${repo##*/}" ;; + *) mv "${tmp}/${asset}" "${tmp}/binary" ;; + esac + + want=${repo##*/} + candidate=$(find "$tmp" -maxdepth 3 -type f -name "$want" -perm -u+x | head -n1) + [[ -n $candidate ]] || candidate=$(find "$tmp" -maxdepth 3 -type f -perm -u+x | head -n1) + [[ -n $candidate ]] || si_die "no executable found inside ${asset}" + si_install_bin "$candidate" + rm -rf "$tmp" + printf '%s\n' "${tag#v}" +} diff --git a/lib/common.sh b/lib/common.sh new file mode 100755 index 0000000..a63590a --- /dev/null +++ b/lib/common.sh @@ -0,0 +1,114 @@ +# lib/common.sh - shared helpers for all shell-inits scripts +# Sourced after the standard entry preamble (which exports SI_BASE_URL/SI_MODE/SI_ROOT). +set -euo pipefail + +# --- logging (logs go to stderr; stdout is reserved for return values) ------- + +si_log() { printf '[shell-inits] %s\n' "$*" >&2; } +si_warn() { printf '[shell-inits][warn] %s\n' "$*" >&2; } +si_die() { printf '[shell-inits][error] %s\n' "$*" >&2; exit 1; } +si_banner() { printf '\n==> %s\n' "$*" >&2; } + +# --- small predicates ------------------------------------------------------ + +si_have_cmd() { command -v "$1" >/dev/null 2>&1; } +si_is_root() { [[ $(id -u) -eq 0 ]]; } + +# --- privileges ------------------------------------------------------------ + +# Run a command with root privileges (packages etc). +si_as_root() { + if si_is_root; then + "$@" + elif si_have_cmd sudo; then + sudo "$@" + elif si_have_cmd doas; then + doas "$@" + else + si_die "root privileges required and no sudo/doas available" + fi +} + +# The user whose environment we configure: +# 1. SHELL_INITS_USER (explicit, e.g. docker builds running as root for a user) +# 2. SUDO_USER (vm user running the script through sudo) +# 3. current user (root login / running as the user directly) +si_target_user() { + if [[ -n ${SHELL_INITS_USER:-} ]]; then + printf '%s' "$SHELL_INITS_USER" + elif [[ -n ${SUDO_USER:-} ]]; then + printf '%s' "$SUDO_USER" + else + id -un + fi +} + +si_target_home() { + local user home + user=$(si_target_user) + home=$(getent passwd "$user" 2>/dev/null | cut -d: -f6 || true) + [[ -n $home ]] || home=$HOME + printf '%s' "$home" +} + +# Run a shell command as the target user (git config, plugin clones, nvim...). +si_as_user() { + local cmd=$1 target + target=$(si_target_user) + if [[ $target == $(id -un) ]]; then + bash -c "$cmd" + elif si_is_root; then + su -s /bin/bash "$target" -c "$cmd" + else + si_die "cannot run as user '$target' without root privileges" + fi +} + +# Fix ownership of files we created as root for another user. +si_chown_target() { + local target group + target=$(si_target_user) + [[ $target == $(id -un) ]] && return 0 + si_is_root || return 0 + group=$(id -gn "$target" 2>/dev/null || true) + [[ -n $group ]] || group=$target + chown -R "$target:$group" "$@" +} + +# Create a directory as the target user (avoids root-owned ~/.local/... when +# provisioning another user's home). +si_user_mkdir() { + local dir=$1 + if [[ $(si_target_user) == $(id -un) ]]; then + mkdir -p "$dir" + else + si_as_user "mkdir -p '$dir'" + fi +} + +# --- component dependencies ------------------------------------------------- + +# Keep a one-time backup of a pre-existing config path. +si_backup_existing() { + local path=$1 bak + [[ -e $path ]] || return 0 + bak="${path}.pre-shell-inits" + [[ -e $bak ]] && bak="${path}.pre-shell-inits.$(date +%s)" + cp -a "$path" "$bak" + si_warn "backed up existing $path -> $bak" +} + +# Ensure another core initializer has run (checks its state marker). +si_ensure_core() { + local component=$1 script + if si_state_has "$component"; then + return 0 + fi + si_log "dependency core/${component}.sh not satisfied yet, running it" + if [[ ${SI_MODE:-local} == local ]]; then + script="$SI_ROOT/core/${component}.sh" + else + script=$(si_fetch_file "core/${component}.sh") + fi + bash "$script" || si_die "dependency component '${component}' failed" +} diff --git a/lib/distro.sh b/lib/distro.sh new file mode 100755 index 0000000..7cb7ca3 --- /dev/null +++ b/lib/distro.sh @@ -0,0 +1,20 @@ +# lib/distro.sh - detect the target distro, reject unsupported ones +# Sets: SI_DISTRO_SLUG (e.g. ubuntu-26.04), SI_PKG_MANAGER (apt|apk) + +si_distro_detect() { + [[ -r /etc/os-release ]] || si_die "cannot read /etc/os-release" + # shellcheck disable=SC1091 + . /etc/os-release + local slug="${ID}-${VERSION_ID}" + unset ID VERSION_ID NAME PRETTY_NAME VERSION_ID VERSION 2>/dev/null || true + + case $slug in + ubuntu-26.04) SI_DISTRO_SLUG=$slug; SI_PKG_MANAGER=apt ;; + *) + si_die "unsupported distro '${slug}'. Supported: ubuntu-26.04. \ +To support another distro add lib/pkgmaps/${slug}.sh and extend lib/distro.sh." + ;; + esac + export SI_DISTRO_SLUG SI_PKG_MANAGER + si_log "detected distro: $SI_DISTRO_SLUG (pkg manager: $SI_PKG_MANAGER)" +} diff --git a/lib/fetch.sh b/lib/fetch.sh new file mode 100755 index 0000000..90c5415 --- /dev/null +++ b/lib/fetch.sh @@ -0,0 +1,61 @@ +# lib/fetch.sh - fetch files relative to the base URL (or read from a checkout) +# Requires SI_MODE/SI_ROOT/SI_BASE_URL from the entry preamble. + +# Print a URL to stdout (curl preferred, wget fallback). +si_fetch_raw() { + local url=$1 + if si_have_cmd curl; then + curl -fsSL "$url" + elif si_have_cmd wget; then + wget -qO- "$url" + else + si_die "neither curl nor wget available" + fi +} + +# si_fetch_file -> prints a local path to the file (dies on failure) +si_fetch_file() { + local rel=$1 out + if [[ ${SI_MODE:-local} == local ]]; then + out="$SI_ROOT/$rel" + [[ -f $out ]] || si_die "file missing from checkout: $out" + else + out=$(mktemp) + si_fetch_raw "${SI_BASE_URL%/}/$rel" >"$out" || si_die "failed to fetch $rel" + fi + printf '%s' "$out" +} + +# si_try_fetch_file -> prints path or nothing if unavailable +si_try_fetch_file() { + local rel=$1 + if [[ ${SI_MODE:-local} == local ]]; then + [[ -f "$SI_ROOT/$rel" ]] && printf '%s' "$SI_ROOT/$rel" + else + local out + out=$(mktemp) + if si_fetch_raw "${SI_BASE_URL%/}/$rel" >"$out" 2>/dev/null; then + printf '%s' "$out" + else + rm -f "$out" + fi + fi +} + +# si_fetch -> file contents on stdout +si_fetch() { + local path + path=$(si_fetch_file "$1") + cat "$path" +} + +# si_deploy_file +si_deploy_file() { + local src dest + src=$(si_fetch_file "$1") + dest=$2 + si_user_mkdir "$(dirname "$dest")" + cp "$src" "$dest" + si_chown_target "$dest" + si_log "deployed $1 -> $dest" +} diff --git a/lib/pkg.sh b/lib/pkg.sh new file mode 100755 index 0000000..8eebe6f --- /dev/null +++ b/lib/pkg.sh @@ -0,0 +1,49 @@ +# lib/pkg.sh - package installation abstraction (apt/apk dispatch via pkgmaps) +# Requires SI_DISTRO_SLUG. The pkgmap defines: declare -A PKG (abstract -> real). + +si_pkgmap_load() { + local map + map=$(si_fetch_file "lib/pkgmaps/${SI_DISTRO_SLUG}.sh") + # shellcheck disable=SC1090 + source "$map" || si_die "failed to load pkgmap for ${SI_DISTRO_SLUG}" + si_log "loaded pkgmap for ${SI_DISTRO_SLUG}" +} + +# Refresh the package index once per process. +si_pkg_update() { + [[ -n ${SI_PKG_UPDATED:-} ]] && return 0 + case ${SI_PKG_MANAGER:?pkg manager not detected} in + apt) si_as_root env DEBIAN_FRONTEND=noninteractive apt-get update -qq ;; + apk) si_as_root apk update -q ;; + *) si_die "unknown package manager: $SI_PKG_MANAGER" ;; + esac + SI_PKG_UPDATED=1 +} + +# si_pkg_install ... (names resolved through the pkgmap) +si_pkg_install() { + (( $# > 0 )) || return 0 + si_pkg_update + local -a real=() + local abstract mapped + for abstract in "$@"; do + mapped=${PKG[$abstract]:-} + if [[ -z $mapped ]]; then + si_warn "no pkgmap entry for '$abstract', using the name as-is" + mapped=$abstract + fi + real+=("$mapped") + done + si_log "installing packages: ${real[*]}" + case $SI_PKG_MANAGER in + apt) + si_as_root env DEBIAN_FRONTEND=noninteractive \ + apt-get install -y -qq --no-install-recommends "${real[@]}" + ;; + apk) + si_as_root apk add -q "${real[@]}" + ;; + *) + si_die "unknown package manager: $SI_PKG_MANAGER" ;; + esac +} diff --git a/lib/pkgmaps/ubuntu-26.04.sh b/lib/pkgmaps/ubuntu-26.04.sh new file mode 100755 index 0000000..66853a2 --- /dev/null +++ b/lib/pkgmaps/ubuntu-26.04.sh @@ -0,0 +1,20 @@ +# Package name map for ubuntu-26.04 (abstract name -> apt package) +# Used by lib/pkg.sh. Keep abstract names distro-neutral. + +declare -gA PKG=( + [ca-certificates]=ca-certificates + [curl]=curl + [wget]=wget + [git]=git + [unzip]=unzip + [tar]=tar + + [tmux]=tmux + [zsh]=zsh + [fzf]=fzf + [neovim]=neovim + [ripgrep]=ripgrep + [fd-find]=fd-find + [gcc]=gcc + [libc6-dev]=libc6-dev +) diff --git a/lib/run-set.sh b/lib/run-set.sh new file mode 100755 index 0000000..2fc9fb4 --- /dev/null +++ b/lib/run-set.sh @@ -0,0 +1,65 @@ +# lib/run-set.sh - shared runner used by sets/.sh wrappers +# The set wrapper defines SET_NAME and COMPONENTS, then calls si_run_set. + +si_run_core() { + local component=$1 script + if [[ ${SI_MODE:-local} == local ]]; then + script="$SI_ROOT/core/${component}.sh" + [[ -f $script ]] || si_die "unknown component '${component}' (no ${script})" + else + script=$(si_fetch_file "core/${component}.sh") + fi + si_banner "component: ${component}" + SI_COMPONENT=$component bash "$script" || si_die "component '${component}' failed" +} + +si_run_set() { + local component skip + local -a skips=() + if [[ -n ${SHELL_INITS_SKIP:-} ]]; then + IFS=',' read -r -a skips <<<"$SHELL_INITS_SKIP" + fi + for component in "${COMPONENTS[@]}"; do + for skip in "${skips[@]:-}"; do + [[ $component == "$skip" ]] && continue 2 + done + si_run_core "$component" + done +} + +# --- common post hooks ------------------------------------------------------ + +# Make zsh the login shell of the target user (best effort). +si_set_default_shell_zsh() { + local target shell zsh_bin + target=$(si_target_user) + shell=$(getent passwd "$target" | cut -d: -f7) + if [[ $shell == *zsh ]]; then + si_log "login shell of '${target}' is already zsh" + return 0 + fi + zsh_bin=$(command -v zsh || true) + [[ -n $zsh_bin ]] || { si_warn "zsh not found, cannot set default shell"; return 0; } + if si_is_root; then + usermod -s "$zsh_bin" "$target" 2>/dev/null \ + || chsh -s "$zsh_bin" "$target" 2>/dev/null \ + || si_warn "failed to change login shell for '${target}'" + elif si_have_cmd sudo; then + sudo chsh -s "$zsh_bin" "$target" 2>/dev/null \ + || sudo usermod -s "$zsh_bin" "$target" 2>/dev/null \ + || si_warn "failed to change login shell for '${target}'" + else + si_warn "no root privileges; run 'chsh -s ${zsh_bin}' manually" + fi +} + +si_print_summary() { + local tool + printf '\n==> %s complete\n' "${SET_NAME:-shell-inits}" + for tool in zsh tmux fzf nvim lazygit; do + if si_have_cmd "$tool"; then + printf ' %-8s %s\n' "$tool" "$(command -v "$tool")" + fi + done + printf '==> next: start a new login shell (zsh), then run: tmux new -As main\n\n' +} diff --git a/lib/state.sh b/lib/state.sh new file mode 100755 index 0000000..1fb6bda --- /dev/null +++ b/lib/state.sh @@ -0,0 +1,35 @@ +# lib/state.sh - idempotency markers (which components are already installed) +# State lives in the target user's state dir; first line = version/rev marker. + +si_state_root() { + printf '%s/shell-inits' "${XDG_STATE_HOME:-$(si_target_home)/.local/state}" +} + +si_state_file() { + printf '%s/%s.ver' "$(si_state_root)" "$1" +} + +# si_state_has [revision] +# True when the marker exists and matches the revision (any revision if omitted). +# SHELL_INITS_FORCE=1 or SI_FORCE=1 always returns false (reinstall). +si_state_has() { + local component=$1 revision=${2:-} file + [[ -n ${SI_FORCE:-${SHELL_INITS_FORCE:-}} ]] && return 1 + file=$(si_state_file "$component") + [[ -f $file ]] || return 1 + if [[ -n $revision ]]; then + [[ $(head -n1 "$file") == "$revision" ]] || return 1 + fi + return 0 +} + +# si_state_mark +si_state_mark() { + local component=$1 revision=$2 file + file=$(si_state_file "$component") + si_user_mkdir "$(dirname "$file")" + printf '%s\ninstalled=%s distro=%s\n' \ + "$revision" "$(date -u +%FT%TZ)" "${SI_DISTRO_SLUG:-unknown}" >"$file" + si_chown_target "$file" + si_log "marked ${component} as initialized (rev ${revision})" +} diff --git a/sets/zsh-tmux-neovim.sh b/sets/zsh-tmux-neovim.sh new file mode 100755 index 0000000..b8a7968 --- /dev/null +++ b/sets/zsh-tmux-neovim.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# sets/zsh-tmux-neovim.sh - zsh + fzf + tmux + lazyvim + lazygit +# Single-command entry point, curl-able: +# curl -fsSL /sets/zsh-tmux-neovim.sh | bash -s -- --base-url +# Env: SHELL_INITS_SKIP=tmux,neovim exclude components +# SHELL_INITS_FORCE=1 reinstall everything +# SHELL_INITS_NVIM_BOOTSTRAP=1 sync nvim plugins headlessly right away + +set -euo pipefail + +# --- shell-inits preamble v1 (identical across core/ and sets/ scripts) ----- +DEFAULT_BASE_URL="" # per-deployment fallback, e.g. https://git.example.com/shell-inits/raw/main +SI_BASE_URL=${SHELL_INITS_BASE_URL:-} +_si_self=${BASH_SOURCE[0]:-} +while [[ $# -gt 0 ]]; do + case $1 in + --base-url) SI_BASE_URL=${2:?--base-url needs a value}; shift 2 ;; + *) shift ;; + esac +done +if [[ -z $SI_BASE_URL && -n $_si_self && -f $_si_self ]]; then + SI_MODE=local + SI_ROOT=$(cd -- "$(dirname -- "$_si_self")/.." && pwd) +elif [[ -z $SI_BASE_URL && -n $DEFAULT_BASE_URL ]]; then + SI_MODE=remote + SI_BASE_URL=$DEFAULT_BASE_URL +else + SI_MODE=remote +fi +export SI_BASE_URL SI_MODE SI_ROOT +if [[ -z $SI_BASE_URL && $SI_MODE != local ]]; then + echo "shell-inits: base URL required (--base-url URL, SHELL_INITS_BASE_URL env, or run from a checkout)" >&2 + exit 1 +fi +if [[ $SI_MODE == local ]]; then + source "$SI_ROOT/lib/common.sh" +else + command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 \ + || { echo "shell-inits: curl or wget required" >&2; exit 1; } + _si_tmp=$(mktemp -d) + for _si_lib in common distro fetch pkg state asset run-set; do + if command -v curl >/dev/null 2>&1; then + curl -fsSL "${SI_BASE_URL%/}/lib/${_si_lib}.sh" -o "${_si_tmp}/${_si_lib}.sh" + else + wget -qO "${_si_tmp}/${_si_lib}.sh" "${SI_BASE_URL%/}/lib/${_si_lib}.sh" + fi + source "${_si_tmp}/${_si_lib}.sh" + done + unset _si_lib _si_tmp +fi +# --- end preamble ------------------------------------------------------------ + +SET_NAME="zsh-tmux-neovim" +COMPONENTS=(fzf zsh tmux lazygit neovim) + +si_banner "set: ${SET_NAME}" +si_distro_detect +si_pkgmap_load + +si_run_set + +si_set_default_shell_zsh +si_print_summary diff --git a/versions.env b/versions.env new file mode 100644 index 0000000..ff704d3 --- /dev/null +++ b/versions.env @@ -0,0 +1,5 @@ +# Versions of tools installed from GitHub release binaries. +# "latest" resolves through the releases/latest redirect; pin a concrete +# version (e.g. "v0.55.1") for reproducible installs. +LAZYGIT_VERSION=latest +TREE_SITTER_VERSION=latest