42 changed files with 2485 additions and 0 deletions
@ -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 <name>.dockerfile |
||||||
|
tmux, zsh, fzf, neovim, --> zsh-fzf, zsh-tmux, --> <name>.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 <relpath> [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/<tool>.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 <other>` (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/<tool>/` (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 <tool> <version>` and a one-line summary. |
||||||
|
|
||||||
|
## 6. Layer 2: sets / toolsets |
||||||
|
|
||||||
|
`sets/<name>.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/<set>.dockerfile` |
||||||
|
- `dockerfiles/<set>.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/<set>.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 <port>`), `trap` cleanup. |
||||||
|
3. `docker build --network=host |
||||||
|
--build-arg SHELL_INITS_BASE_URL=http://127.0.0.1:<port> |
||||||
|
--build-arg USER_UID=<uid> --build-arg USER_GID=<gid> |
||||||
|
--build-arg DISTRO=<arg or matrix>` (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/<set>:<distro-slug>`. 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 <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 <base> <path>` 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/<slug>.sh`. |
||||||
|
- `pkg_install <abstract...>` 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/<tool>.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/<tool>.dockerfile` |
||||||
|
builds a minimal image (base distro + that single core initializer, served |
||||||
|
via the same local-http flow). `tests/core/<tool>.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 <core|set>/<name> [--distro ...]` orchestrates build + |
||||||
|
assert + cleanup for the whole matrix; Makefile wraps the common cases. |
||||||
|
|
||||||
|
## 11. Extensibility recipes |
||||||
|
|
||||||
|
- **New core initializer**: add `core/<tool>.sh` following the contract (5), |
||||||
|
optionally `config/<tool>/`, pkgmap entries, a line in `versions.env` if it |
||||||
|
uses release binaries, and `tests/core/<tool>.{dockerfile,test.sh}`. |
||||||
|
Immediately usable standalone and from any set. |
||||||
|
- **New toolset**: add `sets/<name>.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 `<set>.*`. |
||||||
|
- **New distro**: add `lib/pkgmaps/<slug>.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 | `<id>-<version>` | `ubuntu-24.04`, `alpine-3.21` | |
||||||
|
| core script | `core/<tool>.sh` | `core/lazygit.sh` | |
||||||
|
| core test | `tests/core/<tool>.{dockerfile,test.sh}` | `tests/core/yazi.test.sh` | |
||||||
|
| set script | `sets/<components-joined>.sh` | `sets/zsh-tmux-neovim.sh` | |
||||||
|
| set test | `tests/sets/<set>.{dockerfile,test.sh}` | only for sets with extra steps | |
||||||
|
| dockerfile pair | `dockerfiles/<set>.{dockerfile,build.sh}` | `dockerfiles/zsh-tmux.build.sh` | |
||||||
|
| image | `shell-inits/<set>:<distro-slug>` | `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. |
||||||
@ -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 <base-url>/sets/zsh-tmux-neovim.sh | bash -s -- --base-url <base-url> |
||||||
|
``` |
||||||
|
|
||||||
|
`<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=<base-url> curl -fsSL <base-url>/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 <slug>` (repeatable), |
||||||
|
`--all`, `--gid`, `--tag`, `--no-cache`, and `--base-url <url>` for remote |
||||||
|
docker hosts or Docker Desktop (where `--network=host` does not work, serve the |
||||||
|
repo yourself and pass `http://host.docker.internal:<port>`). |
||||||
|
|
||||||
|
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 <branch>`), 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. |
||||||
@ -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 |
||||||
@ -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))) |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
-- shell-inits: early OSC52 clipboard setup must run before lazy.nvim |
||||||
|
require("config.clipboard") |
||||||
|
require("config.lazy") |
||||||
@ -0,0 +1 @@ |
|||||||
|
-- shell-inits: autocmds on top of LazyVim defaults |
||||||
@ -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" |
||||||
@ -0,0 +1,2 @@ |
|||||||
|
-- shell-inits: keymaps on top of LazyVim defaults |
||||||
|
-- (terminal keymaps are registered by lua/plugins/terminal.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", |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}) |
||||||
@ -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 = "<", |
||||||
|
} |
||||||
@ -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", |
||||||
|
} |
||||||
@ -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", |
||||||
|
}, |
||||||
|
}, |
||||||
|
} |
||||||
@ -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 }, |
||||||
|
}, |
||||||
|
} |
||||||
@ -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, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
} |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
-- shell-inits: terminal UX layered on snacks (<leader>tt, <leader>ft, <C-/>) |
||||||
|
return { |
||||||
|
"chodak166/snacks-terminal-ctl.nvim", |
||||||
|
name = "snacks-terminal-ctl.nvim", |
||||||
|
dependencies = { "folke/snacks.nvim" }, |
||||||
|
event = "VeryLazy", |
||||||
|
opts = {}, |
||||||
|
} |
||||||
@ -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"), |
||||||
|
}, |
||||||
|
} |
||||||
@ -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 |
||||||
@ -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" |
||||||
@ -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)' |
||||||
@ -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 |
||||||
@ -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" |
||||||
@ -0,0 +1,63 @@ |
|||||||
|
#!/usr/bin/env bash |
||||||
|
# core/fzf.sh - fuzzy finder, deployed from distro packages when available. |
||||||
|
# Standalone: curl -fsSL <base>/core/fzf.sh | bash -s -- --base-url <base> |
||||||
|
|
||||||
|
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)" |
||||||
@ -0,0 +1,72 @@ |
|||||||
|
#!/usr/bin/env bash |
||||||
|
# core/lazygit.sh - lazygit release binary + pastel config (terminal git UI). |
||||||
|
# Standalone: curl -fsSL <base>/core/lazygit.sh | bash -s -- --base-url <base> |
||||||
|
|
||||||
|
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" |
||||||
@ -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 <base>/core/neovim.sh | bash -s -- --base-url <base> |
||||||
|
# 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}" |
||||||
@ -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 <base>/core/tmux.sh | bash -s -- --base-url <base> |
||||||
|
|
||||||
|
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" |
||||||
@ -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 <base>/core/zsh.sh | bash -s -- --base-url <base> |
||||||
|
|
||||||
|
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}" |
||||||
@ -0,0 +1,121 @@ |
|||||||
|
#!/usr/bin/env bash |
||||||
|
# dockerfiles/{{SET}}.build.sh |
||||||
|
# Build fully-initialized shell-inits/{{SET}}:<distro> 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 <slug> repeatable; default: ubuntu-26.04 |
||||||
|
# --all build the whole supported distro matrix |
||||||
|
# --uid <N> uid of the image user, owns everything (default 1000) |
||||||
|
# --gid <N> gid of the image user (default: same as --uid) |
||||||
|
# --tag <tag> image tag (default: distro slug) |
||||||
|
# --no-cache pass --no-cache to docker build |
||||||
|
# --base-url <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:<port>) |
||||||
|
# --save also export the image to dist/<set>_<distro>[_<tag>].tar.gz |
||||||
|
# for offline use (docker load -i <file>) |
||||||
|
# -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" |
||||||
@ -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"] |
||||||
@ -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 |
||||||
@ -0,0 +1,121 @@ |
|||||||
|
#!/usr/bin/env bash |
||||||
|
# dockerfiles/zsh-tmux-neovim.build.sh |
||||||
|
# Build fully-initialized shell-inits/zsh-tmux-neovim:<distro> 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 <slug> repeatable; default: ubuntu-26.04 |
||||||
|
# --all build the whole supported distro matrix |
||||||
|
# --uid <N> uid of the image user, owns everything (default 1000) |
||||||
|
# --gid <N> gid of the image user (default: same as --uid) |
||||||
|
# --tag <tag> image tag (default: distro slug) |
||||||
|
# --no-cache pass --no-cache to docker build |
||||||
|
# --base-url <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:<port>) |
||||||
|
# --save also export the image to dist/<set>_<distro>[_<tag>].tar.gz |
||||||
|
# for offline use (docker load -i <file>) |
||||||
|
# -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" |
||||||
@ -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"] |
||||||
@ -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 <file> - 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 <owner/repo> <version|latest> <asset-pattern> [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}" |
||||||
|
} |
||||||
@ -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" |
||||||
|
} |
||||||
@ -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)" |
||||||
|
} |
||||||
@ -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 <relpath> -> 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 <relpath> -> 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 <relpath> -> file contents on stdout |
||||||
|
si_fetch() { |
||||||
|
local path |
||||||
|
path=$(si_fetch_file "$1") |
||||||
|
cat "$path" |
||||||
|
} |
||||||
|
|
||||||
|
# si_deploy_file <relpath> <absolute-dest-path> |
||||||
|
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" |
||||||
|
} |
||||||
@ -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 <abstract-name>... (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 |
||||||
|
} |
||||||
@ -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 |
||||||
|
) |
||||||
@ -0,0 +1,65 @@ |
|||||||
|
# lib/run-set.sh - shared runner used by sets/<name>.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' |
||||||
|
} |
||||||
@ -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 <component> [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 <component> <revision> |
||||||
|
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})" |
||||||
|
} |
||||||
@ -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 <base>/sets/zsh-tmux-neovim.sh | bash -s -- --base-url <base> |
||||||
|
# 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 |
||||||
Loading…
Reference in new issue