diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c099308..f9e9675 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} + - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends zsh fzf - run: bash -n install.sh uninstall.sh - run: python3 -m unittest discover -s tests -v lifecycle: diff --git a/README.md b/README.md index 6245d5c..45a1be9 100644 --- a/README.md +++ b/README.md @@ -1,425 +1,194 @@ # linux-setup -A reproducible Linux terminal setup with Zsh, Oh My Posh, productivity tools, -development helpers, and selective uninstall. Clone it onto another device to -reuse the same configuration, pinned tools, aliases, and scripts. +**A portable Linux terminal setup for everyday development.** -The default prompt is **quick-term**, with orange `╰─ $` transient prompts and a -blank line between commands. The earlier customized `iterm2` theme is also saved. +Zsh dotfiles, Oh My Posh themes, an fzf command picker, tmux, and a curated set of +command-line tools—installed in your home directory, with backups and selective +uninstall. Built for Ubuntu, Debian, and their WSL environments. -## Get started - -Clone or copy this folder to a persistent location, then run: - -```sh -git clone https://github.com/zdevzone/linux-setup.git ~/linux-setup -cd ~/linux-setup -./install.sh --preflight -./install.sh --dry-run -./install.sh -exec zsh -linux-doctor -``` +[![Validate setup](https://github.com/zdevzone/linux-setup/actions/workflows/test.yml/badge.svg)](https://github.com/zdevzone/linux-setup/actions/workflows/test.yml) +[![Platform: Linux](https://img.shields.io/badge/platform-Linux-2563eb)](docs/installation.md#supported-systems) +[![Shell: Zsh](https://img.shields.io/badge/shell-Zsh-64748b)](config/zsh/) -For a copied folder, start with `cd /path/to/linux-setup`. You can keep it anywhere; -rerun `./install.sh` if you move it. Keep the checkout: your live configuration -is linked to its files, so edits take effect without recopying them. +[Get started](#get-started) · [What's included](#included-tools) · [Command picker](#find-your-commands) · [Documentation](docs/README.md) -Prerequisites: Bash, **Python 3.9+**, trusted HTTPS/network access, and the -platform's base package tools. Git is needed to clone; downloading a ZIP also -works. On Ubuntu/Debian, `./install.sh --install-prerequisites` offers the required -system prerequisites and package-index refresh. This explicit option may request -administrator authentication; the normal tool installation uses your home directory. +## Terminal previews -| Platform | Installation | -| --- | --- | -| Ubuntu/Debian, x86_64 | Primary target. Missing base tools are extracted from APT packages into your home directory without sudo; package indexes must be available. Development testing used Ubuntu 26.04 x86_64. | -| Ubuntu/Debian, ARM64 | Linux release mappings are provided. Equivalent installation testing on ARM64 hardware has not yet been completed. | -| WSL with Ubuntu/Debian | Uses the Linux installation path. Install terminal fonts on Windows, where the terminal is rendered. | -| Other glibc Linux distributions | Install `zsh tmux file git curl lsof` and the PostgreSQL client (`psql`) with your package manager first. Compatibility depends on the pinned upstream binaries and the host libraries; these distributions are not yet integration-tested. | -| macOS / native Windows / musl Linux | Outside this release's supported targets. A native Windows/PowerShell setup is planned as a separate project. | +### Quick-term · default -See [installation and migration](docs/installation.md) for prerequisites, file -locations, default-shell configuration, optional previews, and moving an existing -`dev-setup` installation. +A two-line prompt with orange accents, directory and Git context, execution time, +and a compact transient prompt that keeps previous commands readable. -The default install does not change your account's login shell. The terminal -emulator and its font selection remain settings of your device. +![Quick-term terminal preview showing the orange prompt, Git branch, and project script shortcuts](docs/assets/quick-term.png) -## Uninstall +### iTerm2 · alternative -```sh -./uninstall.sh # Interactive program and cache checkboxes -./uninstall.sh --keep-tools --dry-run # Preview configuration removal only -./uninstall.sh --keep-tools # Restore config; keep installed programs -./uninstall.sh --remove bat --remove fd # Restore config; remove selected programs -./uninstall.sh --all-tools # Restore config; remove setup-owned programs -``` +A blue and lime prompt with Git state, exit-status color, and a rocket prompt. +The theme's name comes from upstream; it runs in your Linux terminal. -Programs and caches are kept unless selected. The uninstaller shows what it will -remove before asking for confirmation. Existing system/user installations are -preserved, as are the repository, personal settings, backups, history, projects, -and database recovery files. Removing the setup also removes its aliases and -prompt configuration; retained tools remain installed. +![iTerm2 theme preview showing the blue and lime prompt, Git branch, and project script shortcuts](docs/assets/iterm2.png) -If the managed Zsh is your login shell, it cannot be removed until the account -successfully switches to another registered shell. Cache cleanup is optional and -can cause future downloads or rebuilding. See the [uninstall guide](docs/uninstall.md) -for selection keys, automation, shared dependencies, and recovery. +These are rendered samples using the bundled themes, synthetic prompt context, +and real command-reference output. Appearance depends on your terminal and font. +[Switch themes](docs/customization.md#switch-the-prompt-theme) · [Preview gallery and image sources](docs/previews.md) -## Documentation +## Why use it? -- [Installation, prerequisites, supported systems, and migration](docs/installation.md) -- [Developer workflow and project command reference](docs/developer-workflow.md) -- [Selective uninstall, cache choices, and recovery](docs/uninstall.md) -- [Troubleshooting and development findings](docs/troubleshooting.md) -- [Development, tests, and maintaining pinned versions](docs/development.md) +- **Carry your setup between machines.** Shared configuration stays in this Git + checkout; private device settings stay in your home directory. +- **Find commands as you work.** Alt+X searches aliases, functions, descriptions, + and full commands, then inserts your selection for review. +- **Start with useful tools.** Search files, navigate projects, review Git changes, + manage terminal sessions, and work with JavaScript and Python environments. +- **Keep installation inspectable.** Preflight checks, dry runs, verified downloads, + tracked ownership, and backups explain what the setup changes. +- **Remove only what you choose.** Uninstall can restore configuration while + retaining programs, or remove selected setup-owned tools and caches. -## Included tools +## Get started -| Tool | Use | -| --- | --- | -| Zsh | Interactive shell, completion, persistent history | -| Oh My Posh | `quick-term` theme, spacing, transient prompts | -| fd / ripgrep (`rg`) | Find files / search their contents | -| fzf | Fuzzy selection and shell shortcuts | -| zoxide | Jump to frequently visited directories with `z` / `zi` | -| bat | File viewer with syntax highlighting | -| Yazi (`yazi`, `ya`) | File manager; use `y` to update your shell directory on exit | -| lazygit | Git interface, including delta diffs | -| tmux | Persistent sessions, windows, panes | -| eza | Detailed file listings and directory trees | -| delta | Git diffs with syntax highlighting and line numbers | -| jq | JSON querying and formatting | -| Vite+ (`vp`) | Manage Node.js runtimes, package managers, and the JavaScript toolchain | -| uv / uvx | Python versions, projects, packages, virtual environments, and tools | -| psql / lsof | PostgreSQL management client and TCP listener inspection | -| zsh-autosuggestions | Suggestions from history and completion | -| zsh-syntax-highlighting | Highlight valid commands and shell syntax | - -`tools.lock.json` pins upstream release versions, URLs, SHA-256 checksums, and -plugin commits. Downloads are verified before installation. Repeated installs -reuse verified downloads and installed versions. Base packages follow the target -OS's repositories. This reproduces the -configuration and pinned tools, rather than freezing the entire operating system. - -Vite+ and uv are **installed only when missing**. Existing installations and their -versions are preserved, including Vite+'s current and older installation locations and uv's older -Cargo location. The lockfile pins the versions used on fresh devices. A new Vite+ -installation retains its full release bundle and runs `vp env setup` to prepare -its bootstrap dependencies, official environment, and runtime/package-manager -shims. Zsh loads that environment automatically. uv and uvx shell -completion is enabled when available. Runtime versions and project dependencies -remain controlled by each project's own configuration and lockfiles. -The bundled Linux Vite+ installer targets glibc distributions such as Ubuntu and -Debian, so it downloads matching Node.js runtimes. musl-based distributions are -outside the supported targets for this release. - -Examples in a project directory: +You need Linux, Bash, Python 3.9+, working HTTPS certificate trust, and Git to +clone the repository. Ubuntu/Debian also need APT tools and populated package +indexes. See [prerequisites](docs/installation.md#before-starting) for fresh devices. ```sh -vp env install lts # Install a Node.js runtime through Vite+ -vp install # Install JavaScript project dependencies -vp run dev # Run a project task -uv init # Create a Python project -uv add requests # Add a Python dependency -uv run python # Run Python in the project's environment -uvx ruff check . # Run a Python tool without installing it globally +git clone https://github.com/zdevzone/linux-setup.git ~/linux-setup +cd ~/linux-setup +./install.sh --preflight +./install.sh --dry-run +./install.sh +export PATH="$HOME/.local/bin:$PATH" +exec zsh ``` -See the official [Vite+ guide](https://viteplus.dev/guide/) and -[uv installation guide](https://docs.astral.sh/uv/getting-started/installation/). -For existing manager installations, use their supported upgrade commands; -rerunning this installer deliberately keeps their current versions. - -Yazi's file search, text search, JSON previews, fuzzy navigation, and zoxide -integration have their companion tools installed. Extra preview formats are -optional: `ffmpeg` for video, Poppler for PDFs, and 7-Zip for archives. On Ubuntu: +In the new shell, run `linux-doctor` to check the installation. Keep the checkout +in a persistent location: live configuration links to these files. -```sh -sudo apt install ffmpeg poppler-utils 7zip -``` +Missing Zsh and other base tools are installed automatically on Ubuntu/Debian +without sudo. Existing base programs, Vite+, and uv are reused. If bootstrap +prerequisites are missing, `./install.sh --install-prerequisites` offers the +administrative package installation and index refresh before continuing. -Install a Nerd Font in the device that renders the terminal and select it in -your terminal's font settings. For a local desktop, one option is -`oh-my-posh font install meslo`. In WSL or SSH sessions, the font belongs on the -Windows/desktop client. Terminal font and color settings cannot be selected -reliably without knowing the terminal emulator. +Choose a **Nerd Font** in your terminal settings to display prompt icons. In WSL +or SSH, configure the font on the desktop client. Installation leaves your +account's login shell unchanged; see [default-shell setup](docs/installation.md#default-shell). +The installer also registers the Oh My Posh MCP server when Codex is present and +that registration is missing; pass `--skip-mcp` to skip it. -## Everyday shortcuts +### Platform support -| Command / key | Action | +| Platform | Status | | --- | --- | -| `ll`, `la`, `lt` | Detailed listing, all files, shallow tree | -| `ccat FILE` | View a file with bat | -| `z NAME`, `zi` | Jump by directory history / pick interactively | -| `y` | Open Yazi and follow its final directory | -| `lg` | Open lazygit | -| `fe` | Choose files with fzf and open them in `$EDITOR` | -| `cdf` | Choose and enter a directory | -| `search TEXT` | Search project contents with ripgrep | -| `mkcd DIR` | Create a directory and enter it | -| `up [N]` | Move up one or N directory levels | -| `..`, `...`, `....`, `.....` | Move up one, two, three, or four levels | -| `-`, `home`, `croot` | Previous directory, home, Git repository root | -| `work [DIR]` | Create or enter a tmux session for a project | -| `lip [PORT ...]` / `ports` | List TCP listeners, PIDs, and command names | -| `kip PORT [PORT ...]` | Confirm and send SIGTERM to the listeners on explicit ports | -| `serve [PORT] [DIR]` | Serve a directory on localhost (default port 8000) | -| `db [ENV_FILE]` / `dbm` | Interactive PostgreSQL database/schema manager | -| `dev-check [PATH]` | Read-only project dependency, Git, runtime, and Docker checks | -| `dev-browser doctor` | Check a project's Playwright/browser setup | -| `pathlist` | Print one PATH entry per line | -| `linux-doctor` / `dev-doctor` | Check installed tools and shell integration | -| `linuxsetup` / `devsetup` | Enter this repository | -| `reload` | Restart Zsh after config changes | -| `gs`, `gd`, `gds`, `gl` | Git status, diff, staged diff, recent graph | -| `ga`, `gc`, `gsw`, `gfetch` | Git add, commit, switch, fetch | -| Ctrl-R | Search command history with fzf | -| Ctrl-T | Select files with fzf and bat previews | -| Alt-C | Choose a directory with fzf | -| Right arrow | Accept an autosuggestion | - -Standard commands such as `ls`, `cat`, `cd`, and `rm` keep their normal meanings. -Dot-prefixed commands are navigation shortcuts: `..`, `...`, and `....`. - -tmux keeps **Ctrl-B** as the prefix. After the prefix, press `|` or `-` to split, -`h/j/k/l` to switch panes, `c` for a new window, `r` to reload config, and `d` to -detach. `work` names sessions by project directory plus a short path hash, so two -projects with the same directory name do not collide. Existing sessions are kept. - -## Project checks and browser tests +| Ubuntu/Debian, x86_64 | Primary target; development tested on Ubuntu 26.04, lifecycle CI configured on Ubuntu 24.04 | +| Ubuntu/Debian, ARM64 | Release mappings included; equivalent hardware validation still pending | +| Ubuntu/Debian in WSL | Uses the Linux installation path; fonts belong on the Windows client | +| Other glibc Linux distributions | Install base packages with your package manager first; not yet integration-tested | +| Native Windows, macOS, musl Linux | Outside the current supported targets | -```sh -dev-check # Inspect this checkout; --network opts into registry probes -dev-browser doctor # Inspect the project's Playwright/browser setup -dev-browser install # Download its pinned Chromium version -dev-browser run -- pnpm test:e2e -``` - -On Ubuntu/Debian, `dev-browser install --user-deps` can also extract required -APT libraries into a user cache without sudo. `run` applies those libraries only -to its child command. Project runtime versions, task names, dependencies, and -credentials remain project-owned. See the [developer workflow guide](docs/developer-workflow.md) -for the full recipe, optional aliases, environment checks, and database tools. - -## Ports and local servers - -```sh -lip # All visible TCP listeners -lip 3000 8000 # Only these ports -kip 3000 # Show the matching processes and ask before stopping -kip --force 3000 # Explicit SIGKILL for a process that will not stop -dev-ports kill 3000 --yes # Explicit noninteractive confirmation -dev-ports list --json # Structured listener information -serve 8000 ./public # Bind only to 127.0.0.1; Ctrl-C stops the server -``` +[Full installation guide](docs/installation.md) · [Migrating an existing setup](docs/installation.md#migrating-from-dev-setup) -`kip` only signals visible PIDs still listening on the requested ports. It uses -SIGTERM by default, checks for changed listeners before signalling, and never -requests sudo. Processes owned by other users may require you to act as their -owner. A single process can listen on several ports; stopping it affects all of -its listeners. `lsof` is installed when needed; Linux can also inspect via `ss`. - -## PostgreSQL database and schema manager +## Included tools -From a project's directory: +| Area | Included | +| --- | --- | +| Shell and prompt | Zsh, Oh My Posh, two bundled themes, autosuggestions, syntax highlighting, completion, persistent history | +| Search and navigation | fzf, fd, ripgrep, zoxide, custom command picker | +| Files and previews | eza, bat, Yazi, jq | +| Git and sessions | lazygit, delta, tmux, project session helper | +| JavaScript and Python | Vite+ (`vp`), uv, uvx; existing installations are preserved | +| Local development | TCP listener/port helpers, localhost HTTP server, project readiness checks, Playwright workflow helper | +| PostgreSQL | `psql` client, connection discovery, interactive database/schema manager | +| Setup maintenance | `linux-doctor`, preflight and dry-run checks, verified tool downloads, backup and uninstall workflows | -```sh -db # Read .env in the current directory -db .env.local # Read a different file -db -f ./config/development.env -db --var DATABASE_URL # Only use this variable -db --var DB_URL --var TEST_DB_URL -db --connections # Show redacted targets without connecting -db --maintenance-db app_admin # If the role cannot connect to postgres -``` +[`tools.lock.json`](tools.lock.json) pins release artifacts and plugin commits; +base packages follow the host distribution. Project runtimes and dependencies +remain controlled by each project's configuration and lockfiles. -The installer supplies `psql`; this does **not** install or start a PostgreSQL -server. It uses the database role and permissions in the selected connection. -The server's `postgres` database is used for database-level operations by default. -Your role needs CONNECT there and the appropriate CREATE/ownership privileges. +The command library also includes shortcuts for pnpm, Cargo, Laravel, Docker, +GitHub CLI, Kubernetes, Terraform, Codex, and Claude Code. **Those shortcuts do +not install their target tools.** The PostgreSQL setup installs a client, not a +server. [Complete tool reference](docs/tools.md) · [Developer command groups](docs/developer-workflow.md#discover-and-use-custom-commands) -Recognized variables include `DB_URL`, `DATABASE_URL`, `TEST_DB_URL`, -`TEST_DATABASE_URL`, `DB_URLS`, and `DATABASE_URLS`. Use `--var NAME` for any other -name. Multiple connections can be a JSON array or a named JSON object on a single line: +## Find your commands -```dotenv -DATABASE_URL=postgresql://app:password@localhost:5432/my_project -DB_URLS='{"local":"postgresql://localhost/my_project","test":"postgresql://localhost/my_project_test"}' -# Or: DB_URLS='["postgresql://localhost/my_project","postgresql://localhost/my_project_test"]' -``` +Press **Alt+X** to search by shortcut, description, or full command. The preview +shows the complete alias expansion or function definition. Enter places the +selection in your editable prompt; it does not run it. Escape or Ctrl+C keeps +your original line. -An example lives in `examples/postgres.env.example`. URL-encode special characters -in passwords. The parser supports `export`, quotes, inline comments, and `${VAR}` -references in double-quoted/unquoted values. It does not execute shell code. -Single quotes keep values literal. Credentials stay outside this repository; -passwords are supplied through a temporary mode-0600 password file and removed -after each `psql` call. Connection menus show names and endpoints without passwords. -Existing `.pgpass` authentication works when the URL contains no password. - -Menus: - -1. Select a connection, then **Databases** or **Schemas**. -2. For schemas, select the database to manage. -3. Choose **List**, **Create**, **Remove**, or **Reset**. -4. Use arrows to move, Space to select multiple targets, and Enter to accept. - Escape returns to the previous menu. Ctrl-A selects all and Ctrl-D clears a - batch selection. Single-choice menus accept Space or Enter. - -Remove/reset shows the target server and object names and requires an exact typed -phrase such as `RESET app_test`. There are no table-management actions. Removing -or resetting a database/schema deletes its contained objects and data; schema -CASCADE can also remove dependent objects in other schemas. - -- Database reset drops and recreates it from `template0`, preserving its owner, - encoding/locale, tablespace, connection limit, access grants, and comment. Its - custom schemas, data, installed extensions, and per-database settings are not - restored. Active connections are **not** forcibly terminated. -- Schema reset drops and recreates the schema in one transaction, preserving its - owner, access grants, and comment. A failed schema reset rolls back. -- Database reset cannot be atomic. If recreation fails after a successful drop, - a private recreation script is kept under - `~/.local/state/linux-setup/db-recovery/`; inspect the server and script before - recovering. Other successful targets in a batch are not rolled back. -- `postgres`, template databases, the selected maintenance database, and system - schemas are excluded from destructive selection. `public` is a user schema and - can be explicitly reset or removed after confirmation. - -SSL options such as `sslmode=require` are respected. Common ORM-only fields -(`schema`, `connection_limit`, `pool_timeout`, `pgbouncer`, `socket_timeout`, and -`statement_cache_size`) are ignored. Other unsupported URL options produce an -error rather than being silently discarded. Use a direct database endpoint when -a transaction-pooling proxy disallows database-level DDL. - -Tests never read your project `.env` or connect to its databases. To additionally -run the integration tests, point this command at installed PostgreSQL **server** -binaries; the test creates, starts, and removes its own private cluster: +![fzf command picker showing Docker shortcuts with descriptions and a full command preview](docs/assets/command-picker.png) ```sh -LINUX_SETUP_TEST_POSTGRES_BIN=/path/to/postgresql/bin \ - python3 -m unittest discover -s tests -v +devcommands docker # Open the picker with a search +devhelp pnpm # Print matching commands without opening fzf ``` -## Customize your setup - -```text -linux-setup/ -├── install.sh # Bootstrap entry point -├── uninstall.sh # Configuration, program, and cache removal -├── tools.lock.json # Pinned release assets and plugin commits -├── bin/ # Your portable executable scripts -├── config/ -│ ├── zsh/ # Shell settings, aliases, functions -│ ├── oh-my-posh/ # quick-term and iterm2 prompt themes -│ ├── tmux/ # Pane, session, key, and status settings -│ ├── git/ # Included delta settings -│ ├── bat/ # File viewer settings -│ ├── ripgrep/ # Search settings -│ ├── yazi/ # File manager settings -│ ├── lazygit/ # Git interface settings -│ └── codex/ # Reference Oh My Posh MCP entry -├── scripts/ # Installer, uninstaller, helpers, lock updater -├── docs/ # Installation, recovery, troubleshooting, development -└── tests/ # Installer, uninstall, helper, and isolated DB tests -``` +Aliases insert their expanded command; functions insert their name so you can +supply arguments. If your terminal intercepts Alt+X, use Escape then x, or type +`devcommands`. New described aliases/functions appear after `reload`. -Add shared aliases to `config/zsh/aliases.zsh`, functions to -`config/zsh/functions.zsh`, and executable scripts to `bin/`. Rerun the installer -to link new scripts. Edit the active theme in -`config/oh-my-posh/quick-term.omp.json`. Change its path in `config/zsh/zshrc` to -switch themes. Use `reload` after changing shell initialization or prompt settings. +### Shortcuts to try first -Private or device-specific additions go in: +| Command / key | Action | +| --- | --- | +| Ctrl+R / Ctrl+T / Alt+C | Search history / choose files / choose a directory | +| `ll`, `lt`, `ccat FILE` | Detailed listing, shallow tree, highlighted file contents | +| `z NAME`, `y`, `lg` | Jump by directory history, open Yazi, open lazygit | +| `work [DIR]` | Open a named tmux session for a project | +| `lip`, `kip PORT` | Inspect TCP listeners; confirm before stopping a port's processes | +| `dev-check`, `dev-browser doctor` | Check project readiness and browser-test prerequisites | +| `db --connections`, `db` | Inspect PostgreSQL connection targets; open the database manager | -```text -~/.config/linux-setup-local/zshenv # Environment needed by all Zsh processes -~/.config/linux-setup-local/zshrc # Interactive additions, editor choice, local aliases -``` +[All terminal shortcuts](docs/terminal.md) · [Developer workflows](docs/developer-workflow.md) · [Database guide](docs/databases.md) -For example, put `export EDITOR='nvim'` in the local zshrc if Neovim is installed. -The initial migration preserves the existing Vite+ environment hook in the local -zshenv. Other old files are backed up for reference. Your Git identity, credentials, -SSH keys, shell history, project files, downloaded binaries, and private Codex -settings are not copied into the repository. +## Make it yours -XDG config/data/cache/state directories are respected. Executables always go in -`~/.local/bin`. Rootless base-package binaries and their runtime paths are -generated separately on each device. +Shared aliases live in [`config/zsh/aliases.zsh`](config/zsh/aliases.zsh), functions +in [`config/zsh/functions.zsh`](config/zsh/functions.zsh), and themes in +[`config/oh-my-posh/`](config/oh-my-posh/). Add a description comment immediately +above a command definition so the picker can explain it. Run `reload` afterward. -`LINUX_SETUP_DIR` points to the active checkout through its configuration link. -`DEV_SETUP_DIR` remains a compatibility alias for existing personal scripts. +Device-specific settings belong in `~/.config/linux-setup-local/zshrc` and +`zshenv`. Keep credentials, Git identity, SSH keys, and project environment files +outside this repository. [Customization guide](docs/customization.md) -## Maintenance and recovery +## Update or uninstall + +From your installation checkout: ```sh -./install.sh --config-only --skip-mcp # Link config without downloading tools -./install.sh --preflight # Check installation prerequisites -./install.sh --check # Same checks as linux-doctor -./install.sh --mcp-only # Register the Oh My Posh MCP in existing Codex -./install.sh --restore # Restore configs; keep programs/caches/MCP -./uninstall.sh --keep-tools # Restore configuration; retain programs -python3 -m unittest discover -s tests -v +git pull --ff-only +./install.sh # Refresh config and pinned tools ``` -The default install registers `https://ohmyposh.dev/api/mcp` as `oh-my-posh` when -Codex is installed and that registration is absent. An existing registration is -preserved. Use `--skip-mcp` to skip this. Restart Codex to load a new server. -Uninstallation reverses recorded, unchanged setup-owned integrations. -Pre-existing or older untracked MCP registrations remain for manual review. -The historical `--restore` option restores only setup configuration and the Git -include; it retains installed programs, caches, and the MCP registration. Prefer -the uninstaller for a complete removal plan and report. - -Original files are moved to timestamped directories under -`~/.local/state/linux-setup/backups/`. The deployment manifest records what was -replaced and which programs belong to this setup. Restoration only touches paths -still owned by the installer; changed files are left for you to review. Use the -uninstaller to choose whether installed programs and caches also go. -Existing tmux servers are not restarted automatically; reload with Ctrl-B, then r. - -To update selected pinned tools, review and commit the lockfile changes: +Use `./install.sh --config-only --skip-mcp` for configuration changes without tool +downloads. Existing Vite+ and uv versions are preserved; update those managers +through their own supported upgrade workflows. ```sh -python3 scripts/update-lock.py fd fzf yazi -./install.sh -linux-doctor -git diff -- tools.lock.json +./uninstall.sh --keep-tools --dry-run # Preview configuration restoration +./uninstall.sh --keep-tools # Restore config; retain installed tools +./uninstall.sh # Choose programs and caches interactively ``` -Plugin commits are intentionally explicit in the lockfile; update their release -tag, commit, archive URL, and SHA-256 together. The lock updater does not silently -upgrade plugins or existing Vite+/uv installations. +Programs and caches are kept unless selected. The uninstaller preserves external +installations and changed files, and checks whether managed Zsh is still your +login shell. [Uninstall and recovery guide](docs/uninstall.md) -If a download fails, rerun the installer; completed verified downloads are reused. -If APT cannot locate a missing base package, update your OS's package indexes -using its usual administration workflow, then rerun. +## Documentation and contributions -## Set Zsh as your default shell +Browse the [documentation index](docs/README.md) for installation, terminal +commands, themes, database management, troubleshooting, and maintenance. -The installer adds tools to Zsh's PATH. To change the account's login shell, run -these yourself, authenticating when required: +For changes, start with the [development guide](docs/development.md). Local checks: ```sh -command -v zsh -# Register this path only if it is not already present: -grep -qxF "$HOME/.local/bin/zsh" /etc/shells || \ - printf '%s\n' "$HOME/.local/bin/zsh" | sudo tee -a /etc/shells -chsh -s "$HOME/.local/bin/zsh" +bash -n install.sh uninstall.sh +python3 -m unittest discover -s tests -v ``` -If your device uses a system-provided Zsh and has no `~/.local/bin/zsh`, use the -absolute path printed by `command -v zsh` in both commands. Log out and back in. - -## Share your configuration - -The project repository is [zdevzone/linux-setup](https://github.com/zdevzone/linux-setup). -Fork it to maintain your own shared configuration. Commit portable changes, then -pull on your other devices and rerun the installer when adding or updating tools -or scripts. Installation itself never publishes your files. - -Keep secrets and device-specific additions in the local configuration directory, -outside the checkout. Real dotenv files, credentials, shell history, runtime -downloads, and project databases do not belong in the repository. +Report reproducible issues through [GitHub Issues](https://github.com/zdevzone/linux-setup/issues). +Include your distribution, architecture, and redacted error output. -Upstream project links and theme attribution are in [NOTICE.md](NOTICE.md). +The bundled themes are customized from Oh My Posh. Downloaded tools retain their +upstream licenses; see [project acknowledgements and notices](NOTICE.md). diff --git a/config/zsh/aliases.zsh b/config/zsh/aliases.zsh index d19369c..be47322 100644 --- a/config/zsh/aliases.zsh +++ b/config/zsh/aliases.zsh @@ -1,43 +1,345 @@ -# Navigation +# Shared shortcuts. Companion helpers live in functions.zsh. +# Run devhelp or press Alt+X to discover commands and their descriptions. + +# Navigation. + +# Move up one directory. alias ..='cd ..' +# Move up two directories. alias ...='cd ../..' +# Move up three directories. alias ....='cd ../../..' +# Move up four directories. alias .....='cd ../../../..' +# Return to the previous directory. alias -- -='cd -' +# Go to your home directory. alias home='cd ~' +# Go to the Git repository root, or stay in the current directory. alias croot='cd "$(git rev-parse --show-toplevel 2>/dev/null || print -r -- "$PWD")"' +# Clear the terminal. alias c='clear' +# Clear the terminal (compatibility shortcut). alias cls='clear' +# Restart Zsh to reload your configuration. alias reload='exec zsh' +# Go to your development directory ($DEV_DIR). alias cdev='cd "$DEV_DIR"' +# Go to the setup directory ($DEV_SETUP_DIR). alias csetup='cd "$DEV_SETUP_DIR"' +# Go to the linux-setup checkout. alias devsetup='cd "$LINUX_SETUP_DIR"' +# Go to the linux-setup checkout (alternate name). alias linuxsetup='cd "$LINUX_SETUP_DIR"' -# Modern tools get explicit shortcuts; standard commands remain available. -if (( $+commands[eza] )); then - alias ll='eza --long --all --group-directories-first --git' - alias la='eza --all --group-directories-first' - alias lt='eza --tree --level=2 --group-directories-first' -fi +# Modern tools; aliases are available when their tools are installed. + +# List all files with details, Git status, and directories first. +(( $+commands[eza] )) && alias ll='eza --long --all --group-directories-first --git' +# List all files with directories first. +(( $+commands[eza] )) && alias la='eza --all --group-directories-first' +# Show a directory tree two levels deep. +(( $+commands[eza] )) && alias lt='eza --tree --level=2 --group-directories-first' +# View files with bat syntax highlighting. (( $+commands[bat] )) && alias ccat='bat' +# Open the lazygit interface. (( $+commands[lazygit] )) && alias lg='lazygit' +# Start or control tmux. (( $+commands[tmux] )) && alias t='tmux' -# Git shortcuts avoid destructive operations. +# Git. + +# Show concise Git status and branch information. alias gs='git status --short --branch' +# Show unstaged Git changes. alias gd='git diff' +# Show staged Git changes. alias gds='git diff --staged' +# Show a graph of the latest 20 commits. alias gl='git log --oneline --graph --decorate -20' +# Stage paths for a Git commit. alias ga='git add' +# Create a Git commit. alias gc='git commit' +# Switch Git branches. alias gsw='git switch' +# Create and switch to a new Git branch. +alias gswc='git switch -c' +# Fetch all Git remotes and prune stale remote branches. alias gfetch='git fetch --all --prune' +# List Git worktrees. +alias gwt='git worktree list' +# List local and remote Git branches. +alias gbranches='git branch --all' +# Show the latest commit and its file statistics. +alias glast='git log -1 --stat' +# Show Git reference history with relative dates. +alias greflog='git reflog --date=relative' +# List paths with unresolved merge conflicts. +alias gconflicts='git diff --name-only --diff-filter=U' +# List Git remotes and their URLs. +alias gremote='git remote -v' +# Pull Git changes only when a fast-forward is possible. +alias gpull='git pull --ff-only' + +# GitHub CLI; commands use the current repository by default. + +# Open the current GitHub repository in a browser. +alias ghrepo='gh repo view --web' +# List pull requests. +alias ghprs='gh pr list' +# Inspect a pull request. +alias ghpr='gh pr view' +# Show pull request checks. +alias ghchecks='gh pr checks' +# List workflow runs. +alias ghruns='gh run list' +# Inspect a workflow run; add --log-failed to view failed job logs. +alias ghrun='gh run view' + +# Project scripts and dependencies; named scripts must exist in package.json. + +# Run pnpm with the project runtime and package-manager configuration. +alias pn='pnpm' +# Install project dependencies with pnpm. +alias pi='pnpm install' +# Install dependencies without changing the existing lockfile. +alias pif='pnpm install --frozen-lockfile' +# Run a named package.json script. +alias pr='pnpm run' +# Run the project dev script. +alias pd='pnpm run dev' +# Run the project build script. +alias pb='pnpm run build' +# Run the full project check script. +alias pcheck='pnpm run check' +# Run the full project test script. +alias pt='pnpm run test' +# Run the project format script. +alias pfmt='pnpm run format' +# Run the project start script. +alias pstart='pnpm run start' +# Run a locally installed package executable. +alias px='pnpm exec' +# Select a pnpm workspace package for a command. +alias pfilter='pnpm --filter' +# Add a project dependency. +alias pa='pnpm add' +# Add a development dependency. +alias pad='pnpm add --save-dev' +# List outdated project dependencies. +alias pout='pnpm outdated' +# Explain why a dependency is installed. +alias pwhy='pnpm why' + +# Vite+; direct tools do not run project check/test wrappers. + +# Install project dependencies through Vite+. +alias vpi='vp install' +# Run a named project task through Vite+. +alias vr='vp run' +# Run the direct Vite+ check tool. +alias vcheck='vp check' +# Format code with the direct Vite+ tool. +alias vfmt='vp fmt' +# Run tests once with the direct Vite+ tool. +alias vtest='vp test run' +# Watch tests with the direct Vite+ tool. +alias vwatch='vp test watch' + +# Rust / Cargo. + +# Build the Rust project. +alias cgb='cargo build' +# Check the Rust project without producing an executable. +alias cgc='cargo check' +# Run Rust tests. +alias cgt='cargo test' +# Run the Rust project; pass program arguments after --. +alias cgr='cargo run' +# Format Rust code. +alias cgf='cargo fmt' +# Run Clippy lint checks. +alias cgl='cargo clippy' +# Build an optimized Rust release. +alias cgrelease='cargo build --release' +# Check Rust formatting without changing files. +alias cgfcheck='cargo fmt --check' +# Generate and open Rust documentation. +alias cgdoc='cargo doc --open' + +# Laravel; run from the application root, or use Sail for container PHP. + +# Run an Artisan command using host PHP. +alias art='php artisan' +# Start the Laravel development server using host PHP. +alias arts='php artisan serve' +# Run Laravel tests using host PHP. +alias artt='php artisan test' +# List Laravel routes using host PHP. +alias artroutes='php artisan route:list' +# Run Laravel Sail; use sail artisan or sail test for container PHP. +alias sail='./vendor/bin/sail' -# Local development helpers. +# Docker; commands use the active Docker context and current Compose project. + +# List running Docker containers. +alias dps='docker ps' +# List all Docker containers. +alias dpsa='docker ps --all' +# Follow a container log, starting with the last 100 lines. +alias dlogs='docker logs --follow --tail=100' +# Show live Docker container resource usage. +alias dstats='docker stats' +# Show live Docker container resource usage (existing shortcut). +alias dcstats="docker stats" +# Show Docker disk usage. +alias ddf='docker system df' +# Show the active Docker context. +alias dctx='docker context show' +# List available Docker contexts. +alias dctxs='docker context ls' +# Change the active Docker context. +alias duse='docker context use' +# Run a Compose command; supports -f FILE before the subcommand. +alias dco='docker compose' +# Start Compose services in the background. +alias dcup="docker compose up -d" +# Stop Compose services without removing containers or volumes. +alias dcstop='docker compose stop' +# Stop and remove Compose containers and networks. +alias dcdown="docker compose down" +# Stop Compose and DELETE its volumes, containers, and networks. +alias dcdown-v="docker compose down -v" +# Build images and start Compose services in the background. +alias dcpack="docker compose up -d --build" +# Restart Compose services. +alias dcrestart="docker compose restart" +# Show Compose service status. +alias dcps='docker compose ps' +# Follow Compose logs, starting with the last 100 lines. +alias dclog='docker compose logs --follow --tail=100' +# Follow Compose logs with the existing default log history. +alias dclogs="docker compose logs -f" +# Execute a command in a running Compose service. +alias dcex='docker compose exec' +# Build Compose service images. +alias dcbuild='docker compose build' +# Validate Compose configuration without printing expanded values. +alias dccheck='docker compose config --quiet' +# List Compose service names. +alias dcservices='docker compose config --services' +# DELETE Compose volumes and unused Docker resources, then rebuild and start. +alias docker-nuclear="echo '⚠️ Starting full environment wipe...' && docker compose down -v && docker system prune -a --volumes -f && docker compose build --no-cache && docker compose up -d && echo '✅ System fully rebuilt from scratch!'" + +# Kubernetes; install kubectl separately and select context/namespace explicitly. + +# Run kubectl with the selected context and namespace. +alias k='kubectl' +# Show the active Kubernetes context. +alias kctx='kubectl config current-context' +# List available Kubernetes contexts. +alias kcontexts='kubectl config get-contexts' +# Change the active Kubernetes context. +alias kuse='kubectl config use-context' +# List Kubernetes namespaces. +alias kns='kubectl get namespaces' +# List Kubernetes nodes with additional details. +alias knodes='kubectl get nodes -o wide' +# List pods with additional details; add -n NAMESPACE as needed. +alias kpods='kubectl get pods -o wide' +# List Kubernetes services. +alias ksvc='kubectl get services' +# List Kubernetes deployments. +alias kdeploy='kubectl get deployments' +# Describe a Kubernetes resource. +alias kdesc='kubectl describe' +# Follow pod logs, starting with the last 100 lines. +alias klogs='kubectl logs --follow --tail=100' +# List Kubernetes events by creation time. +alias kevents='kubectl get events --sort-by=.metadata.creationTimestamp' + +# Terraform; install separately and run from the infrastructure directory. + +# Run Terraform. +alias tf='terraform' +# Initialize Terraform, including any configured remote backend. +alias tfinit='terraform init' +# Preview Terraform changes; may contact provider APIs. +alias tfplan='terraform plan' +# Validate the Terraform configuration. +alias tfvalidate='terraform validate' +# Format Terraform files recursively. +alias tffmt='terraform fmt -recursive' +# Check Terraform formatting recursively without changing files. +alias tffmtcheck='terraform fmt -check -recursive' + +# Host and network diagnostics; journal commands require systemd. + +# List failed systemd units. +alias svcfailed='systemctl --failed --no-pager' +# List running systemd services. +alias svcs='systemctl list-units --type=service --state=running --no-pager' +# Show the last 100 error messages from the current boot journal. +alias jerrors='journalctl -b -p err -n 100 --no-pager' +# Show filesystem space and types in human-readable units. +alias disks='df -hT' +# Show memory and swap usage. +alias mem='free -h' +# Show disk usage one directory level deep. +alias dus='du -h --max-depth=1' +# Show listening TCP/UDP sockets and their processes. +alias listens='ss -lntup' +# Show a brief list of network addresses. +alias ipbrief='ip -brief address' + +# Local development and PostgreSQL; configure service profiles in ~/.pg_service.conf. + +# List TCP listeners, optionally restricted to specified ports. alias lip='dev-ports list' +# List TCP listeners (alternate name for lip). alias ports='dev-ports list' +# Confirm and stop listeners on explicitly specified ports. alias kip='dev-ports kill' +# Open the PostgreSQL database/schema manager. alias db='dev-db' +# Open the PostgreSQL database/schema manager (alternate name). alias dbm='dev-db' +# Inspect project dependencies, Git, runtimes, and Docker. +alias dcheck='dev-check' +# Check or run a project browser-testing environment. +alias dbr='dev-browser' +# Connect to the private PostgreSQL service profile named local. +alias pg-local='psql -X "service=local"' +# Connect to the private PostgreSQL service profile named remote. +alias pg-remote='psql -X "service=remote"' +# Connect to the private PostgreSQL service profile named bingo. +alias pg-bingo='psql -X "service=bingo"' +# Print each PATH directory on a separate line. alias pathlist='print -rl -- $path' + +# JSON and file searching. + +# Pretty-print JSON from a file or standard input. +alias jpretty='jq .' +# Print compact JSON from a file or standard input. +alias jcompact='jq -c .' +# List files including hidden paths, excluding .git. +alias rgfiles='rg --files --hidden --glob !.git' + +# AI agents; each CLI retains its configured model and permissions. + +# Start Codex. +alias cx='codex' +# Choose a Codex session to resume. +alias cxr='codex resume' +# Continue the latest Codex session. +alias cxc='codex resume --last' +# Run Codex in exec mode. +alias cxe='codex exec' +# Start Claude Code. +alias cld='claude' +# Choose a Claude Code session to resume. +alias cldr='claude --resume' +# Continue the latest Claude Code session. +alias cldc='claude --continue' diff --git a/config/zsh/command-picker.zsh b/config/zsh/command-picker.zsh new file mode 100644 index 0000000..c1ec1ca --- /dev/null +++ b/config/zsh/command-picker.zsh @@ -0,0 +1,68 @@ +# Keep the live definitions separate from descriptions read from source comments. +_dev_commands_snapshot() { + emulate -L zsh + zmodload zsh/parameter || return + local name + for name in ${(ok)aliases}; do + builtin printf '%s\0%s\0%s\0' alias "$name" "${aliases[$name]}" + done + for name in ${(ok)functions}; do + [[ "$name" == _* ]] && continue + builtin printf '%s\0%s\0%s\0' function "$name" "${functions[$name]}" + done +} + +_dev_commands_list() { + emulate -L zsh + setopt pipefail + _dev_commands_snapshot | command python3 "$LINUX_SETUP_DIR/scripts/command_catalog.py" --list -- "$*" +} + +_dev_commands_select() { + emulate -L zsh + setopt pipefail + if (( ! $+commands[fzf] )); then + print -u2 'Command picker requires fzf. Run ./install.sh from your linux-setup checkout.' + return 2 + fi + _dev_commands_snapshot | command python3 "$LINUX_SETUP_DIR/scripts/command_catalog.py" --pick -- "$*" +} + +# Search custom aliases/functions and insert a command for editing without running it. +devcommands() { + emulate -L zsh + local selected result + selected="$(_dev_commands_select "$@")" + result=$? + (( result == 1 )) && return 0 + (( result == 0 )) || return $result + if [[ -o interactive ]]; then + print -rz -- "$selected" + else + print -r -- "$selected" + fi +} + +_dev_commands_widget() { + emulate -L zsh + local selected result + selected="$(_dev_commands_select)" + result=$? + if (( result == 0 )); then + BUFFER="$selected" + CURSOR=${#BUFFER} + fi + zle reset-prompt + (( result == 1 )) && return 0 + return $result +} + +if [[ -o interactive ]]; then + zle -N dev-commands _dev_commands_widget + # Bind both cases, including vi modes selected by a private zshrc later on. + for _dev_command_keymap in emacs viins vicmd; do + bindkey -M "$_dev_command_keymap" '^[x' dev-commands + bindkey -M "$_dev_command_keymap" '^[X' dev-commands + done + unset _dev_command_keymap +fi diff --git a/config/zsh/functions.zsh b/config/zsh/functions.zsh index 7099471..9a92a76 100644 --- a/config/zsh/functions.zsh +++ b/config/zsh/functions.zsh @@ -63,3 +63,147 @@ work() { local project="${1:-$PWD}" command dev-session "$project" } + +# DELETE a named Docker volume and restart its Compose service; dcdrop-vol SERVICE VOLUME. +dcdrop-vol() { + if [ -z "$1" ] || [ -z "$2" ]; then + echo "❌ Usage: dcdrop-vol " + return 1 + fi + echo "Stopping service $1 and destroying volume $2..." + docker compose stop "$1" + docker compose rm -f "$1" + docker volume rm "$2" + docker compose up -d "$1" + echo "✅ Service $1 reset with a fresh volume!" +} + +# Rebuild and restart one Compose service without its dependencies; dcrefresh SERVICE. +dcrefresh() { + if [ -z "$1" ]; then + echo "❌ Usage: dcrefresh " + return 1 + fi + docker compose up -d --no-deps --build "$1" +} + +# Open a container shell, trying bash then sh; dcinto CONTAINER. +dcinto() { + if [ -z "$1" ]; then + echo "❌ Usage: dcinto " + return 1 + fi + docker exec -it "$1" bash 2>/dev/null || docker exec -it "$1" sh +} + +# Show the reference for the existing Docker reset and Compose helpers. +dc() { + echo -e "\033[1;36m=========================================================" + echo -e "🔮 DEVOPS & DOCKER COMMAND MEMORY ASSISTANT" + echo -e "=========================================================\033[0m" + echo -e "\033[1;31m🚨 THE NUCLEAR WIPE:\033[0m" + echo -e " \033[33mdocker-nuclear\033[0m - Wipes everything, cleans cache, rebuilds, and restarts fresh." + echo "" + echo -e "\033[1;34m📦 DOCKER COMPOSE SHORTCUTS:\033[0m" + echo -e " \033[32mdcup\033[0m - Spin up the stack in the background (detached)." + echo -e " \033[32mdcdown\033[0m - Stop and remove containers and networks." + echo -e " \033[32mdcdown-v\033[0m - Stop containers and completely \033[4;31mDESTROY\033[0m data volumes." + echo -e " \033[32mdcpack\033[0m - Rebuild images and boot up the stack." + echo -e " \033[32mdcrestart\033[0m - Fast restart for all stack containers." + echo -e " \033[32mdclogs\033[0m - Follow logs in real-time." + echo -e " \033[32mdcstats\033[0m - Show live RAM/CPU performance graphs." + echo "" + echo -e "\033[1;35m🛠️ DYNAMIC SMART FUNCTIONS:\033[0m" + echo -e " \033[32mdcinto \033[0m - Jump straight inside a container terminal (tries bash, then sh)." + echo -e " \033[32mdcrefresh \033[0m - Hot-rebuild and restart just ONE service silently." + echo -e " \033[32mdcdrop-vol \033[0m - Kill one specific database service, drop its data, and start it clean." + echo -e "\033[1;36m=========================================================\033[0m" +} + +# Show package scripts without executing them; accepts a different manifest. +pscripts() { + if (( $# > 1 )); then + print -u2 'Usage: pscripts [PACKAGE_JSON]'; return 2 + fi + command jq '.scripts // {}' -- "${1:-package.json}" +} + +# Search commit messages for literal text across all locally known refs. +gfind() { + if (( $# == 0 )); then + print -u2 'Usage: gfind TEXT'; return 2 + fi + command git log --all --oneline --fixed-strings "--grep=$*" +} + +# Open a shell in an existing container; sh works on many minimal images. +# Images without a shell (for example distroless) cannot use these helpers. +dsh() { + if (( $# < 1 || $# > 2 )) || [[ -z "$1" || "$1" == -* ]]; then + print -u2 'Usage: dsh CONTAINER [SHELL, default sh]'; return 2 + fi + command docker exec -it "$1" "${2:-sh}" +} + +# Open a shell in a Compose service using this directory's project and active Docker context. +dcsh() { + if (( $# < 1 || $# > 2 )) || [[ -z "$1" || "$1" == -* ]]; then + print -u2 'Usage: dcsh SERVICE [SHELL, default sh]'; return 2 + fi + command docker compose exec "$1" "${2:-sh}" +} + +# Follow a system service's journal, starting with the last N lines. +# For a user service use: journalctl --user -u UNIT -n 100 -f +junit() { + if (( $# < 1 || $# > 2 )) || [[ -z "$1" || "${2:-100}" != <1-10000> ]]; then + print -u2 'Usage: junit UNIT [LINES from 1 to 10000]'; return 2 + fi + command journalctl --unit "$1" --lines "${2:-100}" --follow +} + +# Perform one HTTP GET; discard the body and print status/timing metrics. +# Redirects are reported, not followed. HTTP 4xx/5xx return a nonzero status. +httpcheck() { + if (( $# != 1 )) || [[ "$1" != http://* && "$1" != https://* ]]; then + print -u2 'Usage: httpcheck http[s]://HOST/PATH'; return 2 + fi + command curl --globoff --fail --silent --show-error \ + --connect-timeout 10 --max-time 30 --output /dev/null \ + --write-out 'HTTP %{http_code}\nDNS %{time_namelookup}s | TCP %{time_connect}s | TLS %{time_appconnect}s\nFirst byte %{time_starttransfer}s | Total %{time_total}s\n' \ + --url "$1" +} + +# Foreground SSH tunnel, bound only to local loopback. Ctrl-C closes it. +# Use an SSH config host alias to retain configured identities/proxies/options. +# Remote host is resolved from the SSH server; bracket IPv6 addresses. +# Example: sshtunnel staging 15432 5432 127.0.0.1 +sshtunnel() { + if (( $# < 3 || $# > 4 )) || [[ -z "$1" || "$1" == -* || + "$2" != <1-65535> || "$3" != <1-65535> || ( $# == 4 && -z "$4" ) ]]; then + print -u2 'Usage: sshtunnel SSH_HOST LOCAL_PORT REMOTE_PORT [REMOTE_HOST, default 127.0.0.1]' + return 2 + fi + command ssh -N -T \ + -o ExitOnForwardFailure=yes \ + -o ServerAliveInterval=30 \ + -o ServerAliveCountMax=3 \ + -L "127.0.0.1:$2:${4:-127.0.0.1}:$3" "$1" +} + +# Connect to any private ~/.pg_service.conf profile, with optional psql flags. +# Profile names here allow letters, numbers, underscores, dots, and hyphens. +# Example: pgconnect staging -c '\conninfo' +pgconnect() { + if (( $# == 0 )) || [[ -z "$1" || "$1" == *[^[:alnum:]_.-]* ]]; then + print -u2 'Usage: pgconnect SERVICE [PSQL_ARGUMENTS...]'; return 2 + fi + local db_service="$1" + shift + command psql -X --dbname="service=$db_service" "$@" +} + +# List all custom commands with their descriptions and full definitions. +devhelp() { + _dev_commands_list "$@" +} diff --git a/config/zsh/zshrc b/config/zsh/zshrc index d61fbc9..40a26ba 100644 --- a/config/zsh/zshrc +++ b/config/zsh/zshrc @@ -50,6 +50,7 @@ fi source "$LINUX_SETUP_DIR/config/zsh/aliases.zsh" source "$LINUX_SETUP_DIR/config/zsh/functions.zsh" +source "$LINUX_SETUP_DIR/config/zsh/command-picker.zsh" typeset _dev_plugin_dir="$XDG_DATA_HOME/linux-setup/plugins" if [[ -r "$_dev_plugin_dir/zsh-autosuggestions/current/zsh-autosuggestions.zsh" ]]; then diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c81f2e2 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,33 @@ +# Documentation + +[Project overview](../README.md) · [Terminal previews](previews.md) + +Start with installation, then choose a guide for the task you want to do. + +| Guide | What you will find | +| --- | --- | +| [Installation and migration](installation.md) | Supported systems, prerequisites, first install, file locations, default shell, migration | +| [Terminal commands](terminal.md) | Keyboard shortcuts, Alt+X command picker, navigation, tmux, ports and local servers | +| [Developer workflow](developer-workflow.md) | Project checks, browser testing, language and container shortcuts, private commands | +| [Included tools](tools.md) | Installed tools, Vite+, uv, version pinning, optional previews | +| [Themes and customization](customization.md) | Theme switching, shared configuration, private device settings, repository layout | +| [PostgreSQL manager](databases.md) | Connection discovery, database/schema actions, confirmations, recovery | +| [Uninstall and recovery](uninstall.md) | Selective removal, retained programs, caches, backups and login-shell protection | +| [Troubleshooting](troubleshooting.md) | Downloads, missing libraries, completion, and known platform issues | +| [Development and maintenance](development.md) | Tests, isolated install lifecycle checks, lockfile updates, publishing | +| [Preview gallery](previews.md) | Theme and picker images, sample data, how to regenerate the assets | + +## Where to make a change + +| Change | Location | +| --- | --- | +| Shared aliases | [`config/zsh/aliases.zsh`](../config/zsh/aliases.zsh) | +| Shell functions | [`config/zsh/functions.zsh`](../config/zsh/functions.zsh) | +| Alt+X picker | [`config/zsh/command-picker.zsh`](../config/zsh/command-picker.zsh), [`scripts/command_catalog.py`](../scripts/command_catalog.py) | +| Prompt themes | [`config/oh-my-posh/`](../config/oh-my-posh/) | +| Portable commands | [`bin/`](../bin/) and [`scripts/`](../scripts/) | +| Tool versions and checksums | [`tools.lock.json`](../tools.lock.json) | +| Private settings | `~/.config/linux-setup-local/zshrc` and `zshenv` on your device | + +Keep credentials, machine-specific paths, and real project environment files out +of the checkout. Follow [development guidance](development.md) when contributing. diff --git a/docs/assets/command-picker.png b/docs/assets/command-picker.png new file mode 100644 index 0000000..40532a3 Binary files /dev/null and b/docs/assets/command-picker.png differ diff --git a/docs/assets/command-picker.svg b/docs/assets/command-picker.svg new file mode 100644 index 0000000..e8b1634 --- /dev/null +++ b/docs/assets/command-picker.svg @@ -0,0 +1,1367 @@ + + + + +linux-setup / Alt+X command picker / search: docker + +╭ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +╮ +│ +C +o +m +m +a +n +d +s +> +d +o +c +k +e +r +│ +│ +4 +2 +/ +1 +7 +1 +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +│ +│ +N +a +m +e +| +k +i +n +d +| +d +e +s +c +r +i +p +t +i +o +n +| +c +o +m +m +a +n +d +— +E +n +t +e +r +: +i +n +s +e +r +t +o +n +l +y +; +E +s +c +: +c +a +n +c +e +l +; +C +t +r +l +- +/ +: +p +r +e +v +i +e +w +│ +│ +▌ + +d + +d + +f + + + + + + +a + +l + +i + +a + +s + + + + +S + +h + +o + +w + + +D + +o + +c + +k + +e + +r + + +d + +i + +s + +k + + +u + +s + +a + +g + +e + +. + + +d + +o + +c + +k + +e + +r + + +s + +y + +s + +t + +e + +m + + +d + +f +│ +│ +│ +▌ +d +p +s +a +l +i +a +s +L +i +s +t +r +u +n +n +i +n +g +D +o +c +k +e +r +c +o +n +t +a +i +n +e +r +s +. +d +o +c +k +e +r +p +s +│ +│ +│ +▌ +d +p +s +a +a +l +i +a +s +L +i +s +t +a +l +l +D +o +c +k +e +r +c +o +n +t +a +i +n +e +r +s +. +d +o +c +k +e +r +p +s +- +- +a +l +l +│ +│ +▌ +d +c +p +s +a +l +i +a +s +S +h +o +w +C +o +m +p +o +s +e +s +e +r +v +i +c +e +s +t +a +t +u +s +. +d +o +c +k +e +r +c +o +m +p +o +s +e +p +s +│ +│ +▌ +d +c +t +x +s +a +l +i +a +s +L +i +s +t +a +v +a +i +l +a +b +l +e +D +o +c +k +e +r +c +o +n +t +e +x +t +s +. +d +o +c +k +e +r +c +o +n +t +e +x +t +l +s +│ +│ +▌ +d +c +t +x +a +l +i +a +s +S +h +o +w +t +h +e +a +c +t +i +v +e +D +o +c +k +e +r +c +o +n +t +e +x +t +. +d +o +c +k +e +r +c +o +n +t +e +x +t +s +h +o +w +│ +│ +▌ +d +u +s +e +a +l +i +a +s +C +h +a +n +g +e +t +h +e +a +c +t +i +v +e +D +o +c +k +e +r +c +o +n +t +e +x +t +. +d +o +c +k +e +r +c +o +n +t +e +x +t +u +s +e +│ +│ +▌ +d +c +b +u +i +l +d +a +l +i +a +s +B +u +i +l +d +C +o +m +p +o +s +e +s +e +r +v +i +c +e +i +m +a +g +e +s +. +d +o +c +k +e +r +c +o +m +p +o +s +e +b +u +i +l +d +│ +│ +▌ +d +c +r +e +s +t +a +r +t +a +l +i +a +s +R +e +s +t +a +r +t +C +o +m +p +o +s +e +s +e +r +v +i +c +e +s +. +d +o +c +k +e +r +c +o +m +p +o +s +e +r +e +s +t +a +r +t +│ +│ +▌ +d +s +t +a +t +s +a +l +i +a +s +S +h +o +w +l +i +v +e +D +o +c +k +e +r +c +o +n +t +a +i +n +e +r +r +e +s +o +u +r +c +e +u +s +a +g +e +. +d +o +c +k +e +r +s +t +a +t +s +│ +│ +╭ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +╮ +│ +│ +│ +d +d +f +( +a +l +i +a +s +) +│ +│ +│ +│ +S +h +o +w +D +o +c +k +e +r +d +i +s +k +u +s +a +g +e +. +│ +│ +│ +│ +│ +│ +│ +│ +I +n +s +e +r +t +s +: +d +o +c +k +e +r +s +y +s +t +e +m +d +f +│ +│ +│ +│ +│ +│ +│ +│ +F +u +l +l +c +o +m +m +a +n +d +: +│ +│ +│ +│ +d +o +c +k +e +r +s +y +s +t +e +m +d +f +│ +│ +│ +│ +│ +│ +│ +│ +│ +│ +│ +│ +│ +│ +│ +╰ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +╯ +│ +╰ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +─ +╯ + diff --git a/docs/assets/iterm2.png b/docs/assets/iterm2.png new file mode 100644 index 0000000..ab67fb2 Binary files /dev/null and b/docs/assets/iterm2.png differ diff --git a/docs/assets/iterm2.svg b/docs/assets/iterm2.svg new file mode 100644 index 0000000..faed790 --- /dev/null +++ b/docs/assets/iterm2.svg @@ -0,0 +1,531 @@ + + + + +linux-setup / iterm2 / Zsh + + + + + + + + + +d + +e + +v + + + + + + + + +~ + +/ + +l + +i + +n + +u + +x + +- + +s + +e + +t + +u + +p + + + + + + + + +m + +a + +i + +n + + + + + + + + + + + + + + +1 + +0 + +: + +2 + +4 + +: + +0 + +0 + + + +🚀 +d +e +v +h +e +l +p +' +p +n +p +m +r +u +n +' +p +b +a +l +i +a +s +R +u +n +t +h +e +p +r +o +j +e +c +t +b +u +i +l +d +s +c +r +i +p +t +. +p +n +p +m +r +u +n +b +u +i +l +d +p +c +h +e +c +k +a +l +i +a +s +R +u +n +t +h +e +f +u +l +l +p +r +o +j +e +c +t +c +h +e +c +k +s +c +r +i +p +t +. +p +n +p +m +r +u +n +c +h +e +c +k +p +d +a +l +i +a +s +R +u +n +t +h +e +p +r +o +j +e +c +t +d +e +v +s +c +r +i +p +t +. +p +n +p +m +r +u +n +d +e +v +p +f +m +t +a +l +i +a +s +R +u +n +t +h +e +p +r +o +j +e +c +t +f +o +r +m +a +t +s +c +r +i +p +t +. +p +n +p +m +r +u +n +f +o +r +m +a +t +p +r +a +l +i +a +s +R +u +n +a +n +a +m +e +d +p +a +c +k +a +g +e +. +j +s +o +n +s +c +r +i +p +t +. +p +n +p +m +r +u +n +p +s +t +a +r +t +a +l +i +a +s +R +u +n +t +h +e +p +r +o +j +e +c +t +s +t +a +r +t +s +c +r +i +p +t +. +p +n +p +m +r +u +n +s +t +a +r +t +p +t +a +l +i +a +s +R +u +n +t +h +e +f +u +l +l +p +r +o +j +e +c +t +t +e +s +t +s +c +r +i +p +t +. +p +n +p +m +r +u +n +t +e +s +t + + + + + + + + +d + +e + +v + + + + + + + + +~ + +/ + +l + +i + +n + +u + +x + +- + +s + +e + +t + +u + +p + + + + + + + + +m + +a + +i + +n + + + + + + + + + + + + + + +1 + +0 + +: + +2 + +4 + +: + +0 + +0 + + + +🚀 + diff --git a/docs/assets/prompt.data.json b/docs/assets/prompt.data.json new file mode 100644 index 0000000..175f8c5 --- /dev/null +++ b/docs/assets/prompt.data.json @@ -0,0 +1,98 @@ +{ + "version": 1, + "env": { + "OS": "ubuntu", + "UserName": "dev", + "HostName": "workstation", + "PWD": "~/linux-setup", + "AbsolutePWD": "/home/dev/linux-setup", + "PSWD": "~/linux-setup", + "Folder": "linux-setup", + "Root": false, + "WSL": false, + "Shell": "zsh", + "Code": 0 + }, + "segments": { + "os": { + "enabled": true, + "data": { + "Icon": { + "$markup": "" + } + } + }, + "session": { + "enabled": true, + "data": { + "SSHSession": false, + "UserName": "dev" + } + }, + "path": { + "enabled": true, + "data": { + "Path": "~/linux-setup", + "Location": "~/linux-setup", + "Writable": true + } + }, + "git": { + "enabled": true, + "data": { + "HEAD": { + "$markup": " main" + }, + "Ahead": 0, + "Behind": 0, + "BranchStatus": { + "$markup": "" + }, + "StashCount": 0, + "Working": { + "Changed": false + }, + "Staging": { + "Changed": false + } + } + }, + "executiontime": { + "enabled": true, + "data": { + "FormattedMs": "0ms", + "Ms": 0 + } + }, + "time": { + "enabled": true, + "data": { + "CurrentDate": "2026-01-01T10:24:00+03:00", + "Format": "15:04:05" + } + }, + "status": { + "enabled": true, + "data": { + "Error": false, + "Meaning": "0" + } + }, + "text": { + "enabled": true, + "data": {} + }, + "root": { + "enabled": false, + "data": {} + }, + "battery": { + "enabled": false, + "data": {} + }, + "python": { + "enabled": false, + "data": {} + } + } +} diff --git a/docs/assets/quick-term.png b/docs/assets/quick-term.png new file mode 100644 index 0000000..d472af1 Binary files /dev/null and b/docs/assets/quick-term.png differ diff --git a/docs/assets/quick-term.svg b/docs/assets/quick-term.svg new file mode 100644 index 0000000..290436a --- /dev/null +++ b/docs/assets/quick-term.svg @@ -0,0 +1,549 @@ + + + + +linux-setup / quick-term / Zsh + +╭ +─ + + + + + + + + + +d + +e + +v + + + + + +~ + +/ + +l + +i + +n + +u + +x + +- + +s + +e + +t + +u + +p + + + + + + + + + +m + +a + +i + +n + + + + + + + + +0 + +m + +s + + + + + + + + +1 + +0 + +: + +2 + +4 + +: + +0 + +0 + + +╰ +─ +$ +d +e +v +h +e +l +p +' +p +n +p +m +r +u +n +' +p +b +a +l +i +a +s +R +u +n +t +h +e +p +r +o +j +e +c +t +b +u +i +l +d +s +c +r +i +p +t +. +p +n +p +m +r +u +n +b +u +i +l +d +p +c +h +e +c +k +a +l +i +a +s +R +u +n +t +h +e +f +u +l +l +p +r +o +j +e +c +t +c +h +e +c +k +s +c +r +i +p +t +. +p +n +p +m +r +u +n +c +h +e +c +k +p +d +a +l +i +a +s +R +u +n +t +h +e +p +r +o +j +e +c +t +d +e +v +s +c +r +i +p +t +. +p +n +p +m +r +u +n +d +e +v +p +f +m +t +a +l +i +a +s +R +u +n +t +h +e +p +r +o +j +e +c +t +f +o +r +m +a +t +s +c +r +i +p +t +. +p +n +p +m +r +u +n +f +o +r +m +a +t +p +r +a +l +i +a +s +R +u +n +a +n +a +m +e +d +p +a +c +k +a +g +e +. +j +s +o +n +s +c +r +i +p +t +. +p +n +p +m +r +u +n +p +s +t +a +r +t +a +l +i +a +s +R +u +n +t +h +e +p +r +o +j +e +c +t +s +t +a +r +t +s +c +r +i +p +t +. +p +n +p +m +r +u +n +s +t +a +r +t +p +t +a +l +i +a +s +R +u +n +t +h +e +f +u +l +l +p +r +o +j +e +c +t +t +e +s +t +s +c +r +i +p +t +. +p +n +p +m +r +u +n +t +e +s +t +╭ +─ + + + + + + + + + +d + +e + +v + + + + + +~ + +/ + +l + +i + +n + +u + +x + +- + +s + +e + +t + +u + +p + + + + + + + + + +m + +a + +i + +n + + + + + + + + +0 + +m + +s + + + + + + + + +1 + +0 + +: + +2 + +4 + +: + +0 + +0 + + +╰ +─ +$ + diff --git a/docs/customization.md b/docs/customization.md new file mode 100644 index 0000000..1ea1bf5 --- /dev/null +++ b/docs/customization.md @@ -0,0 +1,81 @@ +# Customize your terminal setup + +[Documentation](README.md) · [Project overview](../README.md) + +## Switch the prompt theme + +The default is `quick-term`. Both bundled themes are Oh My Posh configurations +for Zsh; the `iterm2` name does not require the iTerm2 application. + +| Theme | Appearance | Configuration | +| --- | --- | --- | +| Quick-term | Orange accents, two-line prompt, duration and clock, compact command history | [`quick-term.omp.json`](../config/oh-my-posh/quick-term.omp.json) | +| iTerm2 | Blue and lime segments, exit-status color, rocket prompt, contextual segments | [`iterm2.omp.json`](../config/oh-my-posh/iterm2.omp.json) | + +To try the alternative in your current Zsh session: + +```zsh +eval "$(oh-my-posh init zsh --config "$LINUX_SETUP_DIR/config/oh-my-posh/iterm2.omp.json")" +``` + +To make it the shared default, change the `--config` path in +[`config/zsh/zshrc`](../config/zsh/zshrc) from `quick-term.omp.json` to +`iterm2.omp.json`, then run `reload`. To keep the choice private to one device, +put the initialization command above in `~/.config/linux-setup-local/zshrc`, +which loads after the shared prompt configuration. Remove that override to use +the shared default again. + +Use a Nerd Font in your terminal emulator. Font, terminal colors, Git state, +screen width, and available hardware affect the appearance. Battery and Python +environment segments in the alternative theme appear when their context is +available. See the [preview gallery](previews.md) for both themes and the command +picker, and [font setup](installation.md#optional-terminal-features) for details. + +## Repository layout + +```text +linux-setup/ +├── install.sh # Bootstrap entry point +├── uninstall.sh # Configuration, program, and cache removal +├── tools.lock.json # Pinned release assets and plugin commits +├── bin/ # Your portable executable scripts +├── config/ +│ ├── zsh/ # Shell settings, aliases, functions +│ ├── oh-my-posh/ # quick-term and iterm2 prompt themes +│ ├── tmux/ # Pane, session, key, and status settings +│ ├── git/ # Included delta settings +│ ├── bat/ # File viewer settings +│ ├── ripgrep/ # Search settings +│ ├── yazi/ # File manager settings +│ ├── lazygit/ # Git interface settings +│ └── codex/ # Reference Oh My Posh MCP entry +├── scripts/ # Installer, uninstaller, helpers, lock updater +├── docs/ # Installation, recovery, troubleshooting, development +└── tests/ # Installer, uninstall, helper, and isolated DB tests +``` + +Add shared aliases to `config/zsh/aliases.zsh`, functions to +`config/zsh/functions.zsh`, and executable scripts to `bin/`. Rerun the installer +to link new scripts. Edit the active theme in +`config/oh-my-posh/quick-term.omp.json`. Change its path in `config/zsh/zshrc` to +switch themes. Use `reload` after changing shell initialization or prompt settings. + +Private or device-specific additions go in: + +```text +~/.config/linux-setup-local/zshenv # Environment needed by all Zsh processes +~/.config/linux-setup-local/zshrc # Interactive additions, editor choice, local aliases +``` + +For example, put `export EDITOR='nvim'` in the local zshrc if Neovim is installed. +The initial migration preserves the existing Vite+ environment hook in the local +zshenv. Other old files are backed up for reference. Your Git identity, credentials, +SSH keys, shell history, project files, downloaded binaries, and private Codex +settings are not copied into the repository. + +XDG config/data/cache/state directories are respected. Executables always go in +`~/.local/bin`. Rootless base-package binaries and their runtime paths are +generated separately on each device. + +`LINUX_SETUP_DIR` points to the active checkout through its configuration link. +`DEV_SETUP_DIR` remains a compatibility alias for existing personal scripts. diff --git a/docs/databases.md b/docs/databases.md new file mode 100644 index 0000000..e596e25 --- /dev/null +++ b/docs/databases.md @@ -0,0 +1,81 @@ +# PostgreSQL database and schema manager + +[Documentation](README.md) · [Project overview](../README.md) + +From a project's directory: + +```sh +db # Read .env in the current directory +db .env.local # Read a different file +db -f ./config/development.env +db --var DATABASE_URL # Only use this variable +db --var DB_URL --var TEST_DB_URL +db --connections # Show redacted targets without connecting +db --maintenance-db app_admin # If the role cannot connect to postgres +``` + +The installer supplies `psql`; this does **not** install or start a PostgreSQL +server. It uses the database role and permissions in the selected connection. +The server's `postgres` database is used for database-level operations by default. +Your role needs CONNECT there and the appropriate CREATE/ownership privileges. + +Recognized variables include `DB_URL`, `DATABASE_URL`, `TEST_DB_URL`, +`TEST_DATABASE_URL`, `DB_URLS`, and `DATABASE_URLS`. Use `--var NAME` for any other +name. Multiple connections can be a JSON array or a named JSON object on a single line: + +```dotenv +DATABASE_URL=postgresql://app:password@localhost:5432/my_project +DB_URLS='{"local":"postgresql://localhost/my_project","test":"postgresql://localhost/my_project_test"}' +# Or: DB_URLS='["postgresql://localhost/my_project","postgresql://localhost/my_project_test"]' +``` + +An example lives in [`examples/postgres.env.example`](../examples/postgres.env.example). URL-encode special characters +in passwords. The parser supports `export`, quotes, inline comments, and `${VAR}` +references in double-quoted/unquoted values. It does not execute shell code. +Single quotes keep values literal. Credentials stay outside this repository; +passwords are supplied through a temporary mode-0600 password file and removed +after each `psql` call. Connection menus show names and endpoints without passwords. +Existing `.pgpass` authentication works when the URL contains no password. + +Menus: + +1. Select a connection, then **Databases** or **Schemas**. +2. For schemas, select the database to manage. +3. Choose **List**, **Create**, **Remove**, or **Reset**. +4. Use arrows to move, Space to select multiple targets, and Enter to accept. + Escape returns to the previous menu. Ctrl-A selects all and Ctrl-D clears a + batch selection. Single-choice menus accept Space or Enter. + +Remove/reset shows the target server and object names and requires an exact typed +phrase such as `RESET app_test`. There are no table-management actions. Removing +or resetting a database/schema deletes its contained objects and data; schema +CASCADE can also remove dependent objects in other schemas. + +- Database reset drops and recreates it from `template0`, preserving its owner, + encoding/locale, tablespace, connection limit, access grants, and comment. Its + custom schemas, data, installed extensions, and per-database settings are not + restored. Active connections are **not** forcibly terminated. +- Schema reset drops and recreates the schema in one transaction, preserving its + owner, access grants, and comment. A failed schema reset rolls back. +- Database reset cannot be atomic. If recreation fails after a successful drop, + a private recreation script is kept under + `~/.local/state/linux-setup/db-recovery/`; inspect the server and script before + recovering. Other successful targets in a batch are not rolled back. +- `postgres`, template databases, the selected maintenance database, and system + schemas are excluded from destructive selection. `public` is a user schema and + can be explicitly reset or removed after confirmation. + +SSL options such as `sslmode=require` are respected. Common ORM-only fields +(`schema`, `connection_limit`, `pool_timeout`, `pgbouncer`, `socket_timeout`, and +`statement_cache_size`) are ignored. Other unsupported URL options produce an +error rather than being silently discarded. Use a direct database endpoint when +a transaction-pooling proxy disallows database-level DDL. + +Tests never read your project `.env` or connect to its databases. To additionally +run the integration tests, point this command at installed PostgreSQL **server** +binaries; the test creates, starts, and removes its own private cluster: + +```sh +LINUX_SETUP_TEST_POSTGRES_BIN=/path/to/postgresql/bin \ + python3 -m unittest discover -s tests -v +``` diff --git a/docs/developer-workflow.md b/docs/developer-workflow.md index 49ae9fb..9f52885 100644 --- a/docs/developer-workflow.md +++ b/docs/developer-workflow.md @@ -1,5 +1,7 @@ # Developer workflow +[Documentation](README.md) · [Project overview](../README.md) + Run project commands from the relevant checkout. This setup supplies shared tools; each project keeps its own runtime version, package manager, dependencies, tasks, and credentials. @@ -129,15 +131,65 @@ the interactive manager also offers database changes with confirmation. Keep test URLs pointed at disposable databases. Installing `psql` does not start a database server. -## Optional personal shortcuts +## Discover and use custom commands -Add only the shortcuts you want to your private -`~/.config/linux-setup-local/zshrc`, then open a new shell: +Press **Alt+X** (both lowercase and uppercase x work) or run `devcommands` to open +the fzf picker. Type part of a shortcut, its description, or its full command. +The preview includes the full definition, even for multiline functions. Ctrl-/ +toggles the preview. Enter inserts the selection into the editable prompt and +does not execute it. An alias inserts its expansion; a function inserts its name +and a trailing space so you can supply arguments. Selecting replaces the current +line; Escape or Ctrl-C preserves the line and cursor. The binding works in Emacs +and vi keymaps. If your terminal uses Alt+X for a menu, use Escape then x or the +`devcommands` command. ```sh -alias dcheck='dev-check' -alias dbr='dev-browser' -alias vr='vp run' +devcommands docker # Open with an initial search +devhelp # Print the current reference without fzf +devhelp pnpm # Filter the printed reference by text +``` + +The picker uses live shell definitions and description comments from +`config/zsh/aliases.zsh`, `functions.zsh`, `command-picker.zsh`, and private +`linux-setup-local/zshrc` / `zshenv`. It excludes internal helper functions and +shortcuts that were not loaded, including conditional aliases for absent tools. +It does not call any of the commands to build the list. Regular aliases and named +functions declared in these files are supported; dynamically generated commands +and commands in other sourced files are outside this catalogue. + +Included shortcuts: + +| Group | Examples | +| --- | --- | +| Project scripts and dependencies | `pscripts`, `pd`, `pb`, `pt`, `pcheck`, `pr TASK`, `pi`, `pif`, `pa`, `pad`, `px`, `pfilter` | +| Vite+ | `vpi`, `vr TASK`, `vcheck`, `vfmt`, `vtest`, `vwatch` | +| Rust and Laravel | `cgb`, `cgc`, `cgt`, `cgr`, `cgl`, `cgrelease`, `art`, `arts`, `artt`, `sail` | +| Git and GitHub | `gfind TEXT`, `gbranches`, `gconflicts`, `gpull`, `ghprs`, `ghpr`, `ghchecks`, `ghruns` | +| Containers | `dctx`, `dctxs`, `duse`, `dpsa`, `dccheck`, `dcservices`, `dcup`, `dcstop`, `dclog`, `dsh`, `dcsh` | +| Kubernetes and Terraform | `kctx`, `kcontexts`, `kuse`, `kpods`, `klogs`, `kdesc`, `tfinit`, `tfvalidate`, `tffmtcheck`, `tfplan` | +| Host and network | `disks`, `mem`, `dus`, `listens`, `ipbrief`, `svcfailed`, `jerrors`, `junit`, `httpcheck`, `sshtunnel` | +| PostgreSQL and JSON | `pgconnect SERVICE`, `pg-local`, `pg-remote`, `pg-bingo`, `jpretty`, `jcompact` | +| AI agents and project checks | `cx`, `cxr`, `cxc`, `cxe`, `cld`, `cldr`, `cldc`, `dcheck`, `dbr` | + +Project script names must exist in `package.json`; use `pscripts` to inspect them. +Direct Vite+ tools can bypass a project's wrappers, so use `pcheck` and `pt` for +its full check/test scripts. Container commands use the active Docker context; +`dsh CONTAINER [SHELL]` and `dcsh SERVICE [SHELL]` default to `sh`. Existing Docker +reset shortcuts and `dc` help remain available, including their volume-deleting +operations. PostgreSQL shortcuts use profiles you configure privately in +`~/.pg_service.conf`, with normal password prompt or `~/.pgpass` authentication. +Kubernetes, Terraform, GitHub, Docker, language tools, and agent CLIs must be +installed separately if missing; adding an alias does not install its target. + +For private additions, put a comment immediately above each alias or function +in `~/.config/linux-setup-local/zshrc`, then run `reload`: + +```sh +# Follow this project's API logs. +alias apilogs='docker compose logs --follow api' + +# Check the current project's package scripts. +project_scripts() { command jq '.scripts // {}' package.json; } ``` Keep credentials in the appropriate project's ignored files or configured diff --git a/docs/development.md b/docs/development.md index 28e30cd..32ffc3a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,5 +1,7 @@ # Development and maintenance +[Documentation](README.md) · [Project overview](../README.md) + Portable configuration lives in `config/`, executable helpers in `bin/`, and their implementation in `scripts/`. Machine-specific additions belong outside the checkout in `~/.config/linux-setup-local/`. diff --git a/docs/installation.md b/docs/installation.md index 85658ea..84989a1 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,5 +1,7 @@ # Installation and migration +[Documentation](README.md) · [Project overview](../README.md) + `linux-setup` installs a repeatable Linux terminal configuration and a pinned set of command-line tools. It links configuration to this checkout, so keep the checkout in a persistent location. @@ -31,6 +33,7 @@ cd ~/linux-setup ./install.sh --preflight ./install.sh --dry-run ./install.sh +export PATH="$HOME/.local/bin:$PATH" exec zsh linux-doctor ``` @@ -66,6 +69,14 @@ The [README tool table](../README.md#included-tools) lists the included programs Already available base programs are reused. Existing Vite+ and uv installations are preserved. Fresh Vite+/uv installations use the lockfile versions. +Zsh is installed automatically when it is missing during a normal `./install.sh` +run on Ubuntu/Debian. Its packages and runtime files stay in your user directory, +with an executable entry point at `~/.local/bin/zsh`; no sudo or login-shell change +is needed. An existing Zsh is reused. `--config-only` deliberately skips all tool +installation, including Zsh. At completion, the installer prints an `exec` command +with the full Zsh path so it also works before your current shell has added +`~/.local/bin` to `PATH`. + The PostgreSQL dependency is the `psql` client only. Installing it does not create a database cluster or start a server. Your applications retain responsibility for their Node/Python versions and dependency lockfiles. diff --git a/docs/previews.md b/docs/previews.md new file mode 100644 index 0000000..5471d27 --- /dev/null +++ b/docs/previews.md @@ -0,0 +1,69 @@ +# Terminal preview gallery + +[Documentation](README.md) · [Project overview](../README.md) · [Switch themes](customization.md#switch-the-prompt-theme) + +## Quick-term + +The default theme uses orange accents, a two-line prompt, directory and Git +context, command duration, and a clock. Completed commands use a compact +transient prompt in an interactive shell. + +![Quick-term prompt with a pnpm script command reference](assets/quick-term.png) + +[PNG](assets/quick-term.png) · [SVG](assets/quick-term.svg) · [Theme source](../config/oh-my-posh/quick-term.omp.json) + +## iTerm2 + +The alternative uses blue and lime segments, a status indicator, and a rocket +prompt. Battery and Python environment information appear when available. + +![Blue and lime iTerm2 prompt with the same pnpm script command reference](assets/iterm2.png) + +[PNG](assets/iterm2.png) · [SVG](assets/iterm2.svg) · [Theme source](../config/oh-my-posh/iterm2.omp.json) + +## Alt+X command picker + +Search by shortcut, description, or full command. The preview contains the whole +definition. Enter inserts the selection for editing; it does not execute it. + +![Actual fzf terminal capture listing Docker commands and previewing a full definition](assets/command-picker.png) + +[PNG](assets/command-picker.png) · [SVG](assets/command-picker.svg) · [Usage](terminal.md) + +## How these images are made + +The theme previews render the repository's actual Oh My Posh configurations with +explicit [sample prompt data](assets/prompt.data.json): user `dev`, directory +`~/linux-setup`, branch `main`, and a fixed example clock. Their command-reference +output comes from running `devhelp 'pnpm run'` against the current source. They +are rendered terminal examples, not screenshots of a personal workstation. + +The picker image captures fzf in an isolated Zsh terminal, then renders its +visible character cells and colors. Capture is cancelled before selection. +The window header and dark background belong to the image renderer, not to an +installed terminal application or desktop theme. + +The PNGs include the rendered font and work without local font installation. +The SVGs keep editable text and may look different without the matching font. +Both formats are kept here for reuse in documentation or a future project +landing page. Retain the upstream theme attribution in [NOTICE.md](../NOTICE.md). + +## Regenerate + +Install this repository's tools, the `MesloLGM Nerd Font Mono` font, and +[Noto Emoji](https://github.com/google/fonts/tree/main/ofl/notoemoji) for the +alternative theme's rocket glyph first. +Then, from the repository root: + +```sh +uv run --with cairosvg==2.9.1 --with pyte==0.8.2 scripts/render-previews.py +``` + +The renderer needs Linux PTY support, a system Cairo library, Zsh, fzf, and +Oh My Posh with `print --data` support. The checked-in images were rendered with +Oh My Posh 31.9.1. The Python packages are documentation-only dependencies; +normal installation does not install them. + +The script writes three PNG/SVG pairs in `docs/assets/`. Inspect them after +changing a theme, command description, fzf layout, font, or rendering dependency. +It uses a temporary shell configuration and excludes private local commands. diff --git a/docs/terminal.md b/docs/terminal.md new file mode 100644 index 0000000..da32f29 --- /dev/null +++ b/docs/terminal.md @@ -0,0 +1,79 @@ +# Terminal commands and keyboard shortcuts + +[Documentation](README.md) · [Project overview](../README.md) + +| Command / key | Action | +| --- | --- | +| `ll`, `la`, `lt` | Detailed listing, all files, shallow tree | +| `ccat FILE` | View a file with bat | +| `z NAME`, `zi` | Jump by directory history / pick interactively | +| `y` | Open Yazi and follow its final directory | +| `lg` | Open lazygit | +| `fe` | Choose files with fzf and open them in `$EDITOR` | +| `cdf` | Choose and enter a directory | +| `search TEXT` | Search project contents with ripgrep | +| `mkcd DIR` | Create a directory and enter it | +| `up [N]` | Move up one or N directory levels | +| `..`, `...`, `....`, `.....` | Move up one, two, three, or four levels | +| `-`, `home`, `croot` | Previous directory, home, Git repository root | +| `work [DIR]` | Create or enter a tmux session for a project | +| `lip [PORT ...]` / `ports` | List TCP listeners, PIDs, and command names | +| `kip PORT [PORT ...]` | Confirm and send SIGTERM to the listeners on explicit ports | +| `serve [PORT] [DIR]` | Serve a directory on localhost (default port 8000) | +| `db [ENV_FILE]` / `dbm` | Interactive PostgreSQL database/schema manager | +| `dev-check [PATH]` | Read-only project dependency, Git, runtime, and Docker checks | +| `dev-browser doctor` | Check a project's Playwright/browser setup | +| `pathlist` | Print one PATH entry per line | +| `linux-doctor` / `dev-doctor` | Check installed tools and shell integration | +| `linuxsetup` / `devsetup` | Enter this repository | +| `reload` | Restart Zsh after config changes | +| `gs`, `gd`, `gds`, `gl` | Git status, diff, staged diff, recent graph | +| `ga`, `gc`, `gsw`, `gfetch` | Git add, commit, switch, fetch | +| Ctrl-R | Search command history with fzf | +| Ctrl-T | Select files with fzf and bat previews | +| Alt-C | Choose a directory with fzf | +| Alt-X / `devcommands [QUERY]` | Search custom aliases/functions and insert a selection for editing | +| `devhelp [TEXT]` | List custom commands, descriptions, and full definitions | +| Right arrow | Accept an autosuggestion | + +Standard commands such as `ls`, `cat`, `cd`, and `rm` keep their normal meanings. +Dot-prefixed commands are navigation shortcuts: `..`, `...`, and `....`. + +Press **Alt+X** in Zsh to open the command picker. Search by shortcut, description, +or any part of the full command; the preview shows the entire alias expansion or +function definition. Enter replaces the current command line with the selection +without executing it. Aliases insert their expanded command; functions insert +their name followed by a space for arguments. Edit it, then press Enter yourself +when ready. Escape or Ctrl-C leaves your original command line unchanged. +`devcommands` opens the same picker from a typed command; `devhelp` prints the list. +If your terminal intercepts Alt+X, press Escape followed by x, or use `devcommands`. + +The catalogue reads the active definitions of shortcuts declared in this repo's +Zsh files and your private `linux-setup-local/zshrc` and `zshenv`. Add a description +comment directly above each `alias name='command'` or `name() { ... }`, then +`reload`; new commands appear automatically. Optional tool shortcuts still need +their corresponding tools installed. See the [workflow guide](developer-workflow.md) +for the included command groups and examples. + +tmux keeps **Ctrl-B** as the prefix. After the prefix, press `|` or `-` to split, +`h/j/k/l` to switch panes, `c` for a new window, `r` to reload config, and `d` to +detach. `work` names sessions by project directory plus a short path hash, so two +projects with the same directory name do not collide. Existing sessions are kept. + +## Ports and local servers + +```sh +lip # All visible TCP listeners +lip 3000 8000 # Only these ports +kip 3000 # Show the matching processes and ask before stopping +kip --force 3000 # Explicit SIGKILL for a process that will not stop +dev-ports kill 3000 --yes # Explicit noninteractive confirmation +dev-ports list --json # Structured listener information +serve 8000 ./public # Bind only to 127.0.0.1; Ctrl-C stops the server +``` + +`kip` only signals visible PIDs still listening on the requested ports. It uses +SIGTERM by default, checks for changed listeners before signalling, and never +requests sudo. Processes owned by other users may require you to act as their +owner. A single process can listen on several ports; stopping it affects all of +its listeners. `lsof` is installed when needed; Linux can also inspect via `ss`. diff --git a/docs/tools.md b/docs/tools.md new file mode 100644 index 0000000..aa274ce --- /dev/null +++ b/docs/tools.md @@ -0,0 +1,72 @@ +# Included tools and runtime managers + +[Documentation](README.md) · [Project overview](../README.md) + +| Tool | Use | +| --- | --- | +| Zsh | Interactive shell, completion, persistent history | +| Oh My Posh | `quick-term` theme, spacing, transient prompts | +| fd / ripgrep (`rg`) | Find files / search their contents | +| fzf | Fuzzy selection and shell shortcuts | +| zoxide | Jump to frequently visited directories with `z` / `zi` | +| bat | File viewer with syntax highlighting | +| Yazi (`yazi`, `ya`) | File manager; use `y` to update your shell directory on exit | +| lazygit | Git interface, including delta diffs | +| tmux | Persistent sessions, windows, panes | +| eza | Detailed file listings and directory trees | +| delta | Git diffs with syntax highlighting and line numbers | +| jq | JSON querying and formatting | +| Vite+ (`vp`) | Manage Node.js runtimes, package managers, and the JavaScript toolchain | +| uv / uvx | Python versions, projects, packages, virtual environments, and tools | +| psql / lsof | PostgreSQL management client and TCP listener inspection | +| zsh-autosuggestions | Suggestions from history and completion | +| zsh-syntax-highlighting | Highlight valid commands and shell syntax | + +`tools.lock.json` pins upstream release versions, URLs, SHA-256 checksums, and +plugin commits. Downloads are verified before installation. Repeated installs +reuse verified downloads and installed versions. Base packages follow the target +OS's repositories. This reproduces the +configuration and pinned tools, rather than freezing the entire operating system. + +Vite+ and uv are **installed only when missing**. Existing installations and their +versions are preserved, including Vite+'s current and older installation locations and uv's older +Cargo location. The lockfile pins the versions used on fresh devices. A new Vite+ +installation retains its full release bundle and runs `vp env setup` to prepare +its bootstrap dependencies, official environment, and runtime/package-manager +shims. Zsh loads that environment automatically. uv and uvx shell +completion is enabled when available. Runtime versions and project dependencies +remain controlled by each project's own configuration and lockfiles. +The bundled Linux Vite+ installer targets glibc distributions such as Ubuntu and +Debian, so it downloads matching Node.js runtimes. musl-based distributions are +outside the supported targets for this release. + +Examples in a project directory: + +```sh +vp env install lts # Install a Node.js runtime through Vite+ +vp install # Install JavaScript project dependencies +vp run dev # Run a project task +uv init # Create a Python project +uv add requests # Add a Python dependency +uv run python # Run Python in the project's environment +uvx ruff check . # Run a Python tool without installing it globally +``` + +See the official [Vite+ guide](https://viteplus.dev/guide/) and +[uv installation guide](https://docs.astral.sh/uv/getting-started/installation/). +For existing manager installations, use their supported upgrade commands; +rerunning this installer deliberately keeps their current versions. + +Yazi's file search, text search, JSON previews, fuzzy navigation, and zoxide +integration have their companion tools installed. Extra preview formats are +optional: `ffmpeg` for video, Poppler for PDFs, and 7-Zip for archives. On Ubuntu: + +```sh +sudo apt install ffmpeg poppler-utils 7zip +``` + +Install a Nerd Font in the device that renders the terminal and select it in +your terminal's font settings. For a local desktop, one option is +`oh-my-posh font install meslo`. In WSL or SSH sessions, the font belongs on the +Windows/desktop client. Terminal font and color settings cannot be selected +reliably without knowing the terminal emulator. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 77b58ce..b03ce33 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,5 +1,7 @@ # Troubleshooting and development findings +[Documentation](README.md) · [Project overview](../README.md) + Start with `./install.sh --preflight` for installation prerequisites and `linux-doctor` for installed tools and shell integrations. Run checks as the user whose terminal is being configured. diff --git a/docs/uninstall.md b/docs/uninstall.md index 7abe045..da3bc39 100644 --- a/docs/uninstall.md +++ b/docs/uninstall.md @@ -1,5 +1,7 @@ # Uninstall programs, configuration, and caches +[Documentation](README.md) · [Project overview](../README.md) + Run the uninstaller from the checkout: ```sh diff --git a/scripts/command_catalog.py b/scripts/command_catalog.py new file mode 100644 index 0000000..a2e88f1 --- /dev/null +++ b/scripts/command_catalog.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Describe live Zsh commands without evaluating their definitions.""" +import argparse +import os +from pathlib import Path +import re +import shlex +import subprocess +import sys +import tempfile + + +ROOT = Path(__file__).resolve().parents[1] +ALIAS = re.compile(r"(?:^|\s|[;&])alias\s+(?:--\s+)?([^\s=]+)=") +FUNCTION = re.compile(r"^\s*(?:function\s+([^\s(){]+)(?:\s*\(\s*\))?|([^\s(){]+)\s*\(\s*\))\s*\{") + + +def descriptions(paths): + """Read adjacent comments, not shell values; Zsh supplies the actual values.""" + result = {} + for path in paths: + if not path.is_file(): + continue + comments = [] + for line in path.read_text().splitlines(): + stripped = line.strip() + if stripped.startswith("#"): + comments.append(stripped.lstrip("# ")) + continue + alias = ALIAS.search(line) + function = FUNCTION.match(line) + if alias or function: + name = alias[1] if alias else (function[1] or function[2]) + kind = "alias" if alias else "function" + result[(kind, name)] = " ".join(comments) or f"Custom {kind}." + # A conditional guard can sit between a description and its alias. + if not stripped.startswith("if "): + comments = [] + return result + + +def source_paths(): + config = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + return [ + ROOT / "config/zsh/aliases.zsh", + ROOT / "config/zsh/functions.zsh", + ROOT / "config/zsh/command-picker.zsh", + config / "linux-setup-local/zshenv", + config / "linux-setup-local/zshrc", + ] + + +def catalog(snapshot, metadata): + fields = snapshot.split("\0") + if fields[-1] == "": + fields.pop() + if len(fields) % 3: + raise ValueError("Incomplete shell command snapshot") + entries = {} + for kind, name, body in zip(fields[::3], fields[1::3], fields[2::3]): + if (kind, name) not in metadata or name.startswith("_"): + continue + # Aliases take precedence when both kinds have the same name. + if name in entries and entries[name]["kind"] == "alias": + continue + entries[name] = { + "name": name, + "kind": kind, + "description": metadata[(kind, name)], + "command": body if kind == "alias" else f"{name} () {{\n{body}\n}}", + "insert": body if kind == "alias" else shlex.quote(name) + " ", + } + return sorted(entries.values(), key=lambda entry: entry["name"].casefold()) + + +def single_line(value): + # Do not let control characters in a command alter fzf's rows or terminal. + return " ".join("".join(c if c.isprintable() else " " for c in value).split()) + + +def row(entry): + return "\t".join(single_line(entry[key]) for key in ("name", "kind", "description", "command")) + + +def pick(entries, query=""): + if not entries: + return None + # Full definitions are plain preview files. Only opaque numeric IDs enter + # the preview shell command; no alias/function is ever used as shell code. + with tempfile.TemporaryDirectory(prefix="linux-setup-commands-") as temporary: + directory = Path(temporary) + for index, entry in enumerate(entries): + (directory / str(index)).write_text( + f"{entry['name']} ({entry['kind']})\n{entry['description']}\n\n" + f"Inserts: {entry['insert']}\n\nFull command:\n{entry['command']}\n" + ) + result = subprocess.run( + [ + "fzf", "--height=80%", "--layout=reverse", "--border", "--no-multi", + "--delimiter=\t", "--with-nth=2..", "--prompt=Commands> ", + "--header=Name | kind | description | command — Enter: insert only; Esc: cancel; Ctrl-/: preview", + "--bind=ctrl-/:toggle-preview", "--preview-window=down,50%,wrap", + "--preview", "cat -- " + shlex.quote(str(directory)) + "/{1}", + "--query", query, + ], + input="".join(f"{index}\t{row(entry)}\n" for index, entry in enumerate(entries)), + text=True, + stdout=subprocess.PIPE, + # User fzf bindings (execute, become, accept on focus, etc.) must + # not change this picker's insert-only behavior. + env={**os.environ, "FZF_DEFAULT_OPTS": "", "FZF_DEFAULT_OPTS_FILE": "", "SHELL": "/bin/sh"}, + ) + if result.returncode in (1, 130): + return None + if result.returncode: + raise RuntimeError(f"fzf exited with status {result.returncode}") + identifier = result.stdout.partition("\t")[0] + if not identifier.isdecimal() or int(identifier) >= len(entries): + raise ValueError("Invalid command selection") + return entries[int(identifier)]["insert"] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--list", action="store_true") + mode.add_argument("--pick", action="store_true") + parser.add_argument("query", nargs="?", default="") + args = parser.parse_args() + try: + entries = catalog(sys.stdin.read(), descriptions(source_paths())) + if args.list: + for entry in entries: + if args.query.casefold() in row(entry).casefold(): + print(row(entry)) + return 0 + selected = pick(entries, args.query) + if selected is None: + return 1 + sys.stdout.write(selected) + return 0 + except (OSError, ValueError, RuntimeError) as error: + print(f"Command picker: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/render-previews.py b/scripts/render-previews.py new file mode 100644 index 0000000..38b277e --- /dev/null +++ b/scripts/render-previews.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Render documentation images from real prompts and an isolated fzf terminal. + +Run with: uv run --with cairosvg==2.9.1 --with pyte==0.8.2 scripts/render-previews.py +Requires zsh, fzf, oh-my-posh, MesloLGM Nerd Font Mono, and Noto Emoji. +""" +import fcntl +import html +import os +from pathlib import Path +import pty +import select +import shlex +import shutil +import signal +import struct +import subprocess +import tempfile +import termios +import time + +import cairosvg +import pyte + +ROOT = Path(__file__).resolve().parents[1] +ASSETS = ROOT / 'docs/assets' +COLUMNS = 112 +FONT = 'MesloLGM Nerd Font Mono' +PALETTE = { + 'black': '#111827', 'red': '#f87171', 'green': '#a3e635', 'brown': '#fbbf24', + 'blue': '#60a5fa', 'magenta': '#c084fc', 'cyan': '#22d3ee', 'white': '#e2e8f0', + 'brightblack': '#64748b', 'brightred': '#fca5a5', 'brightgreen': '#bef264', + 'brightbrown': '#fde68a', 'brightblue': '#93c5fd', 'brightmagenta': '#d8b4fe', + 'brightcyan': '#67e8f9', 'brightwhite': '#f8fafc', +} + + +def color(value, fallback): + if value == 'default': + return fallback + return PALETTE.get(value, '#' + value) + + +def render(screen, name, title): + """Draw terminal cells, preserving ANSI colors and the exact visible text.""" + cell, line, left, top = 9.64, 23, 24, 70 + width, height = COLUMNS * cell + left * 2, screen.lines * line + top + 18 + svg = [f'', + f'', + f'', + '', + f'{html.escape(title)}', + f''] + for y in range(screen.lines): + for x in range(screen.columns): + char = screen.buffer[y].get(x, screen.default_char) + fg, bg = color(char.fg, '#e2e8f0'), color(char.bg, '#0f172a') + if char.reverse: + fg, bg = bg, fg + px, py = left + x * cell, top + y * line + if bg != '#0f172a': + svg.append(f'') + if char.data.strip(): + style = ' font-weight="bold"' if char.bold else '' + if any(ord(c) >= 0x1F000 for c in char.data): + style += ' font-family="Noto Emoji"' + svg.append(f'{html.escape(char.data)}') + svg.append('') + output = ASSETS / (name + '.svg') + output.write_text('\n'.join(svg) + '\n') + cairosvg.svg2png(url=str(output), write_to=str(ASSETS / (name + '.png')), scale=2) + + +def shell_prelude(): + data = Path(os.environ.get('XDG_DATA_HOME', Path.home() / '.local/share')) / 'linux-setup' + modules = list((data / 'runtime/usr/lib').glob('*/zsh/*')) + modules += list((data / 'base/zsh/runtime/usr/lib').glob('*/zsh/*')) + return 'module_path=(' + ' '.join(shlex.quote(str(p)) for p in modules) + ' $module_path)\n' + '\n'.join( + 'source ' + shlex.quote(str(ROOT / 'config/zsh' / name)) + for name in ['aliases.zsh', 'functions.zsh', 'command-picker.zsh'] + ) + '\n' + + +def sample_environment(directory): + return {**os.environ, 'LINUX_SETUP_DIR': str(ROOT), 'ZDOTDIR': directory, + 'XDG_CONFIG_HOME': directory, 'TERM': 'xterm-256color', 'COLORTERM': 'truecolor', + 'FZF_DEFAULT_OPTS': '', 'FZF_DEFAULT_OPTS_FILE': ''} + + +def theme_previews(): + with tempfile.TemporaryDirectory(prefix='linux-setup-preview-') as directory: + listing = subprocess.check_output( + ['zsh', '-dfc', shell_prelude() + "devhelp 'pnpm run'"], + cwd=ROOT, env=sample_environment(directory), text=True, + ) + for name in ['quick-term', 'iterm2']: + prompt = subprocess.check_output([ + 'oh-my-posh', 'print', 'primary', '--config', str(ROOT / f'config/oh-my-posh/{name}.omp.json'), + '--data', str(ASSETS / 'prompt.data.json'), '--data-only', '--shell', 'bash', + '--escape=false', '--terminal-width', str(COLUMNS), + ], text=True) + screen = pyte.Screen(COLUMNS, 18) + stream = pyte.Stream(screen) + stream.feed(prompt.replace('\n', '\r\n')) + stream.feed("devhelp 'pnpm run'\r\n") + stream.feed(listing.replace('\n', '\r\n')) + stream.feed(prompt.replace('\n', '\r\n')) + last = max(y for y, row in enumerate(screen.display) if row.strip()) + cropped = pyte.Screen(COLUMNS, last + 2) + for y in range(cropped.lines): + cropped.buffer[y] = screen.buffer[y].copy() + render(cropped, name, f'linux-setup / {name} / Zsh') + + +def picker_preview(): + """Capture fzf itself; cancel before any command can be selected or run.""" + with tempfile.TemporaryDirectory(prefix='linux-setup-picker-preview-') as directory: + child, master = pty.fork() + if child == 0: + os.execve(shutil.which('zsh'), ['zsh', '-dfi'], sample_environment(directory)) + try: + fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', 34, COLUMNS, 0, 0)) + prelude = shell_prelude() + "PROMPT='preview> '\n_dev_commands_select docker\n" + os.write(master, prelude.encode()) + captured = b'' + deadline, last_output = time.monotonic() + 12, time.monotonic() + while time.monotonic() < deadline: + if select.select([master], [], [], 0.1)[0]: + captured += os.read(master, 65536) + last_output = time.monotonic() + if b'Full command:' in captured and time.monotonic() - last_output > 0.5: + break + else: + raise RuntimeError('fzf preview did not finish rendering') + screen = pyte.Screen(COLUMNS, 34) + pyte.Stream(screen).feed(captured.decode(errors='replace')) + rows = screen.display + # fzf --height keeps the old prompt above it. Crop to its real border. + first = next(i for i, row in enumerate(rows) if '╭─' in row) + last = max(i for i, row in enumerate(rows) if '╰─' in row) + cropped = pyte.Screen(COLUMNS, last - first + 1) + for y in range(cropped.lines): + cropped.buffer[y] = screen.buffer[first + y].copy() + render(cropped, 'command-picker', 'linux-setup / Alt+X command picker / search: docker') + os.write(master, b'\x1b') + finally: + os.kill(child, signal.SIGTERM) + os.close(master) + os.waitpid(child, 0) + + +if __name__ == '__main__': + ASSETS.mkdir(exist_ok=True) + theme_previews() + picker_preview() + print('Rendered quick-term, iterm2, and command-picker previews in docs/assets/') diff --git a/scripts/setup.py b/scripts/setup.py index 63cba37..b668287 100755 --- a/scripts/setup.py +++ b/scripts/setup.py @@ -636,7 +636,13 @@ def main(): install_config(deploy) if not args.skip_mcp: install_mcp(deploy) - print(f"\nInstalled from {ROOT}\nBackups: {STATE / 'backups'}\nRun: exec zsh\nThen: dev-doctor") + print(f"\nInstalled from {ROOT}\nBackups: {STATE / 'backups'}") + zsh = shutil.which("zsh") + if zsh: + # The calling shell may not yet have ~/.local/bin in its PATH. + print(f"Run: exec {shlex.quote(zsh)}\nThen: dev-doctor") + else: + print("Zsh is not installed. Run ./install.sh without --config-only to install missing tools.") return 0 diff --git a/tests/test_command_picker.py b/tests/test_command_picker.py new file mode 100644 index 0000000..4a2a1cf --- /dev/null +++ b/tests/test_command_picker.py @@ -0,0 +1,242 @@ +import importlib.util +import json +import os +from pathlib import Path +import pty +import select +import shlex +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +ZSH = shutil.which("zsh") +FZF = shutil.which("fzf") +spec = importlib.util.spec_from_file_location("command_catalog", ROOT / "scripts/command_catalog.py") +catalog = importlib.util.module_from_spec(spec) +spec.loader.exec_module(catalog) + + +class CatalogTests(unittest.TestCase): + def test_current_definition_and_alias_precedence(self): + metadata = {("alias", "same"): "Alias description", ("function", "same"): "Function description"} + entries = catalog.catalog("alias\0same\0echo current\0function\0same\0echo function\0", metadata) + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0]["command"], "echo current") + self.assertEqual(entries[0]["insert"], "echo current") + self.assertEqual(entries[0]["description"], "Alias description") + + def test_function_body_is_searchable_but_only_name_is_inserted(self): + body = ' command curl --fail "$1"\n print done' + entries = catalog.catalog("function\0probe\0" + body + "\0", {("function", "probe"): "Probe a URL"}) + self.assertEqual(entries[0]["insert"], "probe ") + self.assertIn(body, entries[0]["command"]) + self.assertIn('command curl --fail "$1"', catalog.row(entries[0])) + + def test_unavailable_and_internal_commands_are_not_listed(self): + metadata = {("alias", "absent"): "Unavailable tool", ("function", "_internal"): "Private helper"} + self.assertEqual(catalog.catalog("function\0_internal\0echo private\0", metadata), []) + + +@unittest.skipUnless(ZSH, "Zsh is required for shell picker tests") +class CommandPickerTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.home = Path(self.temporary.name) + self.bin = self.home / "bin" + self.bin.mkdir() + self.env = { + **os.environ, + "HOME": str(self.home), + "ZDOTDIR": str(self.home), + "XDG_CONFIG_HOME": str(self.home / "config"), + "XDG_DATA_HOME": str(self.home / "data"), + "XDG_STATE_HOME": str(self.home / "state"), + "XDG_CACHE_HOME": str(self.home / "cache"), + "LINUX_SETUP_DIR": str(ROOT), + "PATH": os.pathsep.join([str(self.bin), str(Path(sys.executable).parent), os.defpath]), + "TERM": "xterm-256color", + "PICKER_TEST_MARKER": str(self.home / "executed"), + "PICKER_TEST_REPORT": str(self.home / "fzf.json"), + "PICKER_TEST_BUFFER": str(self.home / "buffer"), + "PICKER_TEST_SELECT": "gs", + "PICKER_TEST_REAL_FZF": FZF or "", + "FZF_DEFAULT_OPTS": "--bind=enter:execute(touch MUST_NOT_RUN)", + "FZF_DEFAULT_OPTS_FILE": str(self.home / "nonexistent-options"), + } + # A user-space Zsh executable needs its installed modules even with -f. + data = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) + module_paths = sorted((data / "linux-setup/runtime/usr/lib").glob("*/zsh/*")) + self.prelude = "module_path=(" + " ".join(shlex.quote(str(path)) for path in module_paths) + " $module_path)\n" + self.prelude += '\n'.join( + 'source "$LINUX_SETUP_DIR/config/zsh/' + filename + '"' + for filename in ("aliases.zsh", "functions.zsh", "command-picker.zsh") + ) + "\n" + fzf = self.bin / "fzf" + fzf.write_text('''#!/usr/bin/env python3 +import json, os, pathlib, shlex, subprocess, sys +args = sys.argv[1:] +rows = sys.stdin.read().splitlines() +report = {"args": args, "options": os.environ.get("FZF_DEFAULT_OPTS"), + "options_file": os.environ.get("FZF_DEFAULT_OPTS_FILE"), "rows": rows} +if os.environ.get("PICKER_TEST_CANCEL"): + pathlib.Path(os.environ["PICKER_TEST_REPORT"]).write_text(json.dumps(report)) + sys.exit(130) +if os.environ.get("PICKER_TEST_FILTER"): + result = subprocess.run([os.environ["PICKER_TEST_REAL_FZF"], "--delimiter=\\t", "--with-nth=2..", + "--filter", os.environ["PICKER_TEST_FILTER"]], + input="\\n".join(rows), text=True, stdout=subprocess.PIPE) + rows = result.stdout.splitlines() +matches = [row for row in rows if row.split("\\t")[1] == os.environ["PICKER_TEST_SELECT"]] +if not matches: + sys.exit(1) +row = matches[0] +preview = args[args.index("--preview") + 1].replace("{1}", shlex.quote(row.split("\\t")[0])) +result = subprocess.run(["/bin/sh", "-c", preview], text=True, stdout=subprocess.PIPE, check=True) +report["preview"] = result.stdout +pathlib.Path(os.environ["PICKER_TEST_REPORT"]).write_text(json.dumps(report)) +print(row) +''') + fzf.chmod(0o755) + + def shell(self, command, **updates): + return subprocess.run([ZSH, "-dfc", self.prelude + command], env={**self.env, **updates}, + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) + + def test_catalog_reconciles_existing_and_new_commands(self): + output = self.shell("_dev_commands_list").stdout + rows = [line.split("\t") for line in output.splitlines()] + names = [row[0] for row in rows] + self.assertEqual(len(names), len(set(names))) + for name in ("dcup", "dclogs", "dclog", "docker-nuclear", "dcdrop-vol", "dcinto", "mkcd", "pscripts", "sshtunnel", "pgconnect", "devcommands"): + self.assertIn(name, names) + self.assertTrue(all(len(row) == 4 and row[2] != "Custom alias." and row[2] != "Custom function." for row in rows)) + self.assertNotIn("_dev_commands_snapshot", names) + + def test_private_comments_and_live_override_are_used_without_execution(self): + local = self.home / "config/linux-setup-local/zshrc" + local.parent.mkdir(parents=True) + local.write_text('# Check literal shell expressions.\nalias localprobe=\'touch "$PICKER_TEST_MARKER"\'\n') + output = self.shell('source "$XDG_CONFIG_HOME/linux-setup-local/zshrc"\nalias localprobe=\'printf "%s" "$(touch "$PICKER_TEST_MARKER")"\'\n_dev_commands_list localprobe').stdout + self.assertIn("Check literal shell expressions.", output) + self.assertIn('$(touch "$PICKER_TEST_MARKER")', output) + self.assertFalse((self.home / "executed").exists()) + + def test_queries_can_begin_with_command_flags(self): + output = self.shell("devhelp --follow").stdout + self.assertIn("dclog\t", output) + self.assertIn("--follow", output) + self.assertNotIn("gpull\t", output) + self.assertEqual(self.shell("_dev_commands_select --follow", PICKER_TEST_SELECT="dclog").stdout, + "docker compose logs --follow --tail=100") + report = json.loads((self.home / "fzf.json").read_text()) + self.assertEqual(report["args"][report["args"].index("--query") + 1], "--follow") + + def test_selection_and_preview_keep_shell_expressions_literal(self): + body = 'printf "%s" "$(touch "$PICKER_TEST_MARKER")" `touch "$PICKER_TEST_MARKER"`' + result = self.shell("alias gs=" + shlex.quote(body) + "\n_dev_commands_select") + self.assertEqual(result.stdout, body) + report = json.loads((self.home / "fzf.json").read_text()) + self.assertIn(body, report["preview"]) + self.assertEqual(report["options"], "") + self.assertEqual(report["options_file"], "") + self.assertFalse((self.home / "executed").exists()) + + def test_multiline_alias_is_preserved_for_editing(self): + body = 'printf "%s\\n" first\nprintf "%s\\n" second' + result = self.shell("alias gs=" + shlex.quote(body) + "\n_dev_commands_select") + self.assertEqual(result.stdout, body) + report = json.loads((self.home / "fzf.json").read_text()) + self.assertIn(body, report["preview"]) + + def test_function_selection_inserts_a_call_and_previews_full_body(self): + result = self.shell("_dev_commands_select", PICKER_TEST_SELECT="sshtunnel") + self.assertEqual(result.stdout, "sshtunnel ") + report = json.loads((self.home / "fzf.json").read_text()) + self.assertIn("ExitOnForwardFailure=yes", report["preview"]) + self.assertIn("Usage: sshtunnel", report["preview"]) + + @unittest.skipUnless(FZF, "fzf is required to validate actual fuzzy matching") + def test_actual_fzf_matches_name_description_and_full_command(self): + for query, name, inserted in [ + ("gconflicts", "gconflicts", "git diff --name-only --diff-filter=U"), + ("fast-forward", "gpull", "git pull --ff-only"), + ("diff-filter", "gconflicts", "git diff --name-only --diff-filter=U"), + ("ExitOnForwardFailure", "sshtunnel", "sshtunnel "), + ]: + with self.subTest(query=query): + output = self.shell("_dev_commands_select", PICKER_TEST_FILTER=query, PICKER_TEST_SELECT=name).stdout + self.assertEqual(output, inserted) + + def test_cancellation_preserves_line_and_cursor(self): + output = self.shell('zle() { :; }\nBUFFER="original draft"\nCURSOR=3\n_dev_commands_widget\nprint -r -- "$BUFFER|$CURSOR"', PICKER_TEST_CANCEL="1").stdout + self.assertEqual(output, "original draft|3\n") + + def test_key_bindings_cover_emacs_and_vi_modes(self): + result = subprocess.run([ZSH, "-dfi", "-c", self.prelude + '''for keymap in emacs viins vicmd; do + bindkey -M "$keymap" '^[x' + bindkey -M "$keymap" '^[X' +done'''], env=self.env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) + self.assertEqual(result.stdout.count('dev-commands'), 6) + + def terminal(self, action, cancel=False): + pid, master = pty.fork() + if pid == 0: + os.execve(ZSH, [ZSH, "-dfi"], {**self.env, **({"PICKER_TEST_CANCEL": "1"} if cancel else {})}) + def close(): + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + os.close(master) + os.waitpid(pid, 0) + self.addCleanup(close) + setup = self.prelude + '''bindkey -e +alias gs='touch "$PICKER_TEST_MARKER"' +_capture_buffer() { print -rn -- "$BUFFER" > "$PICKER_TEST_BUFFER"; BUFFER=''; zle reset-prompt; } +zle -N capture-buffer _capture_buffer +bindkey '^G' capture-buffer +PROMPT='PICKER_READY> ' +print -r -- PICKER_INITIALIZED +''' + os.write(master, setup.encode()) + deadline = time.monotonic() + 8 + received = b"" + while b"\r\nPICKER_INITIALIZED\r\n" not in received and time.monotonic() < deadline: + if select.select([master], [], [], 0.1)[0]: + received += os.read(master, 65536) + self.assertIn(b"\r\nPICKER_INITIALIZED\r\n", received, received.decode(errors="replace")) + os.write(master, action) + deadline = time.monotonic() + 8 + report = self.home / "fzf.json" + while not report.exists() and time.monotonic() < deadline: + if select.select([master], [], [], 0.1)[0]: + received += os.read(master, 65536) + self.assertTrue(report.exists(), received.decode(errors="replace")) + os.write(master, b"\x07") + buffer = self.home / "buffer" + while not buffer.exists() and time.monotonic() < deadline: + if select.select([master], [], [], 0.1)[0]: + received += os.read(master, 65536) + self.assertTrue(buffer.exists(), received.decode(errors="replace")) + self.assertFalse((self.home / "executed").exists()) + return buffer.read_text() + + def test_alt_x_inserts_into_real_terminal_without_execution(self): + self.assertEqual(self.terminal(b"\x1bx"), 'touch "$PICKER_TEST_MARKER"') + + def test_typed_devcommands_queues_editable_terminal_input(self): + self.assertEqual(self.terminal(b"devcommands\r"), 'touch "$PICKER_TEST_MARKER"') + + def test_alt_x_cancellation_preserves_real_terminal_input(self): + self.assertEqual(self.terminal(b"printf draft\x1bX", cancel=True), "printf draft") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_setup.py b/tests/test_setup.py index 3a75558..ff802a3 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -1,9 +1,11 @@ +from contextlib import ExitStack import hashlib import importlib.util import io import json import os from pathlib import Path +import shlex import subprocess import sys import tarfile @@ -151,6 +153,86 @@ def test_download_rejects_invalid_checksum(self): self.assertFalse(list((root / "cache/downloads").glob("*.part"))) +class BaseToolTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) / "fresh home" + self.root.mkdir() + stack = ExitStack() + self.addCleanup(stack.close) + for name, path in { + "HOME": self.root, + "CONFIG": self.root / ".config", + "DATA": self.root / ".local/share/linux-setup", + "STATE": self.root / ".local/state/linux-setup", + "CACHE": self.root / ".cache/linux-setup", + "BIN": self.root / ".local/bin", + }.items(): + stack.enter_context(patch.object(setup, name, path)) + self.deploy = setup.Deployment() + + def test_missing_zsh_installs_rootless_runtime_and_records_ownership(self): + runtime = setup.DATA / "base/zsh/runtime" + + def installation_step(command, **kwargs): + if command[:2] == ["apt-get", "-s"]: + self.assertEqual(command[-1], "zsh") + return Mock(stdout="Inst zsh-common (5.9-1 repo [all])\nInst zsh (5.9-1 repo [amd64])\n") + if command[:2] == ["apt-get", "download"]: + self.assertEqual(set(command[2:]), {"zsh=5.9-1", "zsh-common=5.9-1"}) + (Path(kwargs["cwd"]) / "zsh.deb").write_text("test package") + return Mock() + if command[:2] == ["dpkg-deb", "-x"]: + self.assertEqual(command[-1], runtime) + binary = runtime / "usr/bin/zsh" + binary.parent.mkdir(parents=True) + binary.write_text('#!/bin/sh\nprintf "%s\\n" "$ZDOTDIR" "$@"\n') + binary.chmod(0o755) + (runtime / "usr/lib/x86_64-linux-gnu/zsh/5.9").mkdir(parents=True) + (runtime / "usr/share/zsh/functions/Completion").mkdir(parents=True) + return Mock() + self.fail("Unexpected command: " + repr(command)) + + lookup = lambda name: None if name == "zsh" else "/usr/bin/" + name + with patch.object(setup.shutil, "which", side_effect=lookup), patch.object(setup, "run", side_effect=installation_step): + setup.install_base(self.deploy) + entry = setup.BIN / "zsh" + self.assertTrue(entry.is_symlink()) + self.assertTrue(os.access(entry, os.X_OK)) + bootstrap = setup.DATA / "base/zsh/bin/zsh-bootstrap" + startup = (bootstrap / ".zshenv").read_text() + self.assertIn(shlex.quote(str(runtime / "usr/lib/x86_64-linux-gnu/zsh/5.9")), startup) + self.assertIn(shlex.quote(str(runtime / "usr/share/zsh/functions/Completion")), startup) + result = subprocess.run([str(entry), "--version", "argument with spaces"], check=True, capture_output=True, text=True) + self.assertEqual(result.stdout.splitlines(), [str(bootstrap), "--version", "argument with spaces"]) + item = self.deploy.state["programs"]["zsh"] + self.assertTrue(item["owned"]) + self.assertEqual(item["version"], "distro") + self.assertEqual(item["packages"], {"zsh": "5.9-1", "zsh-common": "5.9-1"}) + self.assertEqual(self.deploy.state["links"][str(entry)]["program"], "zsh") + + def test_existing_zsh_is_reused_without_package_operations(self): + with patch.object(setup.shutil, "which", side_effect=lambda name: "/usr/bin/" + name), patch.object(setup, "run") as run: + setup.install_base(self.deploy) + run.assert_not_called() + item = self.deploy.state["programs"]["zsh"] + self.assertFalse(item["owned"]) + self.assertEqual(item["executable"], "/usr/bin/zsh") + self.assertFalse((setup.BIN / "zsh").exists()) + + def test_default_install_prints_zsh_path_for_the_calling_shell(self): + def install_base(deploy): + entry = setup.BIN / "zsh" + entry.write_text("#!/bin/sh\nexit 0\n") + entry.chmod(0o755) + + with patch.object(sys, "argv", ["setup.py", "--skip-mcp"]), patch.dict(os.environ, {"PATH": "/usr/bin:/bin"}), patch("preflight.check", return_value=[]), patch.object(setup, "migrate_legacy"), patch.object(setup, "install_base", side_effect=install_base) as base, patch.object(setup, "install_tools"), patch.object(setup, "install_config"), patch("sys.stdout", new_callable=io.StringIO) as output: + self.assertEqual(setup.main(), 0) + base.assert_called_once() + self.assertIn("Run: exec " + shlex.quote(str(setup.BIN / "zsh")), output.getvalue()) + + class ManagerTests(unittest.TestCase): def test_existing_managers_are_not_reinstalled_or_reconfigured(self): lock = json.loads((ROOT / "tools.lock.json").read_text())