120 Commits
Author SHA1 Message Date
fatalerrors 069457b3e9 expected structural change for 5.x 2026-10-13 18:17:34 +02:00
fatalerrors ea1a8a7a7e fixed spellcheck and minor issues for release 2026-10-13 17:49:03 +02:00
fatalerrors 8221ac5325 release 4.2.0 2026-10-13 16:35:14 +02:00
fatalerrors 9911f6d01d moved helpers separately 2026-10-13 16:21:24 +02:00
fatalerrors ba1e07744e many bugfix, fixed getopt split 2026-10-13 16:01:13 +02:00
fatalerrors b5be992d82 updates bug fix 2026-10-13 15:36:59 +02:00
fatalerrors 76bc2c7ade ssh bug fix 2026-10-13 15:12:19 +02:00
fatalerrors 32dc96ef0f rain bug fix and new engine 2026-10-13 15:00:04 +02:00
fatalerrors d28b602827 pwd bug fix 2026-10-13 14:46:18 +02:00
fatalerrors afe1a1b610 prompt bug fix 2026-10-13 14:35:00 +02:00
fatalerrors 9e96aaa411 processes bug fix 2026-10-13 14:31:18 +02:00
fatalerrors 25c6b71e59 processes bug fix 2026-10-13 14:19:32 +02:00
fatalerrors f78bfe5859 packages bug fix; added pkgi and pkgr 2026-10-13 14:09:00 +02:00
fatalerrors 3fbe9e85e2 changed default service for myextapi 2026-10-13 14:04:44 +02:00
fatalerrors 01ec151ade net bug fix, myextapi now service agnostic 2026-10-13 13:55:24 +02:00
fatalerrors 2b4732d0e3 lang bug fix 2026-10-13 13:42:56 +02:00
fatalerrors f7ff06cce6 info bug fix, fastfetch is now default 2026-10-13 13:41:54 +02:00
fatalerrors d2397ecea7 info bug fix, fastfetch is now default 2026-10-13 13:32:18 +02:00
fatalerrors 8e8f4890b7 help bug fix 2026-10-13 13:22:28 +02:00
fatalerrors 229422eda0 fun bug fix 2026-10-13 13:16:08 +02:00
fatalerrors 9723a6ad93 fun bug fix 2026-10-13 13:05:44 +02:00
fatalerrors df5dca96c6 filefct bug fix 2026-10-13 12:31:51 +02:00
fatalerrors b1071ab4b9 filefct bug fix 2026-10-13 12:31:41 +02:00
fatalerrors a9f55945b8 disp bug fix 2026-10-13 12:15:49 +02:00
fatalerrors f931db0808 fix bugs 2026-10-13 12:02:01 +02:00
fatalerrors daa2ee755c fix conf management 2026-10-13 11:49:40 +02:00
fatalerrors 6bfc2804ab utaz: add case management sensitivity support 2026-10-13 11:21:53 +02:00
fatalerrors 2cc767ecc2 many bugfixes 2026-10-13 11:01:18 +02:00
fatalerrors 82b9e6bf2f fix local issue with real numbers 2026-08-11 16:10:50 +02:00
fatalerrors 99dc9a4ef5 get rid of eval (use readarray) 2026-07-09 18:17:53 +02:00
fatalerrors c1ea14e145 accept various codeset writting 2026-07-09 16:51:13 +02:00
fatalerrors 03ca259c4c remove english based git output dependencies 2026-07-09 16:29:09 +02:00
fatalerrors 97773515eb remove forbidden comments 2026-07-09 16:22:20 +02:00
fatalerrors fbe1f074da several bug fix 2026-07-09 16:04:15 +02:00
fatalerrors db7dcb6f03 bug fix / isipv6 complete rewrite 2026-07-09 14:58:01 +02:00
fatalerrors e328199618 fixed disp with escape sequence 2026-07-09 14:56:30 +02:00
fatalerrors e6607f07af optimise mdcat and fix trruncated files rendering 2026-07-09 11:30:43 +02:00
fatalerrors 54da852ac3 moved load_conf top of files 2026-07-09 11:11:05 +02:00
fatalerrors d375a09623 moved load_conf top of files 2026-07-09 11:10:53 +02:00
fatalerrors b2b25843ff fix debug robustness 2026-07-09 10:55:20 +02:00
fatalerrors 2702682fae fixed displayed value 2026-07-08 18:56:50 +02:00
fatalerrors 970cd2ccf6 bugfix 2026-07-08 18:51:24 +02:00
fatalerrors 38038e0fa1 add keep-all options 2026-07-08 18:40:09 +02:00
fatalerrors 9ba36582e4 implement multiple archive creation 2026-07-08 18:36:30 +02:00
fatalerrors 022d4f9e77 bugfix 2026-07-08 18:12:27 +02:00
fatalerrors 70fc5c2f78 removed last french 2026-07-08 18:02:06 +02:00
fatalerrors a178439545 avoid double call when .profile source .bashrc and vice-versa 2026-06-23 17:15:48 +02:00
fatalerrors a93d367974 added swap and gpu info to pinfo 2026-06-03 15:48:04 +02:00
fatalerrors bcc86814b5 added gprune --deep parameter to allow deletion of forge merge 2026-06-03 15:47:29 +02:00
fatalerrors 462efef034 checked dependencies, made some optional, document optional dependencies 2026-06-02 17:49:16 +02:00
fatalerrors f0c62a5e7f fix doc and style 2026-06-02 17:24:41 +02:00
fatalerrors df4c63657b use internals 2026-06-02 17:17:29 +02:00
fatalerrors 9c201f0e97 make set_term 24 bits aware 2026-06-02 17:16:35 +02:00
fatalerrors c4c52cfb48 allow true color for matrix and rain 2026-06-02 16:47:58 +02:00
fatalerrors 7c14c67ad0 added pkgf 2026-06-02 16:36:20 +02:00
fatalerrors 4882bee0ad updated todo 2026-06-02 13:55:09 +02:00
fatalerrors bbfd877ee5 added wildcard support in rmhost 2026-06-02 13:54:42 +02:00
fatalerrors 1288b47c34 added support for fd when available 2026-06-02 13:54:01 +02:00
fatalerrors d849b22001 added option all to conf_dump 2026-06-02 11:02:35 +02:00
fatalerrors 9fcdb24988 fix colors in conf_dump 2026-06-02 10:13:22 +02:00
fatalerrors 3e0bedd9c8 updated and fixed doc (markdown spellcheck) 2026-05-28 18:08:41 +02:00
fatalerrors 481cc94fa8 added autodetection for TERM var 2026-05-28 17:47:23 +02:00
fatalerrors 28e1e26b0e reworked known_bugs. 2026-05-28 16:33:05 +02:00
fatalerrors bddcd170c3 added conf_dump and updated doc 2026-05-28 16:29:43 +02:00
fatalerrors 0a85d265cb made configuration saving generic 2026-05-28 12:04:30 +02:00
fatalerrors c13945ced5 context factorization 2026-05-28 11:51:23 +02:00
fatalerrors a33aa5f6be add timeout to git operations 2026-05-28 11:31:20 +02:00
fatalerrors d9a62cf2a8 updated doc and exemple conf 2026-05-27 17:32:35 +02:00
fatalerrors 7287d8ef87 add random lang hability 2026-05-27 17:31:37 +02:00
fatalerrors 7c761a4895 make delays more iregular 2026-05-27 15:19:42 +02:00
fatalerrors fef99326c3 make it infinite 2026-05-27 15:05:45 +02:00
fatalerrors ac7eba14b0 unecessary stuff 2026-05-27 14:40:02 +02:00
fatalerrors f6bf229c8d unecessary kill and wait 2026-05-27 14:37:44 +02:00
fatalerrors 01e17888fe add missing exports 2026-05-27 14:26:44 +02:00
fatalerrors 8ac0c1eaf2 added hack and fake_compile 2026-05-27 14:22:22 +02:00
fatalerrors 600f170d00 add error situation preview 2026-05-27 14:06:26 +02:00
fatalerrors cbe29a05f5 fixed preview 2026-05-27 12:01:24 +02:00
fatalerrors 8399adbfa0 fixed preview 2026-05-27 11:59:27 +02:00
fatalerrors 2c81a48fe9 added rainbow 2026-05-21 16:12:56 +02:00
fatalerrors d03ced93bf fix help and spellcheck 2026-05-21 16:12:34 +02:00
fatalerrors 8b1fc5f01c fix spellcheck 2026-05-21 16:11:26 +02:00
fatalerrors 02cd9a853e processes func switch to getopt 2026-05-21 14:28:40 +02:00
fatalerrors 648777a13e update todo 2026-05-21 11:55:07 +02:00
fatalerrors 5264470127 update theming doc 2026-05-21 11:51:08 +02:00
fatalerrors f923dcb758 add preview and configuration saving to prompt theming 2026-05-21 11:43:15 +02:00
fatalerrors c92ca8a51f made local really local 2026-05-21 11:17:21 +02:00
fatalerrors 19ca7fa905 added pinfo 2026-05-21 11:10:09 +02:00
fatalerrors 6bacb03687 improved kt and ku 2026-05-21 11:08:46 +02:00
fatalerrors 5f8800ab44 add resume support to dwl 2026-05-20 16:28:32 +02:00
fatalerrors 8bfb3272c0 fix mod 2026-05-20 16:12:29 +02:00
fatalerrors 1a48280b14 minor fix 2026-05-20 15:54:23 +02:00
fatalerrors 9a272689eb manage element density 2026-05-20 15:22:17 +02:00
fatalerrors 6e4d052170 add completion for set_theme 2026-05-20 15:01:10 +02:00
fatalerrors ceb3386c57 added known-bugs.ml 2026-05-20 14:30:07 +02:00
fatalerrors d22b24e54c prompt now display contextual infos 2026-05-20 14:14:06 +02:00
fatalerrors 28e4c112af added experimental mdcat 2026-05-20 13:55:28 +02:00
fatalerrors 9ec52aa49f update doc 2026-05-19 17:39:15 +02:00
fatalerrors 1b28e90c62 update doc 2026-05-19 17:20:12 +02:00
fatalerrors 1e31712b60 allow auto with taz 2026-05-19 17:19:50 +02:00
fatalerrors 5faae67d11 better cleanup 2026-05-19 16:57:27 +02:00
fatalerrors ee72ede116 update doc and release 4.1.0 2026-05-07 15:03:23 +02:00
fatalerrors f5244ac062 allow the profile to self install 2026-05-07 11:54:06 +02:00
fatalerrors 9a089112c3 make help better 2026-05-07 11:53:28 +02:00
fatalerrors e64a857a43 fix too long long on non functionnal networt (and improve dwl) 2026-05-07 11:52:53 +02:00
fatalerrors ddd7d4193a version bump 2026-05-06 18:28:25 +02:00
fatalerrors 83a1c8ce48 removed rogue french comments 2026-05-06 18:28:06 +02:00
fatalerrors b29fa3b30c make get_pkgmgr public 2026-05-06 18:27:19 +02:00
fatalerrors cd0bcfd214 fix completion 2026-05-06 18:24:38 +02:00
fatalerrors a91c41871a fix completion 2026-05-06 18:20:30 +02:00
fatalerrors 9698f0e506 fix completion 2026-05-06 18:12:14 +02:00
fatalerrors 9e22f007b9 make disp smarter 2026-05-06 18:05:51 +02:00
fatalerrors d472fb61aa load completion 2026-05-06 15:35:05 +02:00
fatalerrors 9108ee8266 add primary completion for git 2026-05-06 15:34:19 +02:00
fatalerrors a7f7452b2b add auto to gacp 2026-05-05 16:25:13 +02:00
fatalerrors bc67399ebc Merge branch '4.x' 2026-04-23 17:59:07 +02:00
fatalerrors 02b037d0fc proper changelog, removed the old history.txt file 2026-04-23 17:57:01 +02:00
fatalerrors e567957ea0 update doc 2026-04-23 17:36:25 +02:00
fatalerrors 67bdd3e863 add gacp 2026-04-23 17:31:33 +02:00
fatalerrors fa573bce8f add git helpers 2026-04-23 17:18:01 +02:00
fatalerrors 241d53ebc4 Merge branch '4.x' 2026-04-22 17:58:21 +02:00
54 changed files with 8701 additions and 1238 deletions
+3
View File
@@ -0,0 +1,3 @@
# Used by shellcheck (run from the project root: shellcheck profile.sh profile.d/*.sh helpers/*.sh)
external-sources=true
source-path=SCRIPTDIR
+293 -61
View File
@@ -1,23 +1,45 @@
# profile # profile
This project aims to create an advanced bash profile. It includes aliases, This project aims to create an advanced bash profile. It includes aliases,
a customized prompt and several functions for different purposes. It's mostly a customized prompt and several functions for different purposes. It's mostly
targeted to system administrators but might satisfy some regular users. targeted to system administrators but might satisfy some regular users.
## 1. Requirements ## 1. Requirements
profile requires **Bash 4.3 or higher** (for associative arrays and namerefs). profile requires **Bash 4.3 or higher** (for associative arrays and namerefs).
It will refuse to load on older versions and will also refuse to load if the It will refuse to load on older versions and will also refuse to load if the
current shell is not bash. current shell is not bash.
## 2. Getting started ## 2. Getting started
Download and extract (or use git clone) the profile archive into your home Download and extract (or use git clone) the profile archive into your home
directory. You will have to modify your `~/.bashrc` and/or `~/.profile` file to directory.
add at the end (preferably):
The profile is designed to be **sourced**, not executed directly.
Manual setup:
```bash ```bash
source <installpath>/profile/profile.sh source <installpath>/profile/profile.sh
``` ```
Automatic setup (recommended):
```bash
bash <installpath>/profile/profile.sh --install
```
`--install` appends the required `source` line to both `~/.bashrc` and
`~/.profile` by default. You can target one file only:
```bash
bash <installpath>/profile/profile.sh --install --bashrc
bash <installpath>/profile/profile.sh --install --profile
```
You may also set the `PROFILE_PATH` environment variable before sourcing if you You may also set the `PROFILE_PATH` environment variable before sourcing if you
want to override the automatic path detection: want to override the automatic path detection:
```bash ```bash
export PROFILE_PATH=/opt/profile export PROFILE_PATH=/opt/profile
source /opt/profile/profile.sh source /opt/profile/profile.sh
@@ -26,64 +48,118 @@ source /opt/profile/profile.sh
It's not recommended to load that profile in `/etc/profile` as users' `.bashrc` It's not recommended to load that profile in `/etc/profile` as users' `.bashrc`
files might interfere with some aliases and functions defined in profile. files might interfere with some aliases and functions defined in profile.
### 2.1. Initial configuration ### 2.1. Interactive vs non-interactive shells
`profile.sh` detects whether the current shell is interactive.
In interactive shells (typical terminal sessions), profile enables
interactive-only features such as:
- aliases from `[aliases]`
- bash completion scripts from `profile.d/bash-completion/`
- prompt initialization (`PROMPT_COMMAND` and timer hook)
- welcome display (`showinfo`) and startup update check (`check_updates -q`)
In non-interactive shells (typical script execution), those features are
intentionally skipped to avoid side effects and startup noise. Public functions
remain available after sourcing, so scripts can still call profile helpers.
### 2.2. Initial configuration
Copy the example configuration file and customise it to your needs: Copy the example configuration file and customise it to your needs:
```bash ```bash
cp <installpath>/profile/doc/profile.conf.example <installpath>/profile/profile.conf cp <installpath>/profile/doc/profile.conf.example <installpath>/profile/profile.conf
``` ```
`profile.conf` is git-ignored so your personal settings will never be `profile.conf` is git-ignored so your personal settings will never be
accidentally committed. All keys are optional — sensible defaults apply when accidentally committed. All keys are optional — sensible defaults apply when
unset. See [section 4](#4-configuration) for the full reference. unset. See [section 4](#4-configuration) for the full reference.
## 3. What's the purpose? ## 3. What's the purpose?
profile gives access to numerous functions, aliases and to an advanced prompt. profile gives access to numerous functions, aliases and to an advanced prompt.
All functions are organized into modules under the `profile.d/` directory and All functions are organized into modules under the `profile.d/` directory and
are loaded automatically at startup. are loaded automatically at startup.
The internal helpers (functions starting with `_`, not meant to be called by
hand) are kept apart from the modules, in the `helpers/` directory:
`helpers/common.sh` holds the ones shared by several modules (option parsing,
trap restoration, ...), and each module loads its own private helpers from
`helpers/<module name>.sh` when that file exists (`profile.d/rain.sh` loads
`helpers/rain.sh`). Both are loaded automatically; `helpers/` must be kept next
to `profile.sh` and `profile.d/`.
### 3.1. Prompt ### 3.1. Prompt
A bar-style prompt showing current time, execution time of the last command A bar-style prompt showing current time, execution time of the last command
(with sub-millisecond precision), and the exit code of the last command. (with sub-millisecond precision), and the exit code of the last command.
### 3.2. Functions reference ### 3.2. Functions reference
| Function | Module | Description | | Function | Module | Description |
|---|---|---| | --- | --- | --- |
| `busy` | fun | Monitor /dev/urandom for a hex pattern — look busy | | `backtrace` | debug | Print the current call stack (function, file, line of each call), innermost frame first; used by the `settrace` error trace |
| `check_updates` | updates | Check whether a newer profile version is available online | | `busy` | fun | Monitor /dev/urandom for a hex pattern — look busy; the pattern can also be given as argument; runs until a key (or Ctrl-C) is pressed, needs a terminal |
| `check_updates` | updates | Check whether a newer profile version is available online; when called with `-q` at startup a 3-second network timeout is applied so a slow or absent network never delays the prompt; the published version is validated before use and an installed version more recent than the published one is not reported as outdated |
| `clean` | filefct | Erase backup files in given directories, optionally recursive | | `clean` | filefct | Erase backup files in given directories, optionally recursive |
| `disp` | disp | Display formatted info / warning / error / debug messages | | `conf_dump` | conf | Display the profile configuration file; `-s/--section NAME` restricts output to one section; `-a/--all` also lists commented-out keys (documented defaults, skipped when the key is set); an optional pattern argument filters keys by substring match; honours `NO_COLOR` |
| `dwl` | net | Download a URL using curl, wget, or fetch transparently | | `conf_save` | conf | Save or update a key=value pair in a configuration section: `conf_save <section> <key> <value>`; replaces an existing key in place or appends it to the section (creating the section if absent); works through a symlinked file and keeps its permissions; the value must be single-line and the running shell is not updated (applies at next load) |
| `expandlist` | filefct | Expand glob expressions into a quoted, separated list | | `disp` | disp | Display formatted info / warning / error / debug messages; long messages are word-wrapped and continuation lines are indented to align with the message text; honours `NO_COLOR`, `QUIET=1` (hides info messages) and `DEBUG` > 1 (shows debug messages); warnings, errors and debug go to stderr |
| `file_stats` | filefct | Display file size statistics for a path | | `dwl` | net | Download a URL using curl, wget, or fetch transparently; supports `-t <seconds>` / `--timeout <seconds>` to cap the transfer time |
| `error` | debug | ERR trap handler installed by `settrace`: reports the failed command status and a call stack on stderr, then keeps the status |
| `expandlist` | filefct | Expand glob expressions into a quoted, separated list; names with spaces are kept whole, `\`, `"`, `$` and `` ` `` are escaped, and the separator (`-s`, `-n` or `EXPANDLIST_DEFAULT_SEPARATOR`) may use escapes such as `\n` |
| `fake_compile` | fun | Simulate a long compilation process with random warnings and errors |
| `file_stats` | filefct | Display file size statistics for a path; `--min`/`--max` are inclusive and validated, the histogram is sorted |
| `findbig` | filefct | Find the biggest files in the given or current directory | | `findbig` | filefct | Find the biggest files in the given or current directory |
| `finddead` | filefct | Find dead symbolic links in the given or current directory | | `finddead` | filefct | Find dead symbolic links in the given or current directory |
| `findzero` | filefct | Find empty files in the given or current directory | | `findzero` | filefct | Find empty files in the given or current directory; `--delete` does not follow symlinks |
| `genpwd` | pwd | Generate one or more random secure passwords with configurable constraints | | `gacp` | git | Add, commit and push changes; auto-pulls with rebase first if needed |
| `genpwd` | pwd | Generate one or more random passwords with configurable constraints, from the kernel's random generator (`$SRANDOM`, or `/dev/urandom`); only the passwords go to stdout |
| `ggraph` | git | Display a decorated git history graph |
| `gpid` | processes | Give the list of PIDs matching the given process name(s) | | `gpid` | processes | Give the list of PIDs matching the given process name(s) |
| `help` | help | Display the list of available functions and basic usage | | `gprune` | git | Delete local branches already merged into the main branch; `-D`/`--deep` also deletes (force) branches whose upstream branch was deleted on the remote, e.g. after a merge request |
| `greset` | git | Reset the current branch to upstream, stashing local changes first (the target is checked before anything is stashed; the previous HEAD is printed for recovery); `-x` also removes ignored files, which cannot be undone |
| `groot` | git | Display the repository root path, or change directory to it with `-g` |
| `gsync` | git | Fetch (the remote the branch tracks, or the given one) and rebase the current branch onto its upstream; needs a checked-out branch |
| `gst` | git | Display compact git status with branch tracking information |
| `gwip` | git | Create a quick WIP checkpoint commit |
| `hack` | fun | Simulate a dramatic multi-phase hacking sequence |
| `help` | help | Display the list of available functions and basic usage; `help <command>` delegates to `<command> --help` for shell functions (profile commands) only; honours `NO_COLOR` |
| `isipv4` | net | Tell if the given parameter is a valid IPv4 address | | `isipv4` | net | Tell if the given parameter is a valid IPv4 address |
| `isipv6` | net | Tell if the given parameter is a valid IPv6 address | | `isipv6` | net | Tell if the given parameter is a valid IPv6 address |
| `ku` | processes | Kill all processes owned by the given user name or ID | | `kt` | processes | Kill a process and all its descendants, deepest first (kill tree); `-n`/`--dry-run`, `-s`/`--signal` or a kill-style `-9`/`-KILL`; refuses PID 0, 1 and the current shell; needs `pgrep` |
| `ku` | processes | Kill all processes owned by the given user name or ID; `-n`/`--dry-run`, `-s`/`--signal` or a kill-style `-9`/`-KILL`; all users are checked before anything is killed; never kills root's or the current user's processes (except with `-n`) |
| `matrix` | rain | Console screensaver with Matrix-style digital rain (binary, kana, ascii charset) | | `matrix` | rain | Console screensaver with Matrix-style digital rain (binary, kana, ascii charset) |
| `mcd` | filefct | Create a directory and immediately move into it | | `mcd` | filefct | Create a directory and immediately move into it |
| `mdcat` | disp | Render Markdown files (or stdin) in terminal with colors, inline formatting, tables and framed code blocks; honours `NO_COLOR` |
| `locale_check` | lang | Tell whether a locale is installed, tolerating codeset spellings (`UTF-8`/`utf8`); prints the name exactly as the system spells it |
| `meteo` | info | Display weather forecast for the configured or given city | | `meteo` | info | Display weather forecast for the configured or given city |
| `myextip` | net | Get information about your public IP address | | `myextip` | net | Get information about your public IP address (IP, ISP, location, coordinates, AS, timezone) from an HTTPS lookup service; needs `jq` for the formatted output, `-R` shows the raw JSON |
| `get_pkgmgr` | packages | Detect the active package manager of the running distribution (`apt`, `dnf`, `yum`, `zypper`, `pacman`, `apk`, `portage`, `xbps`, `nix`) | | `get_pkgmgr` | packages | Detect the active package manager of the running distribution (`apt`, `dnf`, `yum`, `zypper`, `pacman`, `apk`, `portage`, `xbps`, `nix`) |
| `pkgs` | packages | Search for a pattern in installed package names (distro-aware via `get_pkgmgr`, supports `-i`) | | `pinfo` | info | Display system information read from `/proc` and `/sys` only |
| `pkgf` | packages | Find which installed package owns a given file (distro-aware via `get_pkgmgr`) |
| `pkgi` | packages | Install packages with the distribution's manager (distro-aware via `get_pkgmgr`); `-y` assumes yes, `-n` only shows the command; uses `sudo` (or `doas`) when not root |
| `pkgr` | packages | Uninstall packages; `-p`/`--purge` also deletes configuration files (apt, pacman, apk; other managers just remove), `-d`/`--deps` also removes dependencies no longer needed (`--auto-remove`, `-Rs`, `--clean-deps`, `depclean`, ... — apk always does, dnf/yum are told not to unless `-d`); `-y`, `-n` as `pkgi` |
| `pkgs` | packages | Search for a pattern in installed package names (distro-aware via `get_pkgmgr`) |
| `ppg` | processes | Look for the given pattern in running processes | | `ppg` | processes | Look for the given pattern in running processes |
| `ppn` | processes | List processes matching an exact command name | | `ppn` | processes | List processes whose command name (as shown by `ps -o comm`) is exactly the given one |
| `ppu` | processes | List processes owned by a specific user | | `ppu` | processes | List processes owned by a specific user |
| `profile_upgrade` | updates | Upgrade profile to the latest version (git pull or archive) | | `profile_upgrade` | updates | Upgrade profile to the latest version: `git pull --ff-only` on a Git install, otherwise the downloaded (or `-f` local) archive, refused if it contains absolute or `..` paths; `-g` switches to a Git clone keeping `profile.conf` and custom themes; modules left over from an older version are listed, never deleted; `-n` shows what would be done |
| `pwdscore` | pwd | Calculate the strength score of a given password | | `pwdscore` | pwd | Calculate the strength score of a given password |
| `rain` | rain | Console screensaver with falling-rain effect (multiple color themes) | | `rain` | rain | Console screensaver with falling-rain effect (multiple color themes) |
| `rmhost` | ssh | Remove host (name and IP) from SSH known_hosts; supports `--all-users` as root | | `rainbow` | rain | Full-screen rainbow screensaver using only background colors, with horizontal color shifting |
| `rmspc` | filefct | Replace spaces in filenames with underscores (or a custom character) | | `rmhost` | ssh | Remove host(s) from SSH known_hosts: a name is removed with every IP address it resolves to, `[host]:port` entries and glob patterns are supported; `--all-users` (root) handles every local user's file and keeps its owner and mode |
| `rmspc` | filefct | Replace spaces in filenames with underscores (or a custom character, attached: `-c-` / `--subst-char=-`); never overwrites an existing file and does not follow symlinked directories |
| `setc` | lang | Set locale to standard C (POSIX) | | `setc` | lang | Set locale to standard C (POSIX) |
| `setlocale` | lang | Set console locale to any installed locale | | `setlocale` | lang | Set console locale to any installed locale (codeset spelling tolerant, UTF-8 preferred when none is given); clears `LC_ALL` and the other per-category variables so the change always applies |
| `settrace` | debug | Activate or deactivate ERR trap to display backtrace on script errors | | `set_colors` | disp | (Re)define the colour variables (`Red`, `BIWhite`, `On_Blue`, `RESETCOL`, ...) used by `disp`, the prompt and themes; run at load time |
| `showinfo` | info | Display welcome banner and system information (figlet + neofetch/fastfetch) | | `set_theme` | prompt | Switch the prompt colour theme for the session (`--list`, `--preview`, `--save`) |
| `ssr` | ssh | SSH into a server as root, forwarding extra ssh options | | `settrace` | debug | Activate (`--on`), deactivate (`--off`) or query (`--status`) an ERR trap that prints the exit status and a call stack (on stderr) for each failing command; `--force` replaces/removes an ERR trap set by something else; active at startup when `SETTRACE_DEFAULT_ENABLED=1` |
| `showinfo` | info | Display welcome banner and system information (figlet + fastfetch/neofetch) |
| `ssr` | ssh | SSH into a server as root (the server comes first, then extra ssh options); default options from `SSH_DEFAULT_OPT` |
| `taz` | compress | Compress files and directories into a chosen archive format | | `taz` | compress | Compress files and directories into a chosen archive format |
| `term_set` | conf | Set `TERM` to the best available terminal capability |
| `urlencode` | net | URL-encode a string | | `urlencode` | net | URL-encode a string |
| `utaz` | compress | Smartly uncompress archives (zip, tar.gz/bz2/xz/lz, rar, arj, lha, ace, 7z, zst, cpio, cab, deb, rpm) | | `utaz` | compress | Smartly uncompress archives (zip, tar.gz/bz2/xz/lz, rar, arj, lha, ace, 7z, zst, cpio, cab, deb, rpm) |
| `ver` | info | Display the installed profile version | | `ver` | info | Display the installed profile version |
@@ -91,7 +167,15 @@ A bar-style prompt showing current time, execution time of the last command
Locale shortcut functions (`setfr`, `setus`, etc.) are dynamically generated at Locale shortcut functions (`setfr`, `setus`, etc.) are dynamically generated at
startup from the `SET_LOCALE` configuration key (see section 4). startup from the `SET_LOCALE` configuration key (see section 4).
### 3.3. Bash completion
profile loads all `*.sh` files found under `profile.d/bash-completion/`
automatically in interactive sessions. This directory is the right place to add
any custom completion definitions. profile already ships completions for its git
helper functions there (`git-completion.sh`).
## 4. Configuration ## 4. Configuration
profile uses an INI-style configuration file (`profile.conf`) located in the profile uses an INI-style configuration file (`profile.conf`) located in the
same directory as `profile.sh`. Sections are declared with `[section_name]` and same directory as `profile.sh`. Sections are declared with `[section_name]` and
keys follow `key = value` syntax. Each module calls `load_conf "<section>"` at keys follow `key = value` syntax. Each module calls `load_conf "<section>"` at
@@ -99,18 +183,55 @@ load time, which exports every key in that section as an environment variable.
Unknown keys are silently ignored; all keys are optional — sensible defaults Unknown keys are silently ignored; all keys are optional — sensible defaults
apply when unset. apply when unset.
A line is `key = value` (the key is a variable name; a line that is neither a
section, a comment nor a valid assignment is reported and ignored). Lines starting
with `#` or `;` are comments, and so is the end of a line after a blank and a `#`
(`PROMPT_THEME = dark # my theme`); a value between single or double quotes is
what is inside the quotes, so a `#` can be kept in it. `$VAR` references to
existing environment variables are expanded; backticks and `$(` are removed.
`profile.conf` is listed in `.gitignore` so personal values (API keys, cities, `profile.conf` is listed in `.gitignore` so personal values (API keys, cities,
compiler flags, …) are never accidentally staged. Start from the annotated compiler flags, …) are never accidentally staged. Start from the annotated
template at `doc/profile.conf.example` (see [section 2.1](#21-initial-configuration)). template at `doc/profile.conf.example` (see [section 2.2](#22-initial-configuration)).
### 4.1. Core sections ### 4.1. Core sections
| Section | Purpose | | Section | Purpose |
|---|---| | --- | --- |
| `[system]` | Bash history size, pager, and other shell behaviours | | `[system]` | Bash history size, pager, terminal type, and other shell behaviours |
| `[general]` | General-purpose variables (e.g. compilation flags, `MAKEFLAGS`) | | `[general]` | General-purpose variables (e.g. compilation flags, `MAKEFLAGS`) |
| `[aliases]` | User command aliases, loaded for interactive shells only | | `[aliases]` | User command aliases, loaded for interactive shells only |
#### `TERM` — terminal type detection
The `TERM` key in `[system]` has special handling via `term_set`, which runs
automatically at startup:
| Value | Behaviour |
| --- | --- |
| *(absent or `smart`)* | Auto-detect the best available `terminfo` entry (default) |
| any other string | Exported as-is — standard Unix behaviour |
When auto-detection runs, `COLORTERM=truecolor|24bit` is evaluated as a global
truecolor flag: it does not pick an entry by itself, it only enables the
`*-direct` (24-bit colour) terminfo entries below, each of which falls back to its
`*-256color` counterpart. Hints are checked in the following order and the first
entry confirmed to exist via `terminfo` is used. Terminal multiplexers come first
because they inherit the outer emulator's variables (`TERM_PROGRAM`,
`VTE_VERSION`, …) but need their own `TERM`.
1. **`$TMUX`** — tmux session → `tmux-direct`¹ → `screen-direct`¹, then `tmux-256color`, then `screen-256color`
2. **`$STY`** — GNU screen session → `screen-direct`¹, then `screen-256color`, then `screen`
3. **`$TERM_PROGRAM`** — emulator name hints:
- `iTerm.app` → `xterm-direct`¹, then `xterm-256color`
- `WezTerm` → `xterm-direct`¹, then `wezterm`, then `xterm-256color`
- `Hyper`, `vscode` → `xterm-direct`¹, then `xterm-256color`
4. **`$VTE_VERSION`** — GNOME Terminal, Tilix, … → `vte-direct`¹, then `vte-256color`, then `xterm-256color`
5. **`$WT_SESSION`** — Windows Terminal → `xterm-direct`¹, then `xterm-256color`
6. **Generic probe** — `xterm-256color` → `xterm-color` → `xterm` → `vt100`
¹ Only attempted when the truecolor flag is set (`COLORTERM=truecolor` or `24bit`).
### 4.2. Module defaults ### 4.2. Module defaults
Each module exposes its hardcoded defaults as configuration keys. Set a key to Each module exposes its hardcoded defaults as configuration keys. Set a key to
@@ -119,17 +240,24 @@ change the default without having to pass flags every time.
**`[compress]`** — `taz` / `utaz` **`[compress]`** — `taz` / `utaz`
| Key | Default | Description | | Key | Default | Description |
|---|---|---| | --- | --- | --- |
| `TAZ_DEFAULT_FORMAT` | `tar.gz` | Archive format for `taz` (`tar.gz`, `tar.bz2`, `tar.xz`, `zip`, …) | | `TAZ_DEFAULT_FORMAT` | `lz` | Archive format(s) for `taz`: `lz`, `xz`, `bz2`, `gz`, `lzo`, `tar`; comma-separated list keeps the smallest result |
| `TAZ_DEFAULT_THREADS` | `0` | Compression threads (0 = auto-detect) | | `TAZ_DEFAULT_THREADS` | `auto` | Compression threads (`auto` = runtime CPU count, or explicit positive integer) |
| `TAZ_DEFAULT_LEVEL` | `6` | Compression level (1–9) | | `TAZ_DEFAULT_LEVEL` | `6` | Compression level (1–9) |
| `UTAZ_DEFAULT_DELETE` | `0` | Set to `1` to delete the source archive after extraction | | `UTAZ_DEFAULT_DELETE` | `0` | Set to `1` to delete the source archive after extraction |
| `UTAZ_DEFAULT_DIR_MODE` | `0` | Set to `1` to always extract into a subdirectory | | `UTAZ_DEFAULT_DIR_MODE` | `auto` | Host directory policy: `auto` (only when the archive has no single top-level directory), `always` or `never` |
| `UTAZ_DEFAULT_IGNORE_CASE` | `0` | Set to `1` to match archive extensions case-insensitively (`.ZIP`, `.Tar.GZ`). Per call: `utaz -i` / `utaz -s` |
**`[debug]`** — `settrace`
| Key | Default | Description |
| --- | --- | --- |
| `SETTRACE_DEFAULT_ENABLED` | `0` | Set to `1` to activate the error trace (`settrace --on`) at the startup of every interactive shell |
**`[filefct]`** — file utilities **`[filefct]`** — file utilities
| Key | Default | Description | | Key | Default | Description |
|---|---|---| | --- | --- | --- |
| `EXPANDLIST_DEFAULT_SEPARATOR` | ` ` (space) | Separator used by `expandlist` | | `EXPANDLIST_DEFAULT_SEPARATOR` | ` ` (space) | Separator used by `expandlist` |
| `CLEAN_DEFAULT_RECURSIVE` | `0` | Set to `1` to make `clean` recurse into subdirectories | | `CLEAN_DEFAULT_RECURSIVE` | `0` | Set to `1` to make `clean` recurse into subdirectories |
| `RMSPC_DEFAULT_CHAR` | `_` | Replacement character used by `rmspc` | | `RMSPC_DEFAULT_CHAR` | `_` | Replacement character used by `rmspc` |
@@ -138,25 +266,28 @@ change the default without having to pass flags every time.
**`[rain]`** — screensavers **`[rain]`** — screensavers
| Key | Default | Description | | Key | Default | Description |
|---|---|---| | --- | --- | --- |
| `RAIN_DEFAULT_SPEED` | `0.1` | Falling speed for `rain` | | `RAIN_DEFAULT_SPEED` | `5` | Delay between frames for `rain`: a value ≥ 1 is in hundredths of a second (`5` = 0.05 s), a value < 1 is in seconds; must be > 0 |
| `RAIN_DEFAULT_COLOR` | `Green` | Colour for `rain` | | `RAIN_DEFAULT_COLOR` | `white` | Colour for `rain` (`green`, `blue`, `red`, `yellow`, `cyan`, `white`; case-insensitive) |
| `MATRIX_DEFAULT_SPEED` | `0.05` | Falling speed for `matrix` | | `RAIN_DEFAULT_DENSITY` | dynamic | Maximum number of simultaneous falling elements for `rain` |
| `MATRIX_DEFAULT_COLOR` | `Green` | Colour for `matrix` | | `RAINBOW_DEFAULT_SPEED` | `4` | Frame delay (hundredths of a second) of the colour shift for `rainbow` |
| `MATRIX_DEFAULT_CHARSET` | `binary` | Character set for `matrix` (`binary`, `kana`, `ascii`) | | `MATRIX_DEFAULT_SPEED` | `3.5` | Frame delay for `matrix`, same scale as `RAIN_DEFAULT_SPEED` |
| `MATRIX_DEFAULT_COLOR` | `green` | Colour for `matrix` (same names as `rain`) |
| `MATRIX_DEFAULT_DENSITY` | dynamic | Maximum number of simultaneous falling elements for `matrix` |
| `MATRIX_DEFAULT_CHARSET` | `binary` | Character set for `matrix` (`binary`, `kana`, `ascii`; case-insensitive) |
**`[ssh]`** **`[ssh]`**
| Key | Default | Description | | Key | Default | Description |
|---|---|---| | --- | --- | --- |
| `SSH_DEFAULT_OPT` | _(empty)_ | Extra options passed to `ssr` (e.g. `-Y` for X forwarding) | | `SSH_DEFAULT_OPT` | `-Y` when unset | Options passed to `ssr` before the server (e.g. `-Y -o StrictHostKeyChecking=accept-new`); set it empty for none |
**`[pwd]`** — password tools **`[pwd]`** — password tools
| Key | Default | Description | | Key | Default | Description |
|---|---|---| | --- | --- | --- |
| `GENPWD_DEFAULT_LENGTH` | `16` | Generated password length | | `GENPWD_DEFAULT_LENGTH` | `16` | Generated password length |
| `GENPWD_DEFAULT_OCCURS` | `1` | Number of character class occurrences | | `GENPWD_DEFAULT_OCCURS` | `2` | Maximum number of occurrences of the same character in a password |
| `GENPWD_DEFAULT_COUNT` | `1` | Number of passwords to generate | | `GENPWD_DEFAULT_COUNT` | `1` | Number of passwords to generate |
| `GENPWD_DEFAULT_SYMBOLS` | `1` | Include symbols (0/1) | | `GENPWD_DEFAULT_SYMBOLS` | `1` | Include symbols (0/1) |
| `GENPWD_DEFAULT_UPPERCASE` | `1` | Include uppercase letters (0/1) | | `GENPWD_DEFAULT_UPPERCASE` | `1` | Include uppercase letters (0/1) |
@@ -167,41 +298,56 @@ change the default without having to pass flags every time.
**`[fun]`** **`[fun]`**
| Key | Default | Description | | Key | Default | Description |
|---|---|---| | --- | --- | --- |
| `BUSY_DEFAULT_PATTERN` | `[0-9a-f]` | Hex pattern matched by `busy` | | `BUSY_DEFAULT_PATTERN` | `ca fe` | String searched (fixed string, case-insensitive) in the hexdump lines printed by `busy` |
| `BUSY_DEFAULT_DELAY` | `0.1` | Polling delay (seconds) for `busy` | | `BUSY_DEFAULT_DELAY` | `0` | Delay between printed lines for `busy`, in milliseconds (integer, `0` = none) |
| `FAKE_COMPILE_DEFAULT_MIN_DELAY` | `40` | Min delay between output lines for `fake_compile` (ms) |
| `FAKE_COMPILE_DEFAULT_MAX_DELAY` | `150` | Max delay between output lines for `fake_compile` (ms) |
| `FAKE_COMPILE_DEFAULT_LANG` | `c` | Default language preset for `fake_compile` (`c`, `cpp`, `java`, `python`, `random`) |
| `HACK_DEFAULT_MIN_DELAY` | `60` | Min delay between output lines for `hack` (ms) |
| `HACK_DEFAULT_MAX_DELAY` | `250` | Max delay between output lines for `hack` (ms) |
**`[git]`**
| Key | Default | Description |
| --- | --- | --- |
| `GIT_MAIN_BRANCH` | `main` | Fallback main branch name used when remote HEAD cannot be detected |
| `GIT_DEFAULT_REMOTE` | `origin` | Default remote used by git helper functions |
| `GIT_WIP_PREFIX` | `wip` | Prefix used by `gwip` when generating automatic checkpoint messages |
| `GIT_GACP_AUTO_ADD` | `1` | Set to `1` to make `gacp` automatically add all modified files when no explicit file list is given; set to `0` to require explicit paths or the `-a` flag |
**`[info]`** **`[info]`**
| Key | Default | Description | | Key | Default | Description |
|---|---|---| | --- | --- | --- |
| `METEO_DEFAULT_CITY` | _(empty)_ | Default city for `meteo` when no argument is given | | `METEO_DEFAULT_CITY` | _(empty)_ | Default city for `meteo` when no argument is given |
**`[net]`** **`[net]`**
| Key | Default | Description | | Key | Default | Description |
|---|---|---| | --- | --- | --- |
| `DWL_PREFERRED_TOOL` | _(empty)_ | Force `dwl` to use `curl`, `wget`, or `fetch` (auto-detected when unset) | | `DWL_PREFERRED_TOOL` | _(empty)_ | Force `dwl` to use `curl`, `wget`, or `fetch` (auto-detected when unset) |
| `MYEXTIP_DEFAULT_URL` | `https://ip-api.com/json` | API endpoint used by `myextip` | | `DWL_DEFAULT_RESUME` | `0` | Enable `dwl` resume mode by default (`1`/`true`/`yes`/`on`); applies to file downloads |
| `MYEXTIP_DEFAULT_URL` | `https://ipinfo.io/json` | HTTPS JSON API used by `myextip`. `https://ipwho.is/` is also supported, as is the (HTTP-only on its free tier) ip-api.com format; a non-HTTPS URL triggers a warning |
**`[packages]`** **`[packages]`**
| Key | Default | Description | | Key | Default | Description |
|---|---|---| | --- | --- | --- |
| `PKGS_DEFAULT_IGNORE_CASE` | `0` | Set to `1` to make `pkgs` case-insensitive by default | | `PKGS_DEFAULT_IGNORE_CASE` | `0` | Set to `1` to make `pkgs` case-insensitive by default |
**`[processes]`** **`[processes]`**
| Key | Default | Description | | Key | Default | Description |
|---|---|---| | --- | --- | --- |
| `PPU_DEFAULT_FORMAT` | `pid,user,comm,args` | `ps` output format used by `ppu` | | `PPU_DEFAULT_FORMAT` | `pid,user,%cpu,%mem,start,time,command` | `ps` output format used by `ppu` |
| `KU_DEFAULT_SIGNAL` | `TERM` | Default signal sent by `ku` | | `KU_DEFAULT_SIGNAL` | `TERM` | Default signal sent by `ku` |
**`[updates]`** **`[updates]`**
| Key | Default | Description | | Key | Default | Description |
|---|---|---| | --- | --- | --- |
| `UPDT_DEFAULT_BRANCH` | `master` | Git branch used for update checks and `profile_upgrade`. Changing this value causes `profile_upgrade` to automatically switch the local checkout to the new branch on the next run and display a warning. | | `UPDT_DEFAULT_BRANCH` | `master` | Git branch used for update checks and `profile_upgrade` (letters, digits, `.`, `_`, `-`, `/`; anything else falls back to `master`). Changing this value causes `profile_upgrade` to automatically switch the local checkout to the new branch on the next run and display a warning. |
### 4.3. Locale shortcuts ### 4.3. Locale shortcuts
@@ -244,6 +390,23 @@ PROMPT_THEME_DIR = ~/.mythemes # optional: custom search directory
Built-in themes: `default`, `dark`, `light`, `solarized`, `solarized-light`, Built-in themes: `default`, `dark`, `light`, `solarized`, `solarized-light`,
`monokai`, `monochrome`, `abyss`, `plasma`, `adwaita`. `monokai`, `monochrome`, `abyss`, `plasma`, `adwaita`.
**Runtime theme switching (`set_theme`):**
```bash
set_theme --list # list available themes
set_theme dark # apply theme for current shell session
set_theme --preview dark # preview theme colors without applying
set_theme --save # save currently active theme to config
set_theme --save dark # apply and save the given theme
```
`set_theme --save` writes `PROMPT_THEME` in `[prompt]` to:
- `~/.profile.conf` when present
- otherwise `profile.conf` in the profile installation directory
`--preview` and `--save` are mutually exclusive.
**Overriding individual prompt colour slots:** **Overriding individual prompt colour slots:**
```ini ```ini
@@ -252,10 +415,15 @@ PROMPT_COLOR_USER_FG = $ICyan
PROMPT_COLOR_DIR_FG = $IYellow PROMPT_COLOR_DIR_FG = $IYellow
``` ```
The eleven available `PROMPT_COLOR_*` keys are: A value is one or more blocks, each a colour variable (`$ICyan`, `${ICyan}`) or a
raw ANSI literal (`\e[1;32m`, `\033[4m`), e.g. `$BWhite\e[4m`; anything else is
rejected with a warning (colours end up in `PS1`). These overrides always win over
the theme, also after `set_theme`.
The twelve available `PROMPT_COLOR_*` keys are:
| Key | Role | | Key | Role |
|---|---| | --- | --- |
| `PROMPT_COLOR_TIME_FG` / `TIME_BG` | Clock foreground / background | | `PROMPT_COLOR_TIME_FG` / `TIME_BG` | Clock foreground / background |
| `PROMPT_COLOR_BAR_BG` | Main bar background | | `PROMPT_COLOR_BAR_BG` | Main bar background |
| `PROMPT_COLOR_OK_FG` / `OK_MARK` | Exit-code text / checkmark on success | | `PROMPT_COLOR_OK_FG` / `OK_MARK` | Exit-code text / checkmark on success |
@@ -263,6 +431,30 @@ The eleven available `PROMPT_COLOR_*` keys are:
| `PROMPT_COLOR_ROOT_FG` | Username colour when running as root | | `PROMPT_COLOR_ROOT_FG` | Username colour when running as root |
| `PROMPT_COLOR_USER_FG` | Username@host colour for normal users | | `PROMPT_COLOR_USER_FG` | Username@host colour for normal users |
| `PROMPT_COLOR_DIR_FG` | Working directory colour | | `PROMPT_COLOR_DIR_FG` | Working directory colour |
| `PROMPT_COLOR_CTX_FG` | Git/Conda context segment colour at end of top bar |
**Top-bar context segment (Git / Conda):**
```ini
[prompt]
PROMPT_SHOW_GIT = 1
PROMPT_SHOW_GIT_STATUS = 1
PROMPT_GIT_TIMEOUT = 2
PROMPT_SHOW_CONDA = 1
PROMPT_SHOW_VENV = 1
PROMPT_SHOW_SESSION = 1
```
When enabled, the top prompt bar appends:
- `git:<branch>` when inside a Git repository
- `git:<branch>*` when local changes are present
- `git:<branch> +N/-M` when ahead/behind upstream
- `git:<branch>?` when `git diff` or `git rev-list` exceeded `PROMPT_GIT_TIMEOUT`
- `conda:<env>` when a Conda environment is active
- `venv:<name>` when a Python virtualenv is active (and Conda is not)
- `ssh`, `tmux`, `screen` markers when the session runs in those contexts
- all, separated by `|`, when several are available
**Writing a custom theme file:** **Writing a custom theme file:**
@@ -286,19 +478,57 @@ and other colour-aware tools.
True-colour themes (`solarized`, `solarized-light`) require a terminal with True-colour themes (`solarized`, `solarized-light`) require a terminal with
24-bit colour support (Konsole, iTerm2, kitty, Alacritty, Windows Terminal). 24-bit colour support (Konsole, iTerm2, kitty, Alacritty, Windows Terminal).
Verify support with: Verify support with:
```bash ```bash
printf '\e[38;2;38;139;210mTrue colour test\e[0m\n' printf '\e[38;2;38;139;210mTrue colour test\e[0m\n'
``` ```
## 5. Contact and more information ## 5. Optional dependencies
### 5.1. New users
profile is designed so that every external dependency is optional: all features
degrade gracefully (an error message is shown and the specific function returns
early) when a binary is absent. The table below lists packages that are **not**
part of a standard minimal Linux installation (might differ from your actual
distribution, some are still extremely common). Nothing in this list is required
to load profile or use its core functions.
| Binary | Typical package name | Distr. | Function(s) | Behaviour when absent |
| --- | --- | --- | --- | --- |
| `figlet` | `figlet` | all | `showinfo` | ASCII banner skipped |
| `neofetch` | `neofetch` | all | `showinfo` | Used only if `fastfetch` is absent; then falls back to the built-in `pinfo` |
| `fastfetch` | `fastfetch` | all | `showinfo` | Preferred over `neofetch`; if both are absent, falls back to the built-in `pinfo` |
| `jq` | `jq` | all | `myextip` | Raw JSON shown instead of formatted output |
| `hexdump` | `util-linux` / `bsdmainutils` | all | `busy` | Error message, function returns 1 |
| `numfmt` | `coreutils` ≥ 8.21 | all | `file_stats --min/--max` | Error message, function returns 1 |
| `envsubst` | `gettext` | all | config loading | `$VAR` references in `profile.conf` values left literal |
| `killall` | `psmisc` | all | `ku` | Falls back to `pkill` (procps); error if both absent |
| `pkill` / `pgrep` | `procps` / `procps-ng` | all | `ku`, `ppg`, `gpid` | `ku` falls back to `killall`; `ppg`/`gpid` fall back to `ps + awk`; `kt` requires `pgrep` |
| `pigz` | `pigz` | all | `taz` (gzip) | Falls back to `gzip` (slower, single-thread) |
| `plzip` | `plzip` | all | `taz` (lzip) | Falls back to `lzip` |
| `lzip` / `plzip` | `lzip` | all | `taz`/`utaz` (.lz) | Error message if neither is available |
| `xz` | `xz-utils` / `xz` | all | `taz`/`utaz` (.xz) | Error message |
| `unrar` | `unrar` / `unrar-free` | all | `utaz` (.rar) | Error message |
| `unarj` | `arj` | all | `utaz` (.arj) | Error message |
| `lha` | `lhasa` | all | `utaz` (.lzh) | Error message |
| `unace` | `unace` | all | `utaz` (.ace) | Error message |
| `7z` | `p7zip-full` | all | `taz`/`utaz` (.7z) | Error message |
| `cabextract` | `cabextract` | all | `utaz` (.cab) | Error message |
| `rpm2cpio` | `rpm` | all | `utaz` (.rpm) | Error message |
| `nix-locate` | `nix-index` | NixOS | `pkgf` | Error: unsupported package manager |
| `qfile` | `gentoolkit` | Gentoo | `pkgf` | Error: unsupported package manager |
## 6. Contact and more information
### 6.1. New users
If you use (or plan to use) `profile`, I'll be happy if you simply mail me to If you use (or plan to use) `profile`, I'll be happy if you simply mail me to
let me know, especially if you don't plan to contribute. If you plan to let me know, especially if you don't plan to contribute. If you plan to
contribute, I'll be twice happier for sure! contribute, I'll be twice happier for sure!
### 5.2. Bugs ### 6.2. Bugs
**profile** bug tracker is hosted on its Gitea instance. Check the **profile** bug tracker is hosted on its Gitea instance. Check the
https://git.geoffray-levasseur.org/fatalerrors/profile page. If you find a bug, <https://git.geoffray-levasseur.org/fatalerrors/profile> page. If you find a bug,
you can also submit a bug report to the maintainer mail address mentioned at you can also submit a bug report to the maintainer mail address mentioned at
the end of that document. A bug report may contain the command line parameters the end of that document. A bug report may contain the command line parameters
where the bug happens, OS details, the module that triggered it, if any, and the where the bug happens, OS details, the module that triggered it, if any, and the
@@ -310,7 +540,8 @@ have not tested the same code under a real Unix environment.
Check the [FAQ](./doc/FAQ.md) and the [to-do list](./doc/todo.md) before Check the [FAQ](./doc/FAQ.md) and the [to-do list](./doc/todo.md) before
sending any feature request or bug report, as it might already be documented. sending any feature request or bug report, as it might already be documented.
### 5.3. How to contribute? ### 6.3. How to contribute?
You are free to improve and contribute as you wish. If you have no idea what to You are free to improve and contribute as you wish. If you have no idea what to
do or want some direction, you can check the [to-do list](./doc/todo.md), do or want some direction, you can check the [to-do list](./doc/todo.md),
containing desired future improvements. Make sure you always have the latest containing desired future improvements. Make sure you always have the latest
@@ -332,6 +563,7 @@ installation will probably be rejected.
If you want to make a financial contribution, please contact me by mail. If you want to make a financial contribution, please contact me by mail.
### 5.4. License, website, and maintainer ### 5.4. License, website, and maintainer
Everything except configuration files is licensed under the BSD-3 license. Everything except configuration files is licensed under the BSD-3 license.
Please check the license file alongside this one. Please check the license file alongside this one.
@@ -344,4 +576,4 @@ You can mail the author at fatalerrors \<at\> geoffray-levasseur \<dot\> org.
Documentation (c) 2021-2026 Geoffray Levasseur. Documentation (c) 2021-2026 Geoffray Levasseur.
This file is distributed under the 3-clause BSD license. The complete license This file is distributed under the 3-clause BSD license. The complete license
agreement can be obtained at: https://opensource.org/licenses/BSD-3-Clause agreement can be obtained at: <https://opensource.org/licenses/BSD-3-Clause>
Executable → Regular
+296 -6
View File
@@ -7,9 +7,111 @@ Versions follow `MAJOR.MINOR.PATCH-REVISION_STAGE_N` (e.g. `3.99.1-4_rc_1`).
--- ---
## [Unreleased]
---
## [4.2.0] — 2026-10-13
Full review of every module and of `profile.sh`: many bug fixes, comments and
documentation updated.
### Added
- `pkgi` (install) and `pkgr` (uninstall; `-p`/`--purge`, `-d`/`--deps`).
- `utaz -i`/`--ignore-case` and `-s`/`--case-sensitive`, default set by
`UTAZ_DEFAULT_IGNORE_CASE`; `.zst` and `.tzst` support.
- `SETTRACE_DEFAULT_ENABLED` key (`[debug]` section).
- `--help` for `backtrace`, `locale_check` and `get_pkgmgr`; support functions
added to the README table and to `help`.
### Changed
- Internal helpers moved to the new `helpers/` directory: `common.sh` (shared)
and `<module>.sh` (private to a module, loaded by the module itself).
- Option parsing and trap restoration no longer use `xargs` or `eval`
(`_getopt_split`, `_trap_restore`).
- `myextip` uses an HTTPS service (`ipinfo.io`) by default.
- `showinfo` prefers `fastfetch` over `neofetch`.
### Fixed
- Security: prompt injection through git branch / environment names, `genpwd`
weak random source, unsafe handling of values read from the network or from
the configuration.
- Many functional bugs in `taz`/`utaz`, `conf_save`/`conf_dump`, `term_set`,
`disp`/`mdcat`, `findzero`, `gprune`, `setlocale`, `dwl`, `ku`/`kt`, `pkgf`,
`rmhost`, `rain`/`matrix`, `profile_upgrade` and the prompt exit status.
- `profile.sh`: shell check, option handling when sourced, `PATH` and
`PROMPT_COMMAND` handling, configuration parsing.
- Debug traces go to stderr; `((x++))` patterns no longer fail under `set -e`.
- Copyright years and license headers; the whole project is now `shellcheck`
clean (`.shellcheckrc` added).
---
## [4.1.0] — 2026-05-07
### Added
- `profile.sh --install` command to automatically configure profile loading in
shell startup files.
- `--install --bashrc` and `--install --profile` target selectors for
single-file installation.
- **`git.sh`** — entirely new module providing git workflow helpers: `gst`,
`ggraph`, `gsync`, `gacp`, `greset`, `gwip`, `gprune`, `groot`.
- Dedicated git helper completions under `profile.d/bash-completion/`, loaded
automatically in interactive sessions from `profile.d/bash-completion/*.sh`.
### Changed
- `disp` now wraps long messages on terminal width, avoids mid-word splits, and
aligns continuation lines with the message body after the prefix.
- `help` now supports `help <command>` and delegates to `<command> --help`.
- `taz` now supports `-p auto` / `--parallel=auto` to automatically use the
runtime CPU count. This mode is now the default via
`TAZ_DEFAULT_THREADS=auto`. Backward compatibility with 0 being interpreted as
auto is maintained.
### Fixed
- Startup responsiveness improved: `check_updates -q` now uses a short network
timeout so unavailable/slow networks no longer delay prompt readiness.
- `dwl` gained timeout support (`-t` / `--timeout`) and is now used by quiet
startup update checks to enforce fast failure.
- `profile.sh` now detects direct execution and warns that it is designed to be
sourced.
## [4.0.0] — 2026-04-23
### Added
- New `profile.conf` reference template at `doc/profile.conf.example`.
- Dynamic locale shortcuts generated from `SET_LOCALE` and startup default
language selection through `DEFAULT_LANG`.
- Prompt theming system with bundled themes (`default`, `dark`, `light`,
`solarized`, `solarized-light`, `monokai`, `monochrome`, `abyss`, `plasma`,
`adwaita`) and per-key prompt color overrides.
- Module defaults exposed as configuration keys in `profile.conf`.
### Changed
- `utaz` now supports a wider range of archive formats.
- Prompt and theme rendering improved, including better 24-bit color support.
- Overall code quality and maintainability improved across modules.
### Documentation
- README updated with full function reference and configuration tables.
- New and expanded docs in `doc/` (`CONTRIBUTING.md`, `FAQ.md`, `todo.md`).
- Historical releases imported from `history.txt` into this changelog.
---
## [3.99.2-4_rc_2] — 2026-04-21 ## [3.99.2-4_rc_2] — 2026-04-21
### Fixed ### Fixed
- **`prompt.sh`** — `\$Last_Command` in PS1 was escaped, preventing the exit - **`prompt.sh`** — `\$Last_Command` in PS1 was escaped, preventing the exit
code from ever appearing in the prompt (the local variable no longer exists code from ever appearing in the prompt (the local variable no longer exists
when PS1 is rendered by bash). Removed the backslash so the value is embedded when PS1 is rendered by bash). Removed the backslash so the value is embedded
@@ -24,6 +126,7 @@ Versions follow `MAJOR.MINOR.PATCH-REVISION_STAGE_N` (e.g. `3.99.1-4_rc_1`).
`"Usage: ppg <string>"` instead of `"Usage: kt <pid>"`. `"Usage: ppg <string>"` instead of `"Usage: kt <pid>"`.
### Added ### Added
- **`packages.sh` — `get_pkgmgr()`** — new exported helper that detects the - **`packages.sh` — `get_pkgmgr()`** — new exported helper that detects the
active package manager of the running distribution. Detection first reads active package manager of the running distribution. Detection first reads
`/etc/os-release` (`ID` then `ID_LIKE`), then falls back to a `/etc/os-release` (`ID` then `ID_LIKE`), then falls back to a
@@ -36,6 +139,7 @@ Versions follow `MAJOR.MINOR.PATCH-REVISION_STAGE_N` (e.g. `3.99.1-4_rc_1`).
## [3.99.1-4_rc_1] — 2026 ## [3.99.1-4_rc_1] — 2026
### Added ### Added
- **Theming system** — `load_theme` in `profile.d/prompt.sh` loads `.theme` - **Theming system** — `load_theme` in `profile.d/prompt.sh` loads `.theme`
files from `profile.d/themes/` (or a custom directory set via files from `profile.d/themes/` (or a custom directory set via
`PROMPT_THEME_DIR`). Theme files are **parsed, not executed** — no shell code `PROMPT_THEME_DIR`). Theme files are **parsed, not executed** — no shell code
@@ -63,6 +167,7 @@ Versions follow `MAJOR.MINOR.PATCH-REVISION_STAGE_N` (e.g. `3.99.1-4_rc_1`).
never accidentally staged. never accidentally staged.
### Changed ### Changed
- README §2 now explains how to create `profile.conf` from - README §2 now explains how to create `profile.conf` from
`doc/profile.conf.example` (new section 2.1 "Initial configuration"). `doc/profile.conf.example` (new section 2.1 "Initial configuration").
- README §4 updated with full module-defaults tables, theming reference, and a - README §4 updated with full module-defaults tables, theming reference, and a
@@ -71,6 +176,7 @@ Versions follow `MAJOR.MINOR.PATCH-REVISION_STAGE_N` (e.g. `3.99.1-4_rc_1`).
variables, only data). variables, only data).
### Security ### Security
- `load_theme` uses a strict allowlist (no `eval`, no sourcing). Only - `load_theme` uses a strict allowlist (no `eval`, no sourcing). Only
`PROMPT_COLOR_*` keys and known `disp.sh` colour variable names are accepted. `PROMPT_COLOR_*` keys and known `disp.sh` colour variable names are accepted.
Values must match `\$[A-Za-z_][A-Za-z0-9_]*` or `\\e\[[0-9;]*m`; any other Values must match `\$[A-Za-z_][A-Za-z0-9_]*` or `\\e\[[0-9;]*m`; any other
@@ -78,15 +184,10 @@ Versions follow `MAJOR.MINOR.PATCH-REVISION_STAGE_N` (e.g. `3.99.1-4_rc_1`).
--- ---
---
> **Note:** Versions prior to `3.95.x-4_beta` did not maintain a formal
> changelog. The full history of earlier changes is available through the git
> log (`git log --oneline`).
## [3.95.3-4_beta_3] — 2024 ## [3.95.3-4_beta_3] — 2024
### Added ### Added
- Initial public release candidate series. - Initial public release candidate series.
- Core modules: `compress`, `disp`, `filefct`, `fun`, `help`, `info`, `lang`, - Core modules: `compress`, `disp`, `filefct`, `fun`, `help`, `info`, `lang`,
`net`, `packages`, `processes`, `prompt`, `pwd`, `rain`, `ssh`, `updates`. `net`, `packages`, `processes`, `prompt`, `pwd`, `rain`, `ssh`, `updates`.
@@ -95,3 +196,192 @@ Versions follow `MAJOR.MINOR.PATCH-REVISION_STAGE_N` (e.g. `3.99.1-4_rc_1`).
- `genpwd` / `pwdscore` password tools. - `genpwd` / `pwdscore` password tools.
- `matrix` / `rain` screensavers. - `matrix` / `rain` screensavers.
- `profile_upgrade` with git and archive download support. - `profile_upgrade` with git and archive download support.
---
> **Note:** The section below was imported from `history.txt` to preserve
> pre-`3.95.x-4_beta` release notes.
## Legacy releases (imported from `history.txt`)
### [3.6.1] — 2026-03-05
- Fix typo in `compress.sh`.
### [3.6.0] — 2026-03-05
- Improved `utaz` with broader multi-format support.
- Introduced `ppu` and `ppn`.
- Improved update system.
### [3.5.0] — 2026-03-04
- `rain` now has configurable speed and color.
- `showinfo` adapted to `fastfetch` (in addition to `neofetch`).
### [3.3.1] — 2022-02-24
- Fixed version detection.
- Added `busy`.
- Fixed use of library functions before loading.
### [3.3.0] — 2022-11-28
- Initial version update support.
- Changed versioning code.
- Added installation path detection.
### [3.2.3] — 2022-11-28
- Improved README.
### [3.2.2] — 2022-11-21
- Fixed `taz` compression level parsing.
- Fixed typo in `dpkgs`.
### [3.2.1] — 2022-11-20
- Fixed several messages.
- Made `dpkgs` RPM-aware (initial support).
- Removed version history from main script and reverted declaration order.
- Added required license information in all files.
- Completed `LICENSE` file.
### [3.2.0] — 2022-11-18
- Created `disp` command and integrated it across the codebase.
### [3.1.1] — 2022-11-10
- `genpwd`: added feasibility check for requested password constraints.
### [3.1.0] — 2022-11-08
- Added password generator.
### [3.0.1] — 2022-11-07
- Added concatenation option to `rmspc`.
- Added `ku`.
- Improved error handling in `meteo`.
### [3.0.0] — 2022-08-27
- Split code into several files/modules.
- Added `rain` screensaver.
### [2.8.2] — 2022-07-29
- Added warning for non-bash users.
### [2.8.1] — 2022-07-19
- Cleanup, fixes and optimizations.
### [2.8.0] — 2022-06-24
- Added `backtrace`, `error` and `settrace`.
- Bugfixes in `showinfo`.
### [2.7.1] — 2022-06-22
- Minor corrections.
- Added `help` command.
### [2.7.0] — 2022-06-21
- Added `isipv4` and `isipv6`, integrated into `rmhost`.
- Removed broken Konsole save/restore support.
### [2.6.3] — 2021-10-18
- Changed PS1 to status-bar style.
- Minor improvements.
### [2.6.2] — 2021-02-26
- Bugfix in `taz` for directories with trailing slash.
### [2.6.1] — 2020-12-25
- Added checks in `rmhost`.
- Improved `rmspc`.
- Created `expandlist`.
### [2.6.0] — 2020-10-24
- Added Konsole session save/restore.
### [2.5.3] — 2020-09-11
- Added aliases, improved code consistency and fixed typos.
- Improved `utaz`, removed `showdiskmap`, removed remaining French text.
- Added license information for future publication.
### [2.5.2] — 2020-03-06
- Sorted and improved aliases.
### [2.5.1] — 2020-03-05
- Language consistency fixes.
- Added `pigz` support in `taz`.
### [2.5.0] — 2020-03-03
- Added `taz` and `rmspc`.
- Renamed `auzip` to `utaz` and improved it.
### [2.4.0] — 2020-03-02
- Added `auzip`.
### [2.3.2] — 2020-01-31
- `figlet`: changed default font to `ansi_shadow`.
### [2.3.1] — 2020-01-16
- Bugfix: non-interactive shells were blocked by some functions.
### [2.3.0] — 2020-01-08
- Added `figlet` and `neofetch` as MOTD replacement.
### [2.2.0] — 2019-12-16
- Added `showinfo`.
- First implementation of `showdiskmap`.
### [2.1.2] — 2019-09-24
- Bugfix in profile version display.
### [2.1.1] — 2019-09-23
- Bugfix in `dpkgs`.
### [2.1.0] — 2018-09-16
- Added `rmhost`, `setc`, `setfr`.
- Improved locale management.
### [2.0.1] — 2017-02-04
- `clean` improvements (`--shell`).
### [2.0.0] — 2015-10-24
- Added advanced functions (`clean`, `ssr`, etc.).
### [1.0.0] — 2013-02-16
- Initial version.
### [Initial fork]
Forked default Bash profile from Beyond Linux From Scratch by:
- James Robertson <jameswrobertson@earthlink.net>
- Dagmar d'Surreal <rivyqntzne@pbzpnfg.arg>
Executable → Regular
+54 -5
View File
@@ -39,6 +39,7 @@ to target). Stale forks cause avoidable merge conflicts.
**New functionality** must always target `master`. **New functionality** must always target `master`.
**Bugfixes** must target the branch where the bug was introduced: **Bugfixes** must target the branch where the bug was introduced:
- If the bug exists in a released version, open the fix against that version's - If the bug exists in a released version, open the fix against that version's
maintenance branch first, then cherry-pick onto `master`. maintenance branch first, then cherry-pick onto `master`.
- If the bug is only in `master` (unreleased), fix it directly on `master`. - If the bug is only in `master` (unreleased), fix it directly on `master`.
@@ -71,13 +72,15 @@ Any experimental version must have it's dedicated branch.
--- ---
## 5. Development environment ## 5. Development environment
|---|---|---|
| --- | --- | --- |
| Bash | 4.3 | Namerefs (`local -n`) required | | Bash | 4.3 | Namerefs (`local -n`) required |
| shellcheck | any recent | Run before every commit | | shellcheck | any recent | Run before every commit |
| git | any | For contributing patches | | git | any | For contributing patches |
| bats-core | 1.x | Optional — for running the test suite | | bats-core | 1.x | Optional — for running the test suite |
Install shellcheck: Install shellcheck:
```bash ```bash
# Debian / Ubuntu # Debian / Ubuntu
apt-get install shellcheck apt-get install shellcheck
@@ -94,6 +97,7 @@ brew install shellcheck
## 6. Code style ## 6. Code style
### General rules ### General rules
- **Bash only** — no external interpreters in core modules. Python or Perl is - **Bash only** — no external interpreters in core modules. Python or Perl is
acceptable for completely self-contained, optional utilities that have no acceptable for completely self-contained, optional utilities that have no
dependencies beyond a minimal Debian or CentOS installation. dependencies beyond a minimal Debian or CentOS installation.
@@ -104,22 +108,37 @@ brew install shellcheck
environment. Prefer upper case for global and lowercase for local. environment. Prefer upper case for global and lowercase for local.
- **`printf`** instead of `echo` all the time. - **`printf`** instead of `echo` all the time.
- **Never `eval`** — use namerefs (`local -n`), `${!varname}` indirection, or - **Never `eval`** — use namerefs (`local -n`), `${!varname}` indirection, or
`declare -g` instead. `declare -g` instead. The only exception is the function generator of
`build_locale_shortcuts` (`profile.d/lang.sh`): bash cannot create a function
whose name is only known at run time otherwise, and the only value injected
into it is quoted with `printf %q`.
- **No hardcoded defaults** — wire every configurable value through - **No hardcoded defaults** — wire every configurable value through
`${VAR:-default}` and document the key in `profile.conf` and `README.md §4`. `${VAR:-default}` and document the key in `profile.conf` and `README.md §4`.
### Function conventions ### Function conventions
- Public functions **must** be exported: `export -f funcname`. - Public functions **must** be exported: `export -f funcname`.
- Every public function **must** support `-h` / `--help` and print usage to - Every public function **must** support `-h` / `--help` and print usage to
stdout, returning 0. stdout, returning 0.
- Use `getopt` (not `getopts`) for option parsing — it handles long options and - Use `getopt` (not `getopts`) for option parsing — it handles long options and
`--` correctly. `--` correctly.
Rebuild the arguments from its output with the `_getopt_split` helper
(`helpers/common.sh`), never with `eval set --` or `xargs`:
```bash
PARSED=$(getopt -o hv --long help,verbose -n 'mycmd' -- "$@") || return 1
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
```
To restore traps saved with `trap -p`, use `_trap_restore` (no `eval` either).
- Follow existing error-return conventions: 0 = success, 1 = usage error, - Follow existing error-return conventions: 0 = success, 1 = usage error,
2 = bad options, 3 = missing dependency, 4+ = runtime failure. 2 = bad options, 3 = missing dependency, 4+ = runtime failure.
- Prefix all local helper variables with a short unique prefix (e.g. `_taz_`) - Prefix all local helper variables with a short unique prefix (e.g. `_taz_`)
to prevent collisions with caller-scope variables. to prevent collisions with caller-scope variables.
### Module structure ### Module structure
Every new module should follow this pattern: Every new module should follow this pattern:
```bash ```bash
@@ -137,6 +156,29 @@ export -f my_function
Add the `load_conf` call near the top after any variable declarations. Add the `load_conf` call near the top after any variable declarations.
### Helpers
Internal functions (prefixed with `_`) do not belong to the module file; they
live in the `helpers/` directory:
- `helpers/common.sh`: helpers used by several modules (`_getopt_split`,
`_trap_restore`, `_is_uint`, `_profile_load_helpers`). It is loaded by
`profile.sh` before any module. Put a helper there as soon as a second module
needs it, instead of copying it.
- `helpers/<module name>.sh`: helpers private to `profile.d/<module name>.sh`
(for instance `helpers/rain.sh` for `profile.d/rain.sh`). The file is
optional; the module loads it with the two lines below, which also load
`common.sh` when the module is sourced alone:
```bash
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
_profile_load_helpers "${BASH_SOURCE[0]}"
```
Helper files use the same copyright block as the modules and are only
definitions (no side effect besides the variables the helpers need).
Document each helper with a comment (purpose, `Usage:` line).
--- ---
## 7. Configuration keys ## 7. Configuration keys
@@ -160,10 +202,12 @@ See the existing themes and `README.md §4.4` for the allowed syntax.
## 9. Running shellcheck ## 9. Running shellcheck
```bash ```bash
shellcheck -x profile.sh profile.d/*.sh shellcheck profile.sh profile.d/*.sh helpers/*.sh profile.d/bash-completion/*.sh
``` ```
All warnings must be resolved before a patch will be accepted. Accepted The `.shellcheckrc` at the root of the project makes shellcheck follow the
sourced files (`external-sources`, `source-path=SCRIPTDIR`). The run must report
nothing: all warnings must be resolved before a patch will be accepted. Accepted
suppression directives (`# shellcheck disable=SCxxxx`) require an inline suppression directives (`# shellcheck disable=SCxxxx`) require an inline
comment explaining why the suppression is necessary. comment explaining why the suppression is necessary.
@@ -172,20 +216,25 @@ comment explaining why the suppression is necessary.
## 10. Submitting a contribution ## 10. Submitting a contribution
### Via Git (preferred) ### Via Git (preferred)
1. Contact the maintainer to obtain push access, or fork on the Gitea instance. 1. Contact the maintainer to obtain push access, or fork on the Gitea instance.
2. Create a branch: `git checkout -b feature/my-feature`. 2. Create a branch: `git checkout -b feature/my-feature`.
3. Commit with a clear subject line: `module: short description (≤ 72 chars)`. 3. Commit with a clear subject line: `module: short description (≤ 72 chars)`.
4. Push and open a pull request against `master`. 4. Push and open a pull request against `master`.
### Via patch ### Via patch
If you do not have push access: If you do not have push access:
```bash ```bash
git format-patch origin/master git format-patch origin/master
``` ```
Send the resulting `.patch` file(s) to Send the resulting `.patch` file(s) to
`fatalerrors <at> geoffray-levasseur <dot> org`. `fatalerrors <at> geoffray-levasseur <dot> org`.
### Commit message format ### Commit message format
``` ```
module: imperative short description module: imperative short description
@@ -197,7 +246,7 @@ Reference issue numbers if applicable: closes #42.
## 11. What will be rejected ## 11. What will be rejected
- Code requiring packages not in a minimal Debian or CentOS install. - Code requiring packages not in a minimal Debian or CentOS install, unless optionnal.
- Use of `eval`, `source`-based config loading, or other code-injection vectors. - Use of `eval`, `source`-based config loading, or other code-injection vectors.
- Changes that break Bash 4.3 compatibility. - Changes that break Bash 4.3 compatibility.
- Patches without a passing `shellcheck` run. - Patches without a passing `shellcheck` run.
Executable → Regular
+228 -5
View File
@@ -4,15 +4,50 @@
## Installation & loading ## Installation & loading
**Q: How do I install profile automatically into my shell startup files?**
Run the installer directly (no need to source first):
```bash
bash <installpath>/profile/profile.sh --install
```
This appends the required `source` line to both `~/.bashrc` and `~/.profile`.
To target only one file:
```bash
bash <installpath>/profile/profile.sh --install --bashrc
bash <installpath>/profile/profile.sh --install --profile
```
The operation is idempotent — running it again will not add a duplicate line.
---
**Q: I ran `profile.sh` directly and got a warning about sourcing.**
profile.sh is designed to be *sourced*, not executed:
```bash
source <installpath>/profile/profile.sh
```
The only exception is `--install`, which must be passed to a direct execution
(`bash profile.sh --install`) to set up the sourcing line automatically.
---
**Q: profile refuses to load and prints "This profile requires Bash 4.3 or higher."** **Q: profile refuses to load and prints "This profile requires Bash 4.3 or higher."**
Your system's default shell is an older Bash (common on macOS, which ships Your system's default shell is an older Bash (common on macOS, which ships
Bash 3.x for licensing reasons). Install a newer Bash: Bash 3.x for licensing reasons). Install a newer Bash:
```bash ```bash
# macOS # macOS
brew install bash brew install bash
# then add /opt/homebrew/bin/bash to /etc/shells and chsh # then add /opt/homebrew/bin/bash to /etc/shells and chsh
``` ```
Or point your terminal emulator at the newer binary explicitly. Or point your terminal emulator at the newer binary explicitly.
--- ---
@@ -25,13 +60,46 @@ scripts start with `#!/usr/bin/env bash`.
--- ---
**Q: Can I use profile functions in scripts?**
Yes. The supported way is to source `profile.sh` from a Bash script:
```bash
#!/usr/bin/env bash
source /path/to/profile/profile.sh
taz -p auto -f lz mydir
```
You can also source one module directly (for example
`profile.d/compress.sh`) if you only need a subset of functions. The module
loads the helpers it needs from the `helpers/` directory, which must stay next
to `profile.d/`.
When you source a module directly, profile configuration parsing/loading from
`profile.sh` is skipped, so defaults from `profile.conf` are not applied unless
your script loads them explicitly.
`profile.sh` also detects whether the current shell is interactive. In
non-interactive shells (typical script execution), interactive-only features
are intentionally disabled: prompt setup, aliases, welcome/info messages, and
startup update checks are not enabled.
In all cases, avoid aliases in scripts. Use real commands/functions instead,
because alias expansion is interactive-shell oriented and can be disabled or
behave differently in non-interactive execution.
---
**Q: I set `PROFILE_PATH` but profile still can't find its modules.** **Q: I set `PROFILE_PATH` but profile still can't find its modules.**
`PROFILE_PATH` must be exported *before* you source `profile.sh`: `PROFILE_PATH` must be exported *before* you source `profile.sh`:
```bash ```bash
export PROFILE_PATH=/opt/profile export PROFILE_PATH=/opt/profile
source /opt/profile/profile.sh source /opt/profile/profile.sh
``` ```
If set after sourcing, `MYPATH` is already locked in and the variable has If set after sourcing, `MYPATH` is already locked in and the variable has
no effect. no effect.
@@ -65,6 +133,7 @@ See also `README.md §4.2` for a consolidated table.
**Q: A key I set in `profile.conf` is being ignored.** **Q: A key I set in `profile.conf` is being ignored.**
Check that: Check that:
1. The key is inside the correct `[section]` header. 1. The key is inside the correct `[section]` header.
2. There is no leading space before the section name (`[section]` not 2. There is no leading space before the section name (`[section]` not
`[ section ]`). `[ section ]`).
@@ -74,17 +143,43 @@ Check that:
--- ---
**Q: How do I force or auto-detect the correct `TERM` value?**
Set `TERM` in the `[system]` section of `profile.conf`:
```ini
[system]
# Auto-detect best available terminfo entry at startup (default)
#TERM=smart
# Force a specific entry
#TERM=xterm-256color
```
When `TERM` is absent or set to `smart`, `term_set` probes your terminal
emulator's environment variables (`COLORTERM`, `TERM_PROGRAM`, `VTE_VERSION`,
`WT_SESSION`, `TMUX`, `STY`) and then tests terminfo entries in preference
order (multiplexers first). If you are experiencing display issues, run
`TERM=smart; term_set` interactively (`term_set` leaves an already-set `TERM`
untouched) and check the result with `echo $TERM`. See *Prompt & theming* below for
symptoms caused by a wrong `TERM` value.
---
## Prompt & theming ## Prompt & theming
**Q: How do I change the prompt theme?** **Q: How do I change the prompt theme?**
Add to `profile.conf`: Add to `profile.conf`:
```ini ```ini
[prompt] [prompt]
PROMPT_THEME = dark PROMPT_THEME = dark
``` ```
Built-in names: `default`, `dark`, `light`, `solarized`, `solarized-light`, Built-in names: `default`, `dark`, `light`, `solarized`, `solarized-light`,
`monokai`, `monochrome`, `abyss`, `plasma`, `adwaita`. `monokai`, `monochrome`, `abyss`, `plasma`, `adwaita`, but you can create your
own theme.
--- ---
@@ -92,16 +187,45 @@ Built-in names: `default`, `dark`, `light`, `solarized`, `solarized-light`,
Those themes use 24-bit / true-colour ANSI sequences (`\e[38;2;R;G;Bm`). Those themes use 24-bit / true-colour ANSI sequences (`\e[38;2;R;G;Bm`).
Test your terminal: Test your terminal:
```bash ```bash
printf '\e[38;2;38;139;210mTrue colour test\e[0m\n' printf '\e[38;2;38;139;210mTrue colour test\e[0m\n'
``` ```
If you see a solid blue word your terminal supports true colour. If you see a solid blue word your terminal supports true colour.
If you see garbage or plain text, switch to a 16-colour theme If you see garbage or plain text, switch to a 16-colour theme
(`dark`, `default`, etc.) or upgrade your terminal emulator. (`dark`, `default`, etc.) or upgrade your terminal emulator.
--- ---
**Q: I created a custom theme but `load_theme` emits "key not allowed" warnings.** **Q: My prompt colours are missing, wrong, or ANSI escape codes appear as raw text.**
The most common cause is a `TERM` value that does not match what your terminal
emulator actually supports — either it points to an entry that does not exist
in the local `terminfo` database, or it under-describes the real capabilities
(e.g. `TERM=xterm` when the emulator supports 256 colours).
Diagnose with:
```bash
echo "TERM=$TERM"
tput colors # should print 256 (or higher) for a capable terminal
infocmp | head -5 # verify the loaded terminfo entry looks sane
```
Common scenarios and fixes:
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| `tput: unknown terminal` | `TERM` value has no terminfo entry | Remove the forced value and let `TERM=smart` auto-detect |
| Only 8 colours instead of 256 | `TERM=xterm` instead of `xterm-256color` | Set `TERM=smart` or force `xterm-256color` |
| Colours correct in plain shell but wrong inside tmux | tmux overwrites `TERM` | Set `TERM=smart` — `term_set` picks `tmux-256color` |
| Colours correct outside screen but wrong inside it | Same, for GNU screen | Set `TERM=smart` — `term_set` picks `screen-256color` |
| Prompt renders correctly but `rain` / `matrix` shows garbage | Terminal doesn't support the ANSI codes implied by `TERM` | Match `TERM` to the real emulator capability |
The recommended approach is to leave `TERM` unset (or set it to `smart`) in
`profile.conf` and let `term_set` choose the best available entry automatically.
Only force a specific value if auto-detection picks the wrong one.
Theme files are parsed, not executed. Only `PROMPT_COLOR_*` keys and the Theme files are parsed, not executed. Only `PROMPT_COLOR_*` keys and the
standard colour variable names from `disp.sh` (`Black`, `Blue`, `On_IBlack`, standard colour variable names from `disp.sh` (`Black`, `Blue`, `On_IBlack`,
@@ -119,12 +243,66 @@ theme file cannot execute code. Values must be a colour variable reference
--- ---
## Git helpers
**Q: What git helper functions does profile provide?**
All git helpers are defined in `profile.d/git.sh` (new in 4.1.0):
| Command | Purpose |
| --- | --- |
| `gst` | Compact status with branch tracking info |
| `ggraph` | Decorated history graph |
| `gsync` | Fetch and rebase onto upstream |
| `gacp` | Add, commit and push in one command |
| `greset` | Reset to upstream, stashing local changes first |
| `gwip` | Quick WIP checkpoint commit |
| `gprune` | Delete merged local branches |
| `groot` | Print or cd to repository root |
All commands accept `-h` / `--help`.
---
**Q: Tab completion for `gacp` does not show modified files.**
Profile ships dedicated completions in `profile.d/bash-completion/git-completion.sh`,
loaded automatically in interactive sessions. If completions are missing,
check that the system git completion is installed:
```bash
# Debian / Ubuntu
apt-get install bash-completion
# Fedora / RHEL
dnf install bash-completion
```
When the native git completion helpers are available, `gacp` path completion
behaves exactly like `git add` (modified files, untracked files, directories).
---
**Q: `gacp` says "No files specified" even though I passed `-a`.**
The `-a` / `--auto` flag adds all modified files (equivalent to `git add -A`).
The default depends on `GIT_GACP_AUTO_ADD` in `profile.conf`:
```ini
[git]
GIT_GACP_AUTO_ADD = 1
```
Set to `1` to make `-a` the default.
---
## Functions ## Functions
**Q: `meteo` prints "No city specified" even though I set a default.** **Q: `meteo` prints "No city specified" even though I set a default.**
The key is `METEO_DEFAULT_CITY` (not `DEFAULT_CITY`), and it must be in the The key is `METEO_DEFAULT_CITY` (not `DEFAULT_CITY`), and it must be in the
`[info]` section: `[info]` section:
```ini ```ini
[info] [info]
METEO_DEFAULT_CITY = Paris METEO_DEFAULT_CITY = Paris
@@ -136,6 +314,7 @@ METEO_DEFAULT_CITY = Paris
`dwl` requires one of `curl`, `wget`, or `fetch` to be installed. `dwl` requires one of `curl`, `wget`, or `fetch` to be installed.
Install curl: Install curl:
```bash ```bash
# Debian / Ubuntu # Debian / Ubuntu
apt-get install curl apt-get install curl
@@ -143,15 +322,55 @@ apt-get install curl
# Fedora / RHEL # Fedora / RHEL
dnf install curl dnf install curl
``` ```
Or set `DWL_PREFERRED_TOOL` in `[net]` to whichever tool you have. Or set `DWL_PREFERRED_TOOL` in `[net]` to whichever tool you have.
--- ---
**Q: How do I limit how long `dwl` waits for a download?**
Use the `-t` / `--timeout` option:
```bash
dwl -t 5 https://example.com/file.txt /tmp/file.txt
```
This sets a 5-second cap on both the connection and the overall transfer.
The timeout is propagated to `curl` (`--max-time` + `--connect-timeout`),
`wget` (`--timeout`), or `fetch` (`-T`) transparently.
---
**Q: The prompt takes a long time to appear when my network is unavailable.**
Fixed in 4.1.0. `check_updates -q` (called at startup) now enforces a
3-second network timeout. If the update server is unreachable the check
fails silently and the prompt appears immediately.
---
**Q: `help` only shows a list of functions. Can I get usage for a specific one?**
Yes — pass the command name as an argument:
```bash
help gacp
help dwl
help taz
```
This calls `<command> --help` and prints the full usage for that function.
---
**Q: `pkgs` does not find packages I know are installed.** **Q: `pkgs` does not find packages I know are installed.**
`pkgs` delegates to `dpkg -l` (Debian/Ubuntu) or `rpm -qa` (RHEL/Fedora). `pkgs` uses `get_pkgmgr` to detect the active package manager and delegates
If your distribution uses a different package manager (pacman, apk, brew …) to the appropriate tool. Supported families: `apt` (Debian/Ubuntu),
it is not yet supported. See `doc/todo.md` for the tracking issue. `dnf` / `yum` (RHEL/Fedora), `zypper` (openSUSE), `pacman` (Arch),
`apk` (Alpine), `portage` (Gentoo), `xbps` (Void), `nix`, `brew` (macOS).
If your distribution is not detected, run `get_pkgmgr` to see what is
identified, and check that the package manager binary is in your `PATH`.
--- ---
@@ -160,9 +379,11 @@ it is not yet supported. See `doc/todo.md` for the tracking issue.
`check_updates` compares the content of the remote `version` file against `check_updates` compares the content of the remote `version` file against
`$PROFVERSION`. If `UPDT_DEFAULT_BRANCH` in `[updates]` points to a different `$PROFVERSION`. If `UPDT_DEFAULT_BRANCH` in `[updates]` points to a different
branch than your installation, the version files may not match. Check: branch than your installation, the version files may not match. Check:
```bash ```bash
cat "$MYPATH/version" cat "$MYPATH/version"
``` ```
and make sure `UPDT_DEFAULT_BRANCH` matches the branch you track. and make sure `UPDT_DEFAULT_BRANCH` matches the branch you track.
--- ---
@@ -193,6 +414,7 @@ has not been implemented yet.
```bash ```bash
PROFILE_DISABLED=1 bash --norc PROFILE_DISABLED=1 bash --norc
``` ```
Or simply open a shell without sourcing `~/.bashrc` (`bash --norc`). Or simply open a shell without sourcing `~/.bashrc` (`bash --norc`).
--- ---
@@ -202,6 +424,7 @@ Or simply open a shell without sourcing `~/.bashrc` (`bash --norc`).
Open an issue on the Open an issue on the
[Gitea tracker](https://git.geoffray-levasseur.org/fatalerrors/profile/issues) [Gitea tracker](https://git.geoffray-levasseur.org/fatalerrors/profile/issues)
or send a mail to `fatalerrors <at> geoffray-levasseur <dot> org` with: or send a mail to `fatalerrors <at> geoffray-levasseur <dot> org` with:
- The exact command that triggered the bug - The exact command that triggered the bug
- Your OS and Bash version (`bash --version`) - Your OS and Bash version (`bash --version`)
- The module involved - The module involved
Executable → Regular
View File
+55
View File
@@ -0,0 +1,55 @@
# Known bugs
This document tracks currently known issues and limitations.
## Open issues
- None :-)
---
## Won't fix
These issues are caused by platform or environment limitations outside the scope of this
project and will not be addressed in Bash.
### Prompt execution time is inaccurate in Windows Terminal (WSL)
- **Description:** In Windows Terminal the displayed duration includes idle and typing time,
and is consistently higher than actual command execution time. In a native Linux terminal
(including WSL shells inside Konsole, QTerminal, etc.) timing correctly starts on Enter and
stops when the prompt reappears; in Windows Terminal, timer events appear tied to prompt
display rather than to the Enter keypress.
- **Cause:** Execution time is measured via a `DEBUG` trap and `PROMPT_COMMAND` using
`$EPOCHREALTIME` (or `date +%s%N` on bash < 5) deltas. WSL + Windows Terminal introduces scheduling jitter between Bash signal
events and the underlying Windows terminal layer that does not match wall-clock perception.
- **Impact:** Cosmetic / observability only — commands execute normally.
- **Status:** Not fixable in Bash; this is a limitation of the Windows Terminal / WSL
integration layer.
- **Workarounds:**
- Use a native Linux terminal under WSL (Konsole, QTerminal, Terminator, etc.) to
recover the expected Enter→prompt timing behavior.
- Use `/usr/bin/time -p <command>` or the shell built-in `time` when accurate timing
is required.
- Treat prompt timing as an inacurate indicator in this environment.
### Rain/Matrix rendering is slow on Windows
- **Description:** The rain, matrix and rainbow terminal effects are significantly slower
on Windows, especially with high density settings on every terminal software.
- **Cause:** This is due to the way Windows handles terminal display updates, which is
inherently less efficient than on Unix-like systems.
- **Status:** Not fixable in Bash; this is a limitation of Windows terminal design.
- **Workaround:** Lower the density parameter for better performance, or use a Unix-like
environment for optimal speed.
### The prompt timer replaces an existing `DEBUG` trap
- **Description:** The execution time shown in the prompt is measured with a `DEBUG` trap
installed when the profile is loaded. A `DEBUG` trap set earlier (bash-preexec and the
tools built on it) is replaced.
- **Cause:** bash hides the `DEBUG` trap from code run by `source`, so the profile cannot
read it to chain to it.
- **Workaround:** Load the profile before such tools. `PROMPT_COMMAND`, on the other hand,
is preserved: `set_prompt` is added to it.
Executable → Regular
+93 -9
View File
@@ -21,8 +21,10 @@ HISTIGNORE="&:[bf]g:exit"
# Default pager # Default pager
PAGER=less PAGER=less
# Terminal colour capability # Terminal type.
TERM=xterm-256color # smart — auto-detect the best available capability at startup (default)
# <specific> — force a specific terminfo entry, e.g. xterm-256color or vt100
#TERM=smart
# ============================================================================== # ==============================================================================
[compress] [compress]
@@ -30,8 +32,10 @@ TERM=xterm-256color
# Supported: lz (default), xz, bz2, gz, lzo, tar, zip, zst # Supported: lz (default), xz, bz2, gz, lzo, tar, zip, zst
#TAZ_DEFAULT_FORMAT=lz #TAZ_DEFAULT_FORMAT=lz
# taz: Number of compression threads (0 = auto-detect CPU count). # taz: Number of compression threads.
#TAZ_DEFAULT_THREADS=0 # auto — detect CPU count at runtime (default)
# N — explicit positive integer
#TAZ_DEFAULT_THREADS=auto
# taz: Compression level 1 (fast/large) … 9 (slow/small). # taz: Compression level 1 (fast/large) … 9 (slow/small).
#TAZ_DEFAULT_LEVEL=6 #TAZ_DEFAULT_LEVEL=6
@@ -45,13 +49,23 @@ TERM=xterm-256color
# never — always flatten into the current directory # never — always flatten into the current directory
#UTAZ_DEFAULT_DIR_MODE=auto #UTAZ_DEFAULT_DIR_MODE=auto
# utaz: Match archive extensions case-insensitively, so that FOO.ZIP or
# bar.Tar.GZ are recognised (0=no, 1=yes). Overridden per call with
# -i/--ignore-case or -s/--case-sensitive.
#UTAZ_DEFAULT_IGNORE_CASE=0
# ============================================================================== # ==============================================================================
[debug] [debug]
# (no configurable keys yet) # Activate the error trace (settrace --on) at startup of interactive shells:
# every failing command then prints its exit status and a call stack to stderr.
# Verbose; meant for debugging scripts and the profile itself (0=no, 1=yes).
# Can be toggled at any time with settrace --on / --off.
#SETTRACE_DEFAULT_ENABLED=0
# ============================================================================== # ==============================================================================
[disp] [disp]
# Uncomment to disable ANSI colours in profile's own output messages. # Uncomment to disable ANSI colours in profile's own output messages. For the
# prompt use a non-colored theme in [prompt] section of that file.
#NO_COLOR=1 #NO_COLOR=1
# ============================================================================== # ==============================================================================
@@ -76,6 +90,36 @@ TERM=xterm-256color
# busy: Delay between matched lines in milliseconds (0 = no delay). # busy: Delay between matched lines in milliseconds (0 = no delay).
#BUSY_DEFAULT_DELAY=0 #BUSY_DEFAULT_DELAY=0
# fake_compile: Minimum delay between output lines in milliseconds.
#FAKE_COMPILE_DEFAULT_MIN_DELAY=40
# fake_compile: Maximum delay between output lines in milliseconds.
#FAKE_COMPILE_DEFAULT_MAX_DELAY=150
# fake_compile: Default language preset (c, cpp, java, python, random).
#FAKE_COMPILE_DEFAULT_LANG=c
# hack: Minimum delay between output lines in milliseconds.
#HACK_DEFAULT_MIN_DELAY=60
# hack: Maximum delay between output lines in milliseconds.
#HACK_DEFAULT_MAX_DELAY=250
# ==============================================================================
[git]
# Fallback main branch name used when remote HEAD cannot be detected.
#GIT_MAIN_BRANCH=main
# Default remote used by git helper functions.
#GIT_DEFAULT_REMOTE=origin
# Prefix used by gwip when generating automatic checkpoint messages.
#GIT_WIP_PREFIX=wip
# gacp: Automatically add all modified files (git add -A) when no explicit file
# list is provided. Set to 0 to require explicit file paths or the -a flag.
#GIT_GACP_AUTO_ADD=1
# ============================================================================== # ==============================================================================
[info] [info]
# meteo: Default city when no argument is given. Leave unset to require an # meteo: Default city when no argument is given. Leave unset to require an
@@ -100,9 +144,15 @@ TERM=xterm-256color
# Unset = auto-detect (curl preferred, then wget, then fetch). # Unset = auto-detect (curl preferred, then wget, then fetch).
#DWL_PREFERRED_TOOL=curl #DWL_PREFERRED_TOOL=curl
# myextip: API endpoint for external IP lookup. # dwl: Enable resume mode by default (supports curl/wget file downloads).
# Alternatives: https://ipinfo.io/json, https://ip-api.com/json/ # Values accepted: 1/0, true/false, yes/no, on/off.
#MYEXTIP_DEFAULT_URL=https://ip-api.com/json/ #DWL_DEFAULT_RESUME=0
# myextip: HTTPS JSON API used for the external IP lookup (default below).
# Also supported: https://ipwho.is/ (more detailed: ISP and AS kept apart,
# full country name). ip-api.com is not recommended: its free tier is HTTP only
# and https://ip-api.com/ answers 403.
#MYEXTIP_DEFAULT_URL=https://ipinfo.io/json
# ============================================================================== # ==============================================================================
[packages] [packages]
@@ -154,6 +204,28 @@ TERM=xterm-256color
# #
# Working directory # Working directory
#PROMPT_COLOR_DIR_FG=$ICyan #PROMPT_COLOR_DIR_FG=$ICyan
#
# Context segment (Git branch / Conda environment) on the top bar
#PROMPT_COLOR_CTX_FG=$BIYellow
#
# Show Git branch at the end of the top bar when inside a repository.
#PROMPT_SHOW_GIT=1
#
# Include Git dirty marker and upstream drift (+ahead/-behind) in the context.
#PROMPT_SHOW_GIT_STATUS=1
#
# Timeout in seconds for git diff and git rev-list operations.
# If exceeded, the prompt displays git:<branch>? instead of full status.
#PROMPT_GIT_TIMEOUT=2
#
# Show Conda environment name at the end of the top bar when active.
#PROMPT_SHOW_CONDA=1
#
# Show Python venv name when active (ignored if Conda is active).
#PROMPT_SHOW_VENV=1
#
# Show session markers (ssh, tmux, screen) when applicable.
#PROMPT_SHOW_SESSION=1
# ============================================================================== # ==============================================================================
[pwd] [pwd]
@@ -190,12 +262,24 @@ TERM=xterm-256color
# rain: Colour theme. Supported: white (default), green, blue, red, yellow, cyan # rain: Colour theme. Supported: white (default), green, blue, red, yellow, cyan
#RAIN_DEFAULT_COLOR=white #RAIN_DEFAULT_COLOR=white
# rain: Maximum number of simultaneous falling elements.
# Leave unset to keep the terminal-size-based dynamic default.
#RAIN_DEFAULT_DENSITY=80
# rainbow: Horizontal color shift speed — integer/100 gives seconds (4 → 0.04 s).
# Values < 1 are used as raw seconds.
#RAINBOW_DEFAULT_SPEED=4
# matrix: Falling speed. # matrix: Falling speed.
#MATRIX_DEFAULT_SPEED=3.5 #MATRIX_DEFAULT_SPEED=3.5
# matrix: Colour theme. Supported: green (default), blue, red, yellow, cyan, white # matrix: Colour theme. Supported: green (default), blue, red, yellow, cyan, white
#MATRIX_DEFAULT_COLOR=green #MATRIX_DEFAULT_COLOR=green
# matrix: Maximum number of simultaneous falling elements.
# Leave unset to keep the terminal-size-based dynamic default.
#MATRIX_DEFAULT_DENSITY=120
# matrix: Character set. Supported: binary (default), kana, ascii # matrix: Character set. Supported: binary (default), kana, ascii
#MATRIX_DEFAULT_CHARSET=binary #MATRIX_DEFAULT_CHARSET=binary
Executable → Regular
+27 -5
View File
@@ -36,8 +36,18 @@ TERM=xterm-256color
# never — always flatten extraction into the current directory # never — always flatten extraction into the current directory
#UTAZ_DEFAULT_DIR_MODE=auto #UTAZ_DEFAULT_DIR_MODE=auto
# utaz: Match archive extensions case-insensitively, so that FOO.ZIP or
# bar.Tar.GZ are recognised (0=no, 1=yes). Overridden per call with
# -i/--ignore-case or -s/--case-sensitive.
#UTAZ_DEFAULT_IGNORE_CASE=0
[debug] [debug]
# Section used by debug.sh (nothing yet) # Section used by debug.sh
# Activate the error trace (settrace --on) at startup of interactive shells:
# every failing command then prints its exit status and a call stack to stderr
# (0=no, 1=yes).
#SETTRACE_DEFAULT_ENABLED=0
[disp] [disp]
# Section used by disp.sh # Section used by disp.sh
@@ -91,10 +101,14 @@ SET_LOCALE="fr:fr_FR.UTF-8,us:en_US.UTF-8"
# Supported values: curl, wget, fetch. Unset uses auto-detection (default). # Supported values: curl, wget, fetch. Unset uses auto-detection (default).
#DWL_PREFERRED_TOOL=curl #DWL_PREFERRED_TOOL=curl
# dwl: Enable resume mode by default (supports curl/wget file downloads).
# Accepted values: 1/0, true/false, yes/no, on/off.
#DWL_DEFAULT_RESUME=0
# myextip: API endpoint URL used to retrieve external IP information. # myextip: API endpoint URL used to retrieve external IP information.
# Default: https://ip-api.com/json/ # Default: https://ipinfo.io/json
# Compatible alternatives: https://ipinfo.io/json, https://ip-api.com/json/ # Compatible alternative: https://ipwho.is/ (ip-api.com is HTTP only on its free tier).
#MYEXTIP_DEFAULT_URL=https://ip-api.com/json/ #MYEXTIP_DEFAULT_URL=https://ipinfo.io/json
[packages] [packages]
# Section used by packages.sh # Section used by packages.sh
@@ -191,6 +205,10 @@ SET_LOCALE="fr:fr_FR.UTF-8,us:en_US.UTF-8"
# Supported values: white (default), green, blue, red, yellow, cyan # Supported values: white (default), green, blue, red, yellow, cyan
#RAIN_DEFAULT_COLOR=white #RAIN_DEFAULT_COLOR=white
# rain: Maximum number of simultaneous falling elements.
# Leave unset to keep the terminal-size-based dynamic default.
#RAIN_DEFAULT_DENSITY=80
# matrix: Default speed value, using the /100 scale (3.5 => 0.035s). # matrix: Default speed value, using the /100 scale (3.5 => 0.035s).
#MATRIX_DEFAULT_SPEED=3.5 #MATRIX_DEFAULT_SPEED=3.5
@@ -198,6 +216,10 @@ SET_LOCALE="fr:fr_FR.UTF-8,us:en_US.UTF-8"
# Supported values: green (default), blue, red, yellow, cyan, white # Supported values: green (default), blue, red, yellow, cyan, white
#MATRIX_DEFAULT_COLOR=green #MATRIX_DEFAULT_COLOR=green
# matrix: Maximum number of simultaneous falling elements.
# Leave unset to keep the terminal-size-based dynamic default.
#MATRIX_DEFAULT_DENSITY=120
# matrix: Default character set. # matrix: Default character set.
# Supported values: binary (default), kana, ascii # Supported values: binary (default), kana, ascii
MATRIX_DEFAULT_CHARSET=kana MATRIX_DEFAULT_CHARSET=kana
@@ -260,7 +282,7 @@ mkdin='make DESTDIR=$PWD/dest-install install'
ssh='ssh -Y' ssh='ssh -Y'
# Resume mode for wget # Resume mode for wget
wget='wget -c' # resume mode by default wget='wget -c'
# Human readable by default # Human readable by default
df='df -H' df='df -H'
Executable → Regular
+75 -15
View File
@@ -12,7 +12,7 @@ version-bump.
blockers are `local -A` (no associative arrays in ZSH without `typeset -A`) blockers are `local -A` (no associative arrays in ZSH without `typeset -A`)
and `local -n` namerefs. A thin compatibility shim would open the project to and `local -n` namerefs. A thin compatibility shim would open the project to
ZSH users. **[hard]** ZSH users. **[hard]**
- [ ] **Bash completion** — add a `profile.d/completion/` directory and write - [ ] **Bash completion** — add a more bash completion directory and write
`_profile_upgrade`, `_taz`, `_utaz`, `_meteo`, etc. completions so that `_profile_upgrade`, `_taz`, `_utaz`, `_meteo`, etc. completions so that
`<Tab>` works on all public functions. **[medium]** `<Tab>` works on all public functions. **[medium]**
@@ -20,18 +20,21 @@ version-bump.
## Prompt & theming ## Prompt & theming
- [ ] **Git branch in prompt** — show the current branch name (and dirty - [x] **Git branch in prompt** — show the current branch name (with dirty and
indicator) in the PS1 bar when inside a Git repository. Should be upstream drift indicators) in the PS1 bar when inside a Git repository,
gated behind a `[prompt]` config key so it can be disabled. **[medium]** gated by `[prompt]` config keys. **[medium]**
- [ ] **Virtual-env / conda indicator** — detect `$VIRTUAL_ENV` / `$CONDA_DEFAULT_ENV` - [x] **Virtual-env / conda indicator** — detect `$VIRTUAL_ENV` / `$CONDA_DEFAULT_ENV`
and display the name in the prompt bar. **[easy]** and display the active environment in the prompt bar. **[easy]**
- [ ] **True-colour terminal auto-detection** — query `$COLORTERM` and - [x] **Session context markers** — display lightweight session markers
(`ssh`, `tmux`, `screen`) at the end of the prompt bar, gated by
`[prompt]` config keys. **[easy]**
- [x] **True-colour terminal auto-detection** — query `$COLORTERM` and
`$TERM` at load time; automatically fall back from a 24-bit theme to its `$TERM` at load time; automatically fall back from a 24-bit theme to its
16-colour equivalent when the terminal does not support true colour. **[medium]** 16-colour equivalent when the terminal does not support true colour. **[medium]**
- [ ] **True-colour variants of other themes** — create `monokai-tc.theme`, - [ ] **True-colour variants of other themes** — create `monokai-tc.theme`,
`abyss-tc.theme`, etc. using the same `\e[38;2;R;G;Bm` approach as the `abyss-tc.theme`, etc. using the same `\e[38;2;R;G;Bm` approach as the
Solarized themes. **[easy]** _(per theme)_ Solarized themes. **[easy]** _(per theme)_
- [ ] **Theme preview command** — add a `theme_preview` (or `profile_theme`) - [X] **Theme preview command** — add a `theme_preview` (or `profile_theme`)
function that renders a colour swatch and a sample prompt line for the function that renders a colour swatch and a sample prompt line for the
currently loaded theme, so users can evaluate themes without reloading currently loaded theme, so users can evaluate themes without reloading
the session. **[medium]** the session. **[medium]**
@@ -44,46 +47,54 @@ version-bump.
## Module improvements ## Module improvements
### compress ### compress
- [ ] **`taz` progress bar** — show a `pv` / `dd`-based progress indicator when - [ ] **`taz` progress bar** — show a `pv` / `dd`-based progress indicator when
compressing large trees, gated behind a `-p` flag. **[medium]** compressing large trees, gated behind a `-p` flag. **[medium]**
- [ ] **`utaz` integrity check** — run `tar -tOf` / `unzip -t` / `7z t` before - [ ] **`utaz` integrity check** — run `tar -tOf` / `unzip -t` / `7z t` before
extracting and abort if the archive is corrupt. **[easy]** extracting and abort if the archive is corrupt. **[easy]**
### filefct ### filefct
- [ ] **`findbig` / `findzero` / `finddead` — `fd` integration** — optionally
- [x] **`findbig` / `findzero` / `finddead` — `fd` integration** — optionally
use `fd` instead of `find` when available for faster traversal. **[easy]** use `fd` instead of `find` when available for faster traversal. **[easy]**
- [ ] **`file_stats` — human-readable totals** — add `--human` flag to emit - [x] **`file_stats` — human-readable totals** — add `--human` flag to emit
sizes in K/M/G instead of bytes. **[easy]** sizes in K/M/G instead of bytes. **[easy]**
### info ### info
- [ ] **`showinfo` fallback** — when neither `neofetch` nor `fastfetch` is
- [X] **`showinfo` fallback** — when neither `neofetch` nor `fastfetch` is
installed, print a minimal sysinfo block (hostname, OS, kernel, uptime, installed, print a minimal sysinfo block (hostname, OS, kernel, uptime,
CPU, RAM) using pure Bash + `/proc`. **[medium]** CPU, RAM) using pure Bash + `/proc`. **[medium]**
### net ### net
- [ ] **`dwl` resume support** — pass `-C -` to curl / `--continue-at -` to
- [X] **`dwl` resume support** — pass `-C -` to curl / `--continue-at -` to
wget for interrupted downloads; gate behind a `-r` flag. **[easy]** wget for interrupted downloads; gate behind a `-r` flag. **[easy]**
- [ ] **`myextip` multiple providers** — fall back to a secondary URL - [ ] **`myextip` multiple providers** — fall back to a secondary URL
(configurable via `MYEXTIP_FALLBACK_URL`) when the primary times out. (configurable via `MYEXTIP_FALLBACK_URL`) when the primary times out.
**[easy]** **[easy]**
### processes ### processes
- [ ] **`ku` dry-run flag** — add `-n` / `--dry-run` to print what would be
- [X] **`ku` dry-run flag** — add `-n` / `--dry-run` to print what would be
killed without acting. **[easy]** killed without acting. **[easy]**
### pwd ### pwd
- [ ] **`genpwd` passphrase mode** — add `-w` / `--words N` to generate - [ ] **`genpwd` passphrase mode** — add `-w` / `--words N` to generate
word-based passphrases (diceware-style) from `/usr/share/dict/words`. word-based passphrases (diceware-style) from `/usr/share/dict/words`.
**[medium]** **[medium]**
### ssh ### ssh
- [ ] **SSH agent management** — add `ssh_agent_start` / `ssh_agent_stop` helpers - [ ] **SSH agent management** — add `ssh_agent_start` / `ssh_agent_stop` helpers
that start a persistent `ssh-agent`, add configured keys, and survive that start a persistent `ssh-agent`, add configured keys, and survive
re-login via a socket stored in `~/.ssh/agent.env`. **[medium]** re-login via a socket stored in `~/.ssh/agent.env`. **[medium]**
- [ ] **`rmhost` glob support** — allow `rmhost '*.example.com'` to remove all - [x] **`rmhost` glob support** — allow `rmhost '*.example.com'` to remove all
matching entries in one call. **[easy]** matching entries in one call. **[easy]**
### updates ### updates
- [ ] **Automatic update check age** — store a timestamp in `~/.cache/profile_last_check`; - [ ] **Automatic update check age** — store a timestamp in `~/.cache/profile_last_check`;
skip the network request in `check_updates -q` if the last check was less skip the network request in `check_updates -q` if the last check was less
than `UPDT_CHECK_INTERVAL` hours ago (configurable, default 24). **[medium]** than `UPDT_CHECK_INTERVAL` hours ago (configurable, default 24). **[medium]**
@@ -93,6 +104,55 @@ version-bump.
--- ---
## Structure
- [ ] **Helpers must not stay in the environment (do first)** — internal
functions (`_` prefix, `helpers/`) are not part of the user interface, yet 16
of them are `export -f`, so they leak into the environment of every child
process. By design they should only exist where they are needed:
- stop exporting helpers by default; unset the ones only needed at load time
(as `profile.sh` already does for `parse_conf`, `load_alias`, ...);
- a helper stays exported only when an exported public function calls it and
must work in a child bash (`bash -c`, `find -exec`, `xargs`), which has to
be checked and documented one by one (otherwise that public function is no
longer exported either, or waits for the stub mechanism below);
- add a check (shellcheck-like script or `bats` test) listing exported
functions starting with `_`, so none comes back unnoticed.
This is the first step: it does not depend on the lazy loading below, which
builds on it. **[medium]**
- [ ] **Load code on demand (stubs, lazy loading, optional unloading)** —
today every module is sourced at startup (about 63 ms, 109 functions) and 80
functions are exported (about 250 KB of environment inherited by every
external command, which makes each one about 25 % slower and eats `ARG_MAX`).
Replace this by:
- *Stubs*: each lazy command is a tiny generated function
(`rain() { _lazy rain "$@"; }`), the only thing exported. The stubs come
from a command-to-module index, built at upgrade time from a
`# provides: ...` header in each module (so `help`, `type` and completion
still see every command).
- *Lazy loading* (`_try_load`): the first call sources the module and its
`helpers/<module>.sh` (and the modules it depends on, declared in the
header), then runs the real function (`_exec`). The code stays loaded
afterwards. Helpers and implementations are no longer exported, which also
removes them from the environment of child shells.
- *Optional unloading* (`_free`, e.g. `PROFILE_UNLOAD=1`): after the call,
put the stub back instead of unsetting the function. Must be idempotent
(Ctrl-C or `return` skip the cleanup), must not free code still referenced
by a trap (`_rain_stop`, WINCH handler) or used by a running caller
(reference count), and costs about 4 ms per call, so it is not the default.
- *Always resident*: `disp`, `conf`, `debug`, `prompt` (and the `DEBUG` timer
hook), `lang` shortcuts and `help`. With unloading, state kept across calls
(`pwd` pool, current theme, `settrace`) must live in variables that are not
freed. Lazy candidates: `compress`,
`filefct`, `fun`, `git`, `info`, `net`, `packages`, `processes`, `pwd`,
`rain`, `ssh`, `updates`.
- *To solve*: `load_conf`/`CONF_*` must stay available at call time; child
shells need `MYPATH` and the loader exported to resolve stubs; tracebacks
show the loader frames. Do it after the test suite exists, as regressions
would be silent. **[hard]**
---
## Infrastructure ## Infrastructure
- [ ] **Test suite** — add a `test/` directory with `bats` (Bash Automated - [ ] **Test suite** — add a `test/` directory with `bats` (Bash Automated
@@ -107,7 +167,7 @@ version-bump.
- [ ] **`profile_uninstall` function** — remove the `source` line from - [ ] **`profile_uninstall` function** — remove the `source` line from
`~/.bashrc` / `~/.profile` and optionally delete the install directory, `~/.bashrc` / `~/.profile` and optionally delete the install directory,
with a dry-run mode. **[medium]** with a dry-run mode. **[medium]**
- [ ] **`disp` syslog integration** — add a `DISP_SYSLOG=1` config key that - [ ] **`disp` syslog integration** — add a `DISP_SYSLOG=<context>` config key that
additionally pipes E/W messages to `logger`. **[easy]** additionally pipes E/W messages to `logger`. **[easy]**
- [ ] **XDG base-dir support** — honour `$XDG_CONFIG_HOME` as an alternative - [ ] **XDG base-dir support** — honour `$XDG_CONFIG_HOME` as an alternative
location for `profile.conf` so users can keep `~` tidy. **[medium]** location for `profile.conf` so users can keep `~` tidy. **[medium]**
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * 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.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * 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 OWNER 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,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Helpers shared by several modules. This file is loaded by profile.sh before
# any module (and by a module sourced alone); the helpers private to one module
# live in helpers/<module>.sh instead, loaded by the module itself through
# _profile_load_helpers.
#
# Option parsing: the project rule is to never use eval, so _getopt_split and
# _trap_restore rebuild argument lists and traps in pure bash.
# Split the output of getopt into the array whose name is $1, without eval and
# without xargs. getopt prints the normalised arguments as one shell-quoted
# line (options bare, every argument in single quotes, a quote inside an
# argument written '\''), for instance:
# -s 'a b' --long 'it'\''s' -- 'file' ''
# Quoted text is taken verbatim (newlines included), an empty argument ('')
# yields an empty element, and nothing is ever evaluated. The usual call is
# local -a _pargs=()
# _getopt_split _pargs "$PARSED"
# set -- "${_pargs[@]}"
# Usage: _getopt_split <array_name> <getopt_output>
_getopt_split()
{
local -n _gs_out="$1"
local s="$2" tok="" in_tok=0 run
local run_re=$'^[^ \t\n\'\\\\]+'
_gs_out=()
while [[ -n "$s" ]]; do
# shellcheck disable=SC1003 # '\' is a literal backslash
case "${s:0:1}" in
"'")
s="${s:1}"
if [[ "$s" == *\'* ]]; then
tok+="${s%%\'*}"
s="${s#*\'}"
else
# Unterminated quote (never produced by getopt): keep the rest
tok+="$s"
s=""
fi
in_tok=1
;;
'\')
tok+="${s:1:1}"
s="${s:2}"
in_tok=1
;;
' '|$'\t'|$'\n')
(( in_tok )) && _gs_out+=("$tok")
tok=""
in_tok=0
s="${s:1}"
;;
*)
[[ "$s" =~ $run_re ]] || break
run="${BASH_REMATCH[0]}"
tok+="$run"
s="${s:${#run}}"
in_tok=1
;;
esac
done
(( in_tok )) && _gs_out+=("$tok")
return 0
}
export -f _getopt_split
# Put back traps saved with "trap -p" (one "trap -- 'command' SIGNAL" line per
# trap), without eval. Used to restore the caller's traps after temporarily
# replacing them. An empty argument restores nothing.
# Usage: _trap_restore "$(trap -p INT TERM)"
_trap_restore()
{
local line
local -a _tr_w=()
while IFS= read -r line; do
[[ "$line" == "trap -- "* ]] || continue
_getopt_split _tr_w "$line"
# _tr_w = (trap -- <command> <SIGNAL>)
[[ ${#_tr_w[@]} -eq 4 ]] && trap -- "${_tr_w[2]}" "${_tr_w[3]}"
done <<< "$1"
return 0
}
export -f _trap_restore
# True when $1 is a non-negative integer. Every value that ends up in an
# arithmetic context must pass this first (this includes values coming from the
# configuration file): bash evaluates non-numeric strings inside [[ -gt ]] or
# $(( )) as expressions, which can run arbitrary commands.
# Usage: _is_uint <value>
_is_uint()
{
[[ "$1" =~ ^[0-9]+$ ]]
}
export -f _is_uint
# Source the helpers private to a module: helpers/<module name>.sh, found next
# to the directory holding the module (profile.d/../helpers), the name being the
# module file name without ".sh". A module without helpers file is fine.
# Usage: _profile_load_helpers "${BASH_SOURCE[0]}"
_profile_load_helpers()
{
local dir=. name="${1##*/}"
[[ "$1" == */* ]] && dir="${1%/*}"
name="$dir/../helpers/${name%.sh}.sh"
[[ -r "$name" ]] || return 0
# shellcheck source=/dev/null
. "$name" || printf "[ Warning ] Failed to source helpers: %s\n" "$name" >&2
}
export -f _profile_load_helpers
# ------------------------------------------------------------------------------
# EOF
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * 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.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * 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 OWNER 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,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# Private helpers of profile.d/conf.sh, loaded by the module itself.
# Print the path of the configuration file the conf_* functions work on:
# the user file ($HOME/.profile.conf) when it exists, otherwise the installation
# file ($PROFILE_CONF, defaulting to $MYPATH/profile.conf). The returned path
# may not exist yet.
# Usage: _conf_path
_conf_path()
{
if [[ -f "$HOME/.profile.conf" ]]; then
printf '%s\n' "$HOME/.profile.conf"
else
printf '%s\n' "${PROFILE_CONF:-${MYPATH}/profile.conf}"
fi
}
export -f _conf_path
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * 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.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * 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 OWNER 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,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# Private helpers of profile.d/fun.sh, loaded by the module itself.
# Convert a number of milliseconds into a seconds string usable by sleep
# (1500 -> 1.500), in pure bash (no awk fork per call).
# Usage: _fun_ms_to_s <ms>
_fun_ms_to_s()
{
printf '%d.%03d' $(( $1 / 1000 )) $(( $1 % 1000 ))
}
export -f _fun_ms_to_s
# Fail with a message unless a terminal is available: the demos wait for a key
# read from /dev/tty to stop.
# Usage: _fun_need_tty <command-name>
_fun_need_tty()
{
if ! { : </dev/tty; } 2>/dev/null; then
disp E "$1: a terminal is required (it runs until a key is pressed)."
return 1
fi
}
export -f _fun_need_tty
# Wait for a key press, then stop the background job $1 and its children.
# The job runs as "( ... ) &"; with job control (interactive shell) it is the
# leader of its own process group, which is signalled as a whole. Without job
# control that group does not exist, so its direct children (the pipeline) are
# killed first, then the subshell. SIGINT is trapped meanwhile so that Ctrl-C
# stops the job like a key press instead of aborting the caller and leaving it
# running. The wait is a polling loop of short timed reads: a blocking read is
# restarted by bash after a trapped signal and would never notice the Ctrl-C.
# Usage: _fun_stop_on_key <pid>
_fun_stop_on_key()
{
local pid=$1 old_int interrupted=0
old_int=$(trap -p INT)
trap 'interrupted=1' INT
while (( ! interrupted )); do
IFS= read -r -n 1 -s -t 0.3 _ </dev/tty && break
# Status above 128 is just the timeout; anything else (such as end of
# input on the terminal) ends the wait.
(( $? > 128 )) || break
done
kill -- -"$pid" 2>/dev/null || {
pkill -P "$pid" 2>/dev/null
kill "$pid" 2>/dev/null
}
wait "$pid" 2>/dev/null
trap - INT
_trap_restore "$old_int"
return 0
}
export -f _fun_stop_on_key
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * 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.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * 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 OWNER 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,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# Private helpers of profile.d/git.sh, loaded by the module itself.
# Internal helper: ensure git is available and cwd is a git worktree
_git_require_repo()
{
if ! command -v git >/dev/null 2>&1; then
disp E "git command not found."
return 1
fi
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
disp E "Current directory is not inside a git repository."
return 1
fi
return 0
}
export -f _git_require_repo
# Internal helper: return default branch from remote HEAD, fallback to config
_git_default_branch()
{
local remote="${1:-$GIT_DEFAULT_REMOTE}"
local head
head=$(git symbolic-ref --quiet --short "refs/remotes/${remote}/HEAD" 2>/dev/null) || true
if [[ -n $head ]]; then
printf "%s\n" "${head#"${remote}"/}"
return 0
fi
printf "%s\n" "$GIT_MAIN_BRANCH"
return 0
}
export -f _git_default_branch
# Internal helper: print the name of the checked-out branch; fail with an error
# message on a detached HEAD (commands that push or rebase need a branch).
_git_current_branch()
{
local b
b=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) || {
disp E "HEAD is detached: check out a branch first."
return 1
}
printf "%s\n" "$b"
}
export -f _git_current_branch
# Internal helper: print "<remote><TAB><branch>" for the remote branch tracked
# by local branch $1, read from its configuration (branch.<name>.remote/.merge).
# Unlike parsing the "origin/feature/x" form given by @{u}, this is exact for
# remote and branch names containing slashes. Fails when nothing is tracked or
# when the branch tracks another local branch (remote ".").
_git_tracking()
{
local branch=$1 remote merge
remote=$(git config --get "branch.${branch}.remote") || return 1
merge=$(git config --get "branch.${branch}.merge") || return 1
[[ $remote == "." ]] && return 1
printf "%s\t%s\n" "$remote" "${merge#refs/heads/}"
}
export -f _git_tracking
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * 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.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * 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 OWNER 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,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# Private helpers of profile.d/packages.sh, loaded by the module itself.
# Internal helper: run a package manager command, with root privileges when
# needed (sudo, else doas), or only display it in dry-run mode.
# Usage: _pkg_run <needs_root 0|1> <dry_run 0|1> <command> [args...]
_pkg_run()
{
local needs_root="$1" dry="$2"
shift 2
local -a prefix=()
if (( needs_root )) && [[ $EUID -ne 0 ]]; then
if command -v sudo >/dev/null 2>&1; then
prefix=(sudo)
elif command -v doas >/dev/null 2>&1; then
prefix=(doas)
elif (( ! dry )); then
disp E "Root privileges are required and neither sudo nor doas is available."
return 1
fi
fi
if (( dry )); then
# %q gives a copy-pasteable command line
printf '%q ' "${prefix[@]}" "$@"
printf '\n'
return 0
fi
if ! command -v "$1" >/dev/null 2>&1; then
disp E "'$1' is not installed."
return 1
fi
"${prefix[@]}" "$@"
}
export -f _pkg_run
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * 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.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * 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 OWNER 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,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# Private helpers of profile.d/processes.sh, loaded by the module itself.
# Internal helper: take the kill-style signal arguments (-9, -KILL, -SIGHUP)
# out of an argument list. getopt rejects them as unknown options, so they must
# be removed before it runs. They are recognised anywhere before a "--"
# (digits, or upper-case names, which cannot clash with the lower-case options
# of the callers); everything after "--" is left untouched.
# Usage: _proc_split_signals <signals_array> <rest_array> [args...]
_proc_split_signals()
{
local -n _pss_sigs="$1" _pss_rest="$2"
shift 2
_pss_sigs=()
_pss_rest=()
local a
while [[ $# -gt 0 ]]; do
a="$1"
shift
if [[ $a == "--" ]]; then
_pss_rest+=(-- "$@")
break
fi
if [[ $a =~ ^-([0-9]+|SIG[A-Z0-9+-]+|[A-Z][A-Z0-9+-]*)$ ]]; then
_pss_sigs+=("$a")
else
_pss_rest+=("$a")
fi
done
}
export -f _proc_split_signals
# Internal helper of kt: send a signal to a process and, first, to all its
# descendants (depth first, so a parent never outlives the children it could
# respawn from). A failure does not stop the others; the status is non-zero if
# any kill failed.
# Usage: _kt_tree <dry_run 0|1> <pid> [kill_args...]
_kt_tree()
{
local dry="$1" pid="$2" child rc=0
shift 2
for child in $(pgrep -P "$pid" 2>/dev/null); do
_kt_tree "$dry" "$child" "$@" || rc=1
done
if (( dry )); then
printf "DRY-RUN: "
printf "%q " kill "$@" "$pid"
printf "\n"
else
kill "$@" "$pid" || rc=1
fi
return "$rc"
}
export -f _kt_tree
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * 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.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * 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 OWNER 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,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# Private helpers of profile.d/prompt.sh, loaded by the module itself.
# Colour variable names defined by disp.sh. They are the only names a theme or
# the [prompt] configuration may reference (as $Name or ${Name}), and the only
# palette variables a theme may redefine.
_PROMPT_COLOR_NAMES=(
Black Red Green Yellow Blue Purple Cyan White
BBlack BRed BGreen BYellow BBlue BPurple BCyan BWhite
UBlack URed UGreen UYellow UBlue UPurple UCyan UWhite
On_Black On_Red On_Green On_Yellow On_Blue On_Purple On_Cyan On_White
IBlack IRed IGreen IYellow IBlue IPurple ICyan IWhite
BIBlack BIRed BIGreen BIYellow BIBlue BIPurple BICyan BIWhite
On_IBlack On_IRed On_IGreen On_IYellow On_IBlue On_IPurple On_ICyan On_IWhite
DEFAULTFG DEFAULTBG DEFAULTCOL RESETCOL
)
# The same names as an ERE alternation (Black|Red|...)
_PROMPT_COLOR_RE=""
for _pc_name in "${_PROMPT_COLOR_NAMES[@]}"; do
_PROMPT_COLOR_RE+="|${_pc_name}"
done
_PROMPT_COLOR_RE="${_PROMPT_COLOR_RE#|}"
unset _pc_name
# Check a colour value and put its resolved form in _PROMPT_RESOLVED.
# A value is a sequence of blocks, each one either
# $ColorName or ${ColorName} — colour variable from disp.sh (resolved by
# indirection, never evaluated)
# \e[...m or \033[...m — raw ANSI escape literal
# Anything else is refused (return 1): colours end up inside PS1, so they must
# not be able to carry anything else than colour codes.
# Usage: _prompt_resolve_color <value>
_prompt_resolve_color()
{
local rest="$1" out="" name
# shellcheck disable=SC2016 # a regular expression, the $ must stay literal
local ref_re='^\$(\{('"$_PROMPT_COLOR_RE"')\}|('"$_PROMPT_COLOR_RE"'))'
local ansi_re='^(\\e|\\033)\[[0-9;]*m'
[[ -n $rest ]] || return 1
while [[ -n $rest ]]; do
if [[ $rest =~ $ref_re ]]; then
name="${BASH_REMATCH[2]:-${BASH_REMATCH[3]}}"
out+="${!name:-}"
elif [[ $rest =~ $ansi_re ]]; then
out+="${BASH_REMATCH[0]}"
else
return 1
fi
rest="${rest:${#BASH_REMATCH[0]}}"
done
_PROMPT_RESOLVED="$out"
}
# Path of the theme file for a bare name (searched in the theme directory) or an
# explicit path (anything containing a slash).
# Usage: _prompt_theme_file <theme_name_or_path> [theme_dir]
_prompt_theme_file()
{
local name="$1" dir="${2:-${PROMPT_THEME_DIR:-$MYPATH/profile.d/themes}}"
if [[ "$name" == */* ]]; then
printf '%s' "$name"
else
printf '%s' "$dir/${name}.theme"
fi
}
# Apply the PROMPT_COLOR_* keys of the [prompt] section on top of the current
# colours, so they win over the theme. Their values may use $ColorName, which
# parse_conf leaves untouched while the colour variables do not exist yet.
# A value that is not a valid colour is reported and not applied.
# Usage: _prompt_apply_overrides
_prompt_apply_overrides()
{
# shellcheck disable=SC2154 # CONF_prompt is filled by parse_conf (profile.sh)
declare -p CONF_prompt >/dev/null 2>&1 || return 0
local k
for k in "${!CONF_prompt[@]}"; do
[[ $k == PROMPT_COLOR_* ]] || continue
if _prompt_resolve_color "${CONF_prompt[$k]}"; then
export "$k=$_PROMPT_RESOLVED"
else
printf "[ Warning ] [prompt] %s: invalid colour value '%s', ignoring.\n" \
"$k" "${CONF_prompt[$k]}" >&2
# load_conf exported the raw text: do not keep it as a colour
[[ "${!k:-}" == "${CONF_prompt[$k]}" ]] && unset "$k"
fi
done
return 0
}
# Switch to a theme from a clean state: default palette, no PROMPT_COLOR_*
# left over from the previous theme (a partial theme must not inherit them),
# the theme, then the [prompt] overrides. The theme file is checked first, so a
# wrong name changes nothing.
# Usage: _prompt_load_theme_clean <theme_name_or_path> [theme_dir]
_prompt_load_theme_clean()
{
local file v
file=$(_prompt_theme_file "$1" "${2:-}")
if [[ ! -f "$file" || ! -r "$file" ]]; then
printf "[ Warning ] set_theme: theme file not found: %s\n" "$file" >&2
return 1
fi
set_colors
for v in ${!PROMPT_COLOR_@}; do
unset "$v"
done
load_theme "$1" "${2:-}" || return 1
_prompt_apply_overrides
}
# Put the current time, in microseconds since the epoch, in _timer_us.
# EPOCHREALTIME (bash >= 5) costs no process; its decimal separator follows the
# locale ("." or ","), hence the removal of both. Otherwise "date +%s%N" is
# used (it prints a literal "N" where nanoseconds are not supported, which the
# digit check rejects), then plain seconds.
_timer_now()
{
if [[ -n "${EPOCHREALTIME:-}" ]]; then
_timer_us="${EPOCHREALTIME//[.,]/}"
else
local t
t=$(date +%s%N 2>/dev/null)
if [[ $t =~ ^[0-9]{16,}$ ]]; then
_timer_us=$(( t / 1000 ))
else
_timer_us=$(( $(date +%s) * 1000000 ))
fi
fi
}
# Prompt segment: set _PROMPT_SEG to the git branch (or short commit when
# detached) with dirty/ahead/behind markers; empty outside a repository or when
# disabled by PROMPT_SHOW_GIT / PROMPT_SHOW_GIT_STATUS.
_prompt_seg_git()
{
_PROMPT_SEG=""
# Fast path: skip git lookup when feature is disabled.
[[ "${PROMPT_SHOW_GIT:-1}" == "0" ]] && return 0
local branch
branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) || \
branch=$(git rev-parse --short HEAD 2>/dev/null) || return 0
local dirty="" sync="" timed_out=0
if [[ "${PROMPT_SHOW_GIT_STATUS:-1}" != "0" ]]; then
# Build a timeout wrapper if the 'timeout' command is available;
# fall back to direct execution on systems that lack it.
local -a _tw=()
command -v timeout >/dev/null 2>&1 && _tw=(timeout "${PROMPT_GIT_TIMEOUT:-2}")
# One call for everything: --branch gives the "# branch.ab +A -B" line
# (only when an upstream exists) and each changed tracked file gives a
# line starting with 1, 2 or u. Untracked files are not counted.
# GIT_OPTIONAL_LOCKS=0 keeps git from taking the index lock to refresh
# it, which would make a concurrent "git commit" fail.
local out ec
out=$(GIT_OPTIONAL_LOCKS=0 "${_tw[@]}" git status --porcelain=v2 --branch \
--untracked-files=no --ignore-submodules 2>/dev/null)
ec=$?
if [[ $ec -eq 124 ]]; then
timed_out=1
else
[[ $'\n'"$out" == *$'\n'[12u]\ * ]] && dirty="*"
if [[ "$out" =~ \#\ branch\.ab\ \+([0-9]+)\ -([0-9]+) ]]; then
if [[ "${BASH_REMATCH[1]}" -gt 0 || "${BASH_REMATCH[2]}" -gt 0 ]]; then
sync=" +${BASH_REMATCH[1]}/-${BASH_REMATCH[2]}"
fi
fi
fi
fi
if [[ $timed_out -eq 1 ]]; then
_PROMPT_SEG="git:${branch}?"
else
_PROMPT_SEG="git:${branch}${dirty}${sync}"
fi
}
# Prompt segment: set _PROMPT_SEG to the active conda environment, if any.
_prompt_seg_conda()
{
_PROMPT_SEG=""
[[ "${PROMPT_SHOW_CONDA:-1}" == "0" ]] && return 0
[[ -z "${CONDA_DEFAULT_ENV:-}" ]] && return 0
_PROMPT_SEG="conda:${CONDA_DEFAULT_ENV}"
}
# Prompt segment: set _PROMPT_SEG to the active Python virtualenv name, unless
# a conda environment (which sets its own) is active.
_prompt_seg_venv()
{
_PROMPT_SEG=""
[[ "${PROMPT_SHOW_VENV:-1}" == "0" ]] && return 0
[[ -n "${CONDA_DEFAULT_ENV:-}" ]] && return 0
[[ -z "${VIRTUAL_ENV:-}" ]] && return 0
_PROMPT_SEG="venv:${VIRTUAL_ENV##*/}"
}
# Prompt segment: set _PROMPT_SEG to the session kind tags (ssh, tmux, screen)
# joined with '+', empty for a plain local session.
_prompt_seg_session()
{
_PROMPT_SEG=""
[[ "${PROMPT_SHOW_SESSION:-1}" == "0" ]] && return 0
local -a tags=()
[[ -n "${SSH_CONNECTION:-}" ]] && tags+=("ssh")
[[ -n "${TMUX:-}" ]] && tags+=("tmux")
[[ -n "${STY:-}" ]] && tags+=("screen")
[[ ${#tags[@]} -eq 0 ]] && return 0
local out="${tags[0]}" i
for ((i=1; i<${#tags[@]}; ++i)); do
out+="+${tags[i]}"
done
_PROMPT_SEG="$out"
}
# Append the current _PROMPT_SEG to the context text _PROMPT_CTX (" | " between
# segments). An "if" keeps the status at 0 when the segment is empty.
_prompt_ctx_add()
{
if [[ -n "$_PROMPT_SEG" ]]; then
if [[ -n "$_PROMPT_CTX" ]]; then
_PROMPT_CTX+=" | $_PROMPT_SEG"
else
_PROMPT_CTX="$_PROMPT_SEG"
fi
fi
}
# Store the exit status of the user's last command (first command of a
# PROMPT_COMMAND that also contains other commands, see profile.sh).
_profile_save_status()
{
_profile_status=$?
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * 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.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * 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 OWNER 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,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# Private helpers of profile.d/pwd.sh, loaded by the module itself.
# Pool of random words read from /dev/urandom, refilled by _pwd_rand.
_PWD_POOL=()
# Internal helper: put in _PWD_RAND an integer uniformly distributed in
# [0, $1), for 1 <= $1 <= 2^32. $RANDOM is NOT usable for passwords: it only
# has 15 bits, a state seeded from the time and the PID, and is predictable.
# The source is $SRANDOM (bash >= 5.1, from the kernel's random generator), or
# else /dev/urandom read through od, 256 words at a time to limit the number
# of processes. Values above the largest multiple of $1 are rejected, so that
# "% $1" introduces no bias.
# Usage: _pwd_rand <max>
_pwd_rand()
{
local max="$1" limit r
limit=$(( 4294967296 - 4294967296 % max ))
while :; do
if [[ -n "${SRANDOM+x}" ]]; then
r="$SRANDOM"
else
if (( ${#_PWD_POOL[@]} == 0 )); then
# shellcheck disable=SC2207 # whitespace-separated numbers
_PWD_POOL=( $(od -An -v -N1024 -tu4 /dev/urandom 2>/dev/null) )
if (( ${#_PWD_POOL[@]} == 0 )); then
disp E "No source of random numbers available (/dev/urandom)."
return 1
fi
fi
r="${_PWD_POOL[-1]}"
unset '_PWD_POOL[-1]'
fi
if (( r < limit )); then
_PWD_RAND=$(( r % max ))
return 0
fi
done
}
+468
View File
@@ -0,0 +1,468 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * 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.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * 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 OWNER 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,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# Private helpers of profile.d/rain.sh, loaded by the module itself.
# Fill RAIN_ENGINE_COLORS with the escape sequences of the drop colours (head to
# tail) of the colour theme $1: a 20-step 24-bit gradient when the terminal
# supports truecolor, a short 256-colour palette otherwise.
# Usage: _rain_build_colors <colour_name>
_rain_build_colors()
{
# Case-insensitive: the configuration documents capitalised names (Green)
local base_color="${1,,}"
local i
RAIN_ENGINE_COLORS=()
case "$base_color" in
green|blue|red|yellow|cyan|white) ;;
*)
disp E "Unknown color: ${1} (supported: green, blue, red, yellow, cyan, white)."
return 1
;;
esac
local use_truecolor=0
# term_set() already sets TERM=*-direct when truecolor is available;
# honour COLORTERM as a belt-and-suspenders fallback.
[[ "$TERM" == *direct* || "${COLORTERM:-}" == "truecolor" || "${COLORTERM:-}" == "24bit" ]] && use_truecolor=1
if (( use_truecolor )); then
# 24-bit gradient from a near-black shade to a vivid hue.
# 20 steps provide smooth depth variation across simultaneous drops.
local steps=20
local r1 g1 b1 r2 g2 b2
case "${base_color}" in
green) r1=0; g1=12; b1=2; r2=50; g2=255; b2=90 ;;
blue) r1=0; g1=5; b1=25; r2=60; g2=140; b2=255 ;;
red) r1=15; g1=0; b1=0; r2=255; g2=40; b2=40 ;;
yellow) r1=25; g1=18; b1=0; r2=255; g2=240; b2=50 ;;
cyan) r1=0; g1=18; b1=18; r2=50; g2=255; b2=240 ;;
*) r1=40; g1=40; b1=45; r2=220; g2=220; b2=255 ;;
esac
local r g b
for ((i = 0; i < steps; i++)); do
r=$(( r1 + (r2 - r1) * i / (steps - 1) ))
g=$(( g1 + (g2 - g1) * i / (steps - 1) ))
b=$(( b1 + (b2 - b1) * i / (steps - 1) ))
RAIN_ENGINE_COLORS+=("\e[38;2;${r};${g};${b}m")
done
else
# Fallback: 256-colour palettes.
case $base_color in
green)
for i in {22..28} {34..40} {46..48}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;;
blue)
for i in {17..21} {27..33} {39..45}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;;
red)
for i in {52..52} {88..88} {124..124} {160..160} {196..201}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;;
yellow)
for i in {58..58} {100..100} {142..142} {184..184} {226..229}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;;
cyan)
for i in {30..31} {37..38} {44..45} {50..51}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;;
*)
RAIN_ENGINE_COLORS=("\e[37m" "\e[37;1m")
for i in {244..255}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;;
esac
fi
}
# Fill RAIN_ENGINE_CHARS with the characters a drop may be made of, according to
# the animation ($1: rain or matrix) and the matrix charset name ($2).
# Usage: _rain_build_chars <mode> <charset>
_rain_build_chars()
{
local mode="$1"
local charset="$2"
RAIN_ENGINE_CHARS=()
case "$mode" in
matrix)
case "$charset" in
""|binary)
RAIN_ENGINE_CHARS=("0" "1")
;;
kana|kanji)
# Half-width katakana set, generally rendered as single-cell glyphs.
RAIN_ENGINE_CHARS=("ア" "イ" "ウ" "エ" "オ" "カ" "キ" "ク" "ケ" "コ" "サ" "シ" "ス" "セ" "ソ" "タ" "チ" "ツ" "テ" "ト" "ナ" "ニ" "ヌ" "ネ" "ノ" "ハ" "ヒ" "フ" "ヘ" "ホ" "マ" "ミ" "ム" "メ" "モ" "ヤ" "ユ" "ヨ" "ラ" "リ" "ル" "レ" "ロ" "ワ" "ン")
;;
ascii)
RAIN_ENGINE_CHARS=("0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "A" "B" "C" "D" "E" "F")
;;
*)
disp E "Unknown charset: ${charset} (supported: binary, kana, ascii)."
return 1
;;
esac
;;
*)
RAIN_ENGINE_CHARS=("|" "│" "┃" "┆" "┇" "┊" "┋" "╽" "╿")
;;
esac
return 0
}
# Validate a speed and print it as a delay in seconds for read -t / sleep:
# values >= 1 are centiseconds (5 -> 0.050), values < 1 are raw seconds.
# Return 1 for a non-numeric or null value.
# Usage: _rain_normalize_speed <speed>
_rain_normalize_speed()
{
local raw_speed="$1"
# Accept integer/floating values. UI scale is centiseconds by default:
# 5 -> 0.05s, 2.5 -> 0.025s. Values < 1 are treated as direct seconds
# for backward compatibility (e.g. 0.03).
if [[ ! "$raw_speed" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
return 1
fi
# A null delay is refused: "read -t 0" does not wait, the animation loop
# would spin and use a whole CPU.
if LC_ALL=C awk -v s="$raw_speed" 'BEGIN { exit !(s == 0) }'; then
return 1
fi
if LC_ALL=C awk -v s="$raw_speed" 'BEGIN { exit !(s < 1) }'; then
printf "%s" "$raw_speed"
else
LC_ALL=C awk -v s="$raw_speed" 'BEGIN { printf "%.3f", s / 100 }'
fi
}
# Validate a density (maximum number of simultaneous drops, an integer >= 1)
# and print it; return 1 otherwise.
# Usage: _rain_normalize_density <density>
_rain_normalize_density()
{
local raw_density="$1"
if [[ ! "$raw_density" =~ ^[0-9]+$ || "$raw_density" -lt 1 ]]; then
return 1
fi
printf "%s" "$raw_density"
}
# Fill RAINBOW_BG_PALETTE with <width> background colour escapes sweeping the
# hue wheel: 6 bands of 256 steps in 24-bit colour, or 12 fixed ANSI colours
# spread over the width otherwise.
# Usage: _rainbow_build_palette <width> <use_truecolor (0|1)>
_rainbow_build_palette()
{
local palette_width="$1"
local use_truecolor="$2"
local x=0 wheel_pos=0 band=0 blend=0
local red=0 green=0 blue=0
RAINBOW_BG_PALETTE=()
if (( palette_width < 1 )); then
RAINBOW_BG_PALETTE+=("\e[41m")
return 0
fi
if (( use_truecolor )); then
for ((x = 0; x < palette_width; x++)); do
wheel_pos=$((x * 1536 / palette_width))
band=$((wheel_pos / 256))
blend=$((wheel_pos % 256))
case "$band" in
0)
red=255; green=$blend; blue=0
;;
1)
red=$((255 - blend)); green=255; blue=0
;;
2)
red=0; green=255; blue=$blend
;;
3)
red=0; green=$((255 - blend)); blue=255
;;
4)
red=$blend; green=0; blue=255
;;
*)
red=255; green=0; blue=$((255 - blend))
;;
esac
RAINBOW_BG_PALETTE+=("\e[48;2;${red};${green};${blue}m")
done
else
local ansi_palette=(41 101 43 103 42 102 46 106 44 104 45 105)
local ansi_count=${#ansi_palette[@]}
for ((x = 0; x < palette_width; x++)); do
RAINBOW_BG_PALETTE+=("\e[${ansi_palette[x * ansi_count / palette_width]}m")
done
fi
}
# Stop request of the animation loops: sets exit_st, a local of the running
# animation (dynamic scoping). Installed as the TERM/INT trap.
_rain_stop()
{
exit_st=1
}
# The animations read a key from the terminal and draw on it, and need tput.
# Usage: _rain_need_tty <command-name>
_rain_need_tty()
{
if [[ ! -t 0 || ! -t 1 ]]; then
disp E "$1: a terminal is required (standard input and output must be a terminal)."
return 1
fi
command -v tput >/dev/null 2>&1 || {
disp E "The program 'tput' is required but not installed."
return 1
}
}
# Prepare the terminal for an animation: save its settings and the TERM, INT
# and WINCH traps of the caller, install the animation traps, disable echo,
# hide the cursor and clear the screen.
# saved_stty and saved_traps are locals of the calling animation.
# Usage: _rain_enter <winch_handler_function>
_rain_enter()
{
saved_stty=$(stty -g 2>/dev/null)
saved_traps=$(trap -p TERM INT WINCH)
trap _rain_stop TERM INT
# shellcheck disable=SC2064 # $1 is the handler name, expanded on purpose
trap "$1" WINCH
stty -echo
printf '\e[?25l\e[2J'
}
# Undo _rain_enter: reset the colours (the last drawn drop colour would
# otherwise stay active), show the cursor, clear the screen, then restore the
# terminal settings and the caller's traps.
_rain_leave()
{
printf '\e[0m\e[?25h\e[2J\e[H'
[[ -n "$saved_stty" ]] && stty "$saved_stty"
trap - TERM INT WINCH
_trap_restore "$saved_traps"
return 0
}
# Shared engine of the rain and matrix animations: draws the falling drops until
# a key is pressed, a signal is received, or the terminal is resized (the
# state is rebuilt). The terminal state is always restored on exit.
# Usage: _rain_engine <delay_seconds> <colour> <rain|matrix> <charset> <density>
_rain_engine()
{
local step_duration="$1"
local base_color="$2"
local mode="$3"
local charset="$4"
local density_override="$5"
_rain_need_tty "$mode" || return 1
_rain_build_colors "$base_color" || return 1
_rain_build_chars "$mode" "$charset" || return 1
local rain_colors=("${RAIN_ENGINE_COLORS[@]}")
local rain_chars=("${RAIN_ENGINE_CHARS[@]}")
unset RAIN_ENGINE_COLORS RAIN_ENGINE_CHARS
local rain_color_tab=${#rain_colors[@]}
local rain_tab=${#rain_chars[@]}
local matrix_head_color=$'\e[1;97m'
local exit_st=0
local num_rain_metadata=5
local term_height=0 term_width=0
local X=0 Y=0 drop_length=0 rain_drop=0
local max_rain_width=0 max_rain_height=0
local new_rain_odd=0 falling_odd=0
local term_area=0
local frame_sleep="$step_duration"
local saved_stty="" saved_traps=""
# The drops: 5 consecutive entries each (X, Y of the top, character, colour,
# length).
local rains=()
local num_rains=0
local ch=""
# WINCH handler (and initial sizing). Called with the animation running, it
# sees its locals through dynamic scoping.
_rain_sigwinch()
{
term_width=$(tput cols)
term_height=$(tput lines)
term_area=$(( term_width * term_height ))
case "$mode" in
matrix)
max_rain_width=$(( term_area / 3 ))
max_rain_height=$(( term_height < 8 ? 1 : term_height / 6 ))
new_rain_odd=$(( term_height > 50 ? 100 : term_height * 2 ))
new_rain_odd=$(( new_rain_odd * 85 / 100 ))
falling_odd=100
# Adapt cadence and density to terminal size for smoother rendering.
if ((term_area < 1200)); then
max_rain_width=$(( term_area / 4 ))
frame_sleep=$(LC_ALL=C awk -v s="$step_duration" 'BEGIN { printf "%.3f", s * 1.15 }')
elif ((term_area > 5000)); then
max_rain_width=$(( term_area / 2 ))
frame_sleep=$(LC_ALL=C awk -v s="$step_duration" 'BEGIN { printf "%.3f", s * 0.85 }')
else
frame_sleep="$step_duration"
fi
;;
*)
max_rain_width=$(( term_area / 4 ))
max_rain_height=$(( term_height < 10 ? 1 : term_height / 10 ))
new_rain_odd=$(( term_height > 50 ? 100 : term_height * 2 ))
new_rain_odd=$(( new_rain_odd * 75 / 100 ))
falling_odd=$(( term_height > 25 ? 100 : term_height * 4 ))
falling_odd=$(( falling_odd * 90 / 100 ))
frame_sleep="$step_duration"
;;
esac
if [[ -n "$density_override" ]]; then
max_rain_width="$density_override"
fi
# The drops were placed for the previous size and the screen content
# does not fit the new one: start again from an empty screen.
rains=()
num_rains=0
printf '\e[2J'
}
# Draw one frame. The whole frame (erasing the previous drop positions,
# then the new ones) is built in a string and written with one printf: a
# write per character made the animation slow and flickering.
_rain_render()
{
local idx=0 y=0 drop_color="" current_char="" render_color="" out=""
local esc=$'\e'
for ((idx = 0; idx < num_rains * num_rain_metadata; idx += num_rain_metadata)); do
X=${rains[idx]}
Y=${rains[idx + 1]}
drop_length=${rains[idx + 4]}
for ((y = Y; y < Y + drop_length; y++)); do
((y < 1 || y > term_height)) && continue
out+="${esc}[${y};${X}H "
done
done
for ((idx = 0; idx < num_rains * num_rain_metadata; idx += num_rain_metadata)); do
if ((100 * RANDOM / 32768 < falling_odd)); then
if ((++rains[idx + 1] > term_height)); then
# The drop left the screen: remove it. The next drop moves
# into this slot, which must be processed again (the loop
# increment would skip it, and it would vanish for a frame).
rains=("${rains[@]:0:idx}" "${rains[@]:idx+num_rain_metadata}")
num_rains=$(( num_rains - 1 ))
idx=$(( idx - num_rain_metadata ))
continue
fi
fi
X=${rains[idx]}
Y=${rains[idx + 1]}
rain_drop=${rains[idx + 2]}
drop_color=${rains[idx + 3]}
drop_length=${rains[idx + 4]}
for ((y = Y; y < Y + drop_length; y++)); do
((y < 1 || y > term_height)) && continue
if [[ "$mode" == "matrix" ]]; then
current_char="${rain_chars[rain_tab * RANDOM / 32768]}"
if ((y == Y + drop_length - 1)); then
render_color="$matrix_head_color"
else
render_color="$drop_color"
fi
else
current_char="$rain_drop"
render_color="$drop_color"
fi
# render_color holds a literal \e[...m: hence %b below
out+="${esc}[${y};${X}H${render_color}${current_char}"
done
done
[[ -n "$out" ]] && printf '%b' "$out"
return 0
}
_rain_enter _rain_sigwinch
_rain_sigwinch
while ((exit_st <= 0)); do
ch=""
# A timeout makes read fail (status > 128): expected, not an error
read -r -n 1 -t "$frame_sleep" ch || true
case "$ch" in
q|Q)
_rain_stop
;;
esac
if ((num_rains < max_rain_width)) && ((100 * RANDOM / 32768 < new_rain_odd)); then
rain_drop="${rain_chars[rain_tab * RANDOM / 32768]}"
drop_color="${rain_colors[rain_color_tab * RANDOM / 32768]}"
if [[ "$mode" == "matrix" ]]; then
drop_length=$((max_rain_height * RANDOM / 32768 + 2))
else
drop_length=$((max_rain_height * RANDOM / 32768 + 1))
fi
X=$((term_width * RANDOM / 32768 + 1))
Y=$((1 - drop_length))
rains+=("$X" "$Y" "$rain_drop" "$drop_color" "$drop_length")
num_rains=$(( num_rains + 1 ))
fi
_rain_render
done
_rain_leave
unset -f _rain_sigwinch _rain_render
return 0
}
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * 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.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * 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 OWNER 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,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# Private helpers of profile.d/ssh.sh, loaded by the module itself.
# Internal helper: remove <key> (a name or an IP, in known_hosts notation, so
# also "[host]:port") from the known_hosts file <file> with ssh-keygen -R.
# ssh-keygen writes a new file: run by root on another user's file (--all-users)
# it would leave it owned by root with mode 0644, which keeps that user's ssh
# from adding host keys, or exposes a private file. The owner and mode of the
# file, and of its ".old" backup, are therefore put back.
# Returns ssh-keygen's status (non-zero when the key was not found).
# Usage: _rmhost_remove <key> <file>
_rmhost_remove()
{
local key="$1" file="$2" owner="" mode="" rc=0
if [[ ${EUID:-$(id -u)} -eq 0 ]]; then
owner=$(stat -c '%u:%g' -- "$file" 2>/dev/null)
mode=$(stat -c '%a' -- "$file" 2>/dev/null)
fi
ssh-keygen -R "$key" -f "$file" </dev/null >/dev/null 2>&1 || rc=$?
if [[ -n $owner && -n $mode ]]; then
chown "$owner" -- "$file" "${file}.old" 2>/dev/null
chmod "$mode" -- "$file" "${file}.old" 2>/dev/null
fi
return "$rc"
}
export -f _rmhost_remove
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * 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.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * 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 OWNER 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,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# Private helpers of profile.d/updates.sh, loaded by the module itself.
# Check a branch name. It ends up in URLs and in git command lines, so it is
# restricted to plain ref characters, without a leading "-" or "..".
# Usage: _updt_valid_branch <name>
_updt_valid_branch()
{
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ && "$1" != *..* ]]
}
# Tell whether the published version $2 is an update for the installed one $1.
# Versions look like MAJOR.MINOR.PATCH[-REVISION[_STAGE_N]]. The base version
# (before the first "-" or "_") decides, compared in version order; with the same
# base, any difference (a new revision, a release candidate becoming final) is
# an update. Returns 0 when it is, 1 otherwise (equal, or the installed version
# is the more recent one, as on a development branch).
# Usage: _updt_is_newer <installed> <published>
_updt_is_newer()
{
local cur="$1" new="$2" cur_base new_base
[[ "$cur" == "$new" ]] && return 1
cur_base="${cur%%[-_]*}"
new_base="${new%%[-_]*}"
[[ "$cur_base" == "$new_base" ]] && return 0
[[ "$(printf '%s\n%s\n' "$cur_base" "$new_base" | sort -V | tail -n 1)" == "$new_base" ]]
}
-147
View File
@@ -1,147 +0,0 @@
------------------------------------------------------------------------------
Initial version from Beyond Linux From Scratch by
* James Robertson <jameswrobertson@earthlink.net>
* Dagmar d'Surreal <rivyqntzne@pbzpnfg.arg>
------------------------------------------------------------------------------
Current version from Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
------------------------------------------------------------------------------
Version history:
------------------------------------------------------------------------------
# 05/03/2026 v3.6.1
Fix a typo in compress.sh
# 05/03/2026 v3.6.0
Improved utaz to make it multiformat with lot of it
Introduced ppu and ppn
Improved update system
# 04/03/2026 v3.5.0
rain has now configurable speed and color
showinfo adapted to fastfetch, replacing neofetch
# 24/02/2022 v3.3.1
Fixed version detection
Added "busy" function
Fixed use of library functions before it's loaded
# 28/11/2022 v3.3.0
Initial version update support
Changed versioning code
Added installation path detection
# 28/11/2022 v3.2.3
Made proper readme file, to improve
# 21/11/2022 v3.2.2
Fixed taz compression level analysis
Fixed typo in dpkgs
# 20/11/2022 v3.2.1
Fix some messages
Make dpkgs rpm aware (more to come)
Removed version history from main script and revert declaration order
Added required license information in all files
Completed LICENSE file
# 18/11/2022 v3.2.0
Created disp command for display and make use of it
# 10/11/2022 v3.1.1
genpwd: test if password is doable
# 08/11/2022 v3.1.0
Added password generator
# 07/11/2022 v3.0.1
Added concatenation to rmspc
Added ku
Error managed in meteo
# 27/08/2022 v3.0.0
Splitted everything in several files
Added rain screensaver
# 29/07/2022 v2.8.2
Added warning for non bash or zsh users
# 19/07/2022 v2.8.1
Few cleanups, fixes and optimizations
# 24/06/2022 v2.8.0
Added backtrace, error and settrace
[bugfix] corrected showinfo
# 22/06/2022 v2.7.1
[bugfix] few minor corrections
Added help command
# 21/06/2022 v2.7.0
Added isipv4 and isipv6 and use it in rmhost as an improvement
Removed konsole save and restore not working
# 18/10/2021 v2.6.3
Changed PS1 for status bar style version
Few minor improvements
# 26/02/2021 v2.6.2
[bugfix] taz: corrected bug with trailing slash on directories
# 25/12/2020 v2.6.1
Add check on rmhost
Improvements rmspc
Created expendlist
# 24/10/2020 v2.6.0
Added session save and restore for Konsole
# 11/09/2020 v2.5.3
Few more aliases, improved code consistancy and typo,
Improved utaz, removed showdiskmap, removed remaining French,
Added license information for future publication
# 06/03/2020 v2.5.2
Few aliases sorted out
# 05/03/2020 v2.5.1
Language consistancy fix
Added pigz support in taz
# 03/03/2020 v2.5.0
Added command taz and rmspc
Renamed auzip => utaz and improved it
# 02/03/2020 v2.4.0
Added command auzip
# 31/01/2020 v2.3.2
Figlet: changed default font to ansi_shadow
# 16/01/2020 v2.3.1
[bugfix] non-interactive were blocked with some functions
# 08/01/2020 v2.3.0
Added use of figlet and neofetch as a motd replace
# 16/12/2019 v2.2.0
Added showinfo
Primary write of showdiskmap
# 24/09/2019 v2.1.2
[bugfix] bug in profile version display
# 23/09/2019 v2.1.1
[bugfix] dpkgs
# 16/09/2018 v2.1.0
Added rmhost, setc, setfr
More locales management
# 04/02/2017 v2.0.1
clean improvements (--shell)
# 24/10/2015 v2.0.0
Added advanced functionnalities (clean, srr, etc.)
# 16/02/2013 v1.0.0
Initial version
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * 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.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * 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 OWNER 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,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# Git helper completions for profile.d/git.sh shortcuts.
# ------------------------------------------------------------------------------
# Return 0 when current directory is inside a git work tree.
_profile_git_in_repo()
{
git rev-parse --is-inside-work-tree >/dev/null 2>&1
}
# Load git completion helpers on demand if they are available on the system.
_profile_git_load_completion_helpers()
{
declare -F __git_complete >/dev/null 2>&1 && return 0
local completion_file
for completion_file in \
/usr/share/bash-completion/completions/git \
/usr/share/git/completion/git-completion.bash \
/etc/bash_completion.d/git
do
if [[ -r "$completion_file" ]]; then
# shellcheck source=/dev/null
. "$completion_file"
break
fi
done
declare -F __git_complete >/dev/null 2>&1
}
_profile_git_complete_remotes()
{
local cur
cur="${COMP_WORDS[COMP_CWORD]}"
if ! _profile_git_in_repo; then
COMPREPLY=()
return 0
fi
mapfile -t COMPREPLY < <(compgen -W "$(git remote 2>/dev/null)" -- "$cur")
}
_profile_git_complete_refs()
{
local cur
cur="${COMP_WORDS[COMP_CWORD]}"
if ! _profile_git_in_repo; then
COMPREPLY=()
return 0
fi
mapfile -t COMPREPLY < <(compgen -W "$(git for-each-ref --format='%(refname:short)' refs/heads refs/remotes refs/tags 2>/dev/null)" -- "$cur")
}
_profile_git_complete_add_paths()
{
# shellcheck disable=SC2034 # Used indirectly by git-completion helpers via dynamic scope.
local cur words cword prev __git_cmd_idx=0
local complete_opt="--others --modified --directory --no-empty-directory"
if declare -F __git_complete_index_file >/dev/null 2>&1; then
if declare -F _get_comp_words_by_ref >/dev/null 2>&1; then
_get_comp_words_by_ref -n =: cur words cword prev
else
cur="${COMP_WORDS[COMP_CWORD]}"
if (( COMP_CWORD > 0 )); then
prev="${COMP_WORDS[COMP_CWORD-1]}"
else
prev=""
fi
# shellcheck disable=SC2034 # Used indirectly by git-completion helpers via dynamic scope.
cword="$COMP_CWORD"
# shellcheck disable=SC2034 # Used indirectly by git-completion helpers via dynamic scope.
words=("${COMP_WORDS[@]}")
fi
if [[ -n $(__git_find_on_cmdline "-u --update") ]]; then
complete_opt="--modified"
fi
__git_complete_index_file "$complete_opt"
return 0
fi
mapfile -t COMPREPLY < <(compgen -f -- "${COMP_WORDS[COMP_CWORD]}")
}
_complete_gst()
{
local cur
cur="${COMP_WORDS[COMP_CWORD]}"
case "$cur" in
-*)
mapfile -t COMPREPLY < <(compgen -W "-h --help" -- "$cur")
;;
*)
mapfile -t COMPREPLY < <(compgen -d -- "$cur")
;;
esac
}
_complete_ggraph()
{
local cur prev
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
case "$prev" in
-n|--limit)
COMPREPLY=()
return 0
;;
esac
mapfile -t COMPREPLY < <(compgen -W "-h --help -n --limit" -- "$cur")
}
_complete_gsync()
{
local cur
cur="${COMP_WORDS[COMP_CWORD]}"
if [[ $cur == -* ]]; then
mapfile -t COMPREPLY < <(compgen -W "-h --help" -- "$cur")
return 0
fi
_profile_git_complete_remotes
}
_complete_gacp()
{
local cur prev
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
case "$prev" in
-m|--message)
COMPREPLY=()
return 0
;;
esac
if [[ $cur == -* ]]; then
mapfile -t COMPREPLY < <(compgen -W "-h --help -a --auto -m --message" -- "$cur")
return 0
fi
_profile_git_complete_add_paths
}
_complete_greset()
{
local cur
cur="${COMP_WORDS[COMP_CWORD]}"
if [[ $cur == -* ]]; then
mapfile -t COMPREPLY < <(compgen -W "-h --help -x --with-ignored" -- "$cur")
return 0
fi
_profile_git_complete_refs
}
_complete_gwip()
{
local cur
cur="${COMP_WORDS[COMP_CWORD]}"
if [[ $cur == -* ]]; then
mapfile -t COMPREPLY < <(compgen -W "-h --help" -- "$cur")
else
COMPREPLY=()
fi
}
_complete_gprune()
{
local cur
cur="${COMP_WORDS[COMP_CWORD]}"
if [[ $cur == -* ]]; then
mapfile -t COMPREPLY < <(compgen -W "-h --help" -- "$cur")
return 0
fi
_profile_git_complete_refs
}
_complete_groot()
{
local cur
cur="${COMP_WORDS[COMP_CWORD]}"
mapfile -t COMPREPLY < <(compgen -W "-h --help -g --go" -- "$cur")
}
_profile_git_register_completions()
{
complete -F _complete_gst gst
complete -F _complete_ggraph ggraph
complete -F _complete_gsync gsync
complete -F _complete_gacp gacp
complete -F _complete_greset greset
complete -F _complete_gwip gwip
complete -F _complete_gprune gprune
complete -F _complete_groot groot
}
# Register completions only in interactive bash sessions.
if [[ $- == *i* && -n ${BASH_VERSION:-} ]]; then
_profile_git_load_completion_helpers >/dev/null 2>&1 || true
_profile_git_register_completions
fi
# EOF
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * 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.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * 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 OWNER 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,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# Prompt helper completions for profile.d/prompt.sh shortcuts.
# ------------------------------------------------------------------------------
_profile_prompt_complete_theme_names()
{
local theme_dir="${PROMPT_THEME_DIR:-${MYPATH}/profile.d/themes}"
[[ -d "$theme_dir" ]] || return 0
local theme_file theme_names=""
for theme_file in "$theme_dir"/*.theme; do
[[ -f "$theme_file" ]] || continue
theme_names+=" ${theme_file##*/}"
theme_names="${theme_names%.theme}"
done
printf "%s\n" "$theme_names"
}
_complete_set_theme()
{
local cur prev
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
case "$prev" in
-h|--help|-l|--list)
COMPREPLY=()
return 0
;;
-p|--preview|-S|--save)
;;
esac
if [[ "$cur" == -* ]]; then
mapfile -t COMPREPLY < <(compgen -W "-h --help -l --list -p --preview -S --save" -- "$cur")
return 0
fi
if [[ "$cur" == */* || "$cur" == .* ]]; then
mapfile -t COMPREPLY < <(compgen -f -X '!*.theme' -- "$cur")
return 0
fi
mapfile -t COMPREPLY < <(compgen -W "$(_profile_prompt_complete_theme_names)" -- "$cur")
}
if [[ $- == *i* && -n ${BASH_VERSION:-} ]]; then
complete -F _complete_set_theme set_theme
fi
# EOF
+484 -105
View File
@@ -34,6 +34,14 @@
# * OF SUCH DAMAGE. # * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# shellcheck disable=SC2317,SC2329 # nested helper functions are called through their name (shellcheck cannot follow them)
# Common helpers (helpers/common.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
load_conf "compress"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Smartly uncompress archives # Smartly uncompress archives
# Usage: utaz [option] [directorie(s)|file(s)] # Usage: utaz [option] [directorie(s)|file(s)]
@@ -42,99 +50,147 @@
# -d, --delete If decompression succeeded, delete the source file # -d, --delete If decompression succeeded, delete the source file
# -c, --create-dir Always create a host directory # -c, --create-dir Always create a host directory
# -n, --no-dir Never create a host directory # -n, --no-dir Never create a host directory
# -i, --ignore-case Match archive extensions case-insensitively (.ZIP, .Tar.GZ)
# -s, --case-sensitive Match archive extensions case-sensitively (overrides the
# UTAZ_DEFAULT_IGNORE_CASE configuration key)
utaz() utaz()
{ {
# Extractor helpers. Every one takes the archive path as $1 and an existing
# target directory as $2, and returns the extractor exit status (0 = ok,
# 1 = warning, anything else = failure). The matching one is selected by
# file extension and invoked by name (see the dispatch "case" below), and
# the name minus its "_un" prefix is the binary checked beforehand.
# NOTE: bash defines functions globally, so these helpers are (re)defined in
# the shell on each call and the "local" lines below only declare an unused
# variable of the same name; they are kept as a marker for shellcheck.
local _ununzip
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_ununzip() _ununzip()
{ {
unzip -o "$1" -d "$2" >/dev/null 2>&1 unzip -o "$1" -d "$2" >/dev/null 2>&1
} }
local _untar
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_untar() _untar()
{ {
tar -xf "$1" -C "$2" tar -xf "$1" -C "$2"
} }
local _ungzip
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_ungzip() _ungzip()
{ {
tar -xzf "$1" -C "$2" tar -xzf "$1" -C "$2"
} }
local _unbzip2
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_unbzip2() _unbzip2()
{ {
tar -xjf "$1" -C "$2" tar -xjf "$1" -C "$2"
} }
local _unxz
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_unxz() _unxz()
{ {
tar -xJf "$1" -C "$2" tar -xJf "$1" -C "$2"
} }
local _unlzop
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_unlzop() _unlzop()
{ {
lzop -d "$1" -o "$2/$(basename "${1%.*}")" lzop -d "$1" -o "$2/$(basename "${1%.*}")"
} }
local _unlzip
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_unlzip() _unlzip()
{ {
if command -v plzip >/dev/null 2>&1; then # Only *.tar.lz / *.tlz reach this helper: decompress to stdout and
plzip -d -c "$1" > "$2/$(basename "${1%.*}")" # untar from the pipe. pipefail (subshell-scoped) makes a lzip failure
else # visible instead of being masked by tar's exit status.
lzip -d -c "$1" > "$2/$(basename "${1%.*}")" local lzcmd=lzip
fi command -v plzip >/dev/null 2>&1 && lzcmd=plzip
(
set -o pipefail
"$lzcmd" -d -c -- "$1" | tar -xf - -C "$2"
)
} }
local _ununrar
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_ununrar() _ununrar()
{ {
unrar x -o+ "$1" "$2/" >/dev/null 2>&1 unrar x -o+ "$1" "$2/" >/dev/null 2>&1
} }
local _ununarj
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_ununarj() _ununarj()
{ {
unarj e "$1" "$2/" >/dev/null 2>&1 unarj e "$1" "$2/" >/dev/null 2>&1
} }
local _unlha
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_unlha() _unlha()
{ {
# lha typically extracts into the current directory # lha extracts into the current directory, so we cd into the target
# We ensure it hits the target directory # directory and feed it an absolute path to the archive.
(cd "$2" && lha -x "../$1") >/dev/null 2>&1 local src
src=$(realpath -- "$1") || return 1
(cd "$2" && lha -x "$src") >/dev/null 2>&1
} }
local _ununace
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_ununace() _ununace()
{ {
unace x "$1" "$2/" >/dev/null 2>&1 unace x "$1" "$2/" >/dev/null 2>&1
} }
local _un7z
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_un7z() _un7z()
{ {
7z x "$1" -o"$2/" >/dev/null 2>&1 7z x "$1" -o"$2/" >/dev/null 2>&1
} }
local _unzstd
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_unzstd() _unzstd()
{ {
# Zstd decompresses files directly, often requiring tar for archives # A tar.zst/.tzst is untarred; a bare .zst is a single compressed file
tar --zstd -xf "$1" -C "$2" # and is simply decompressed into the target directory.
# Called from utaz: "icase" is utaz's local, visible here through
# dynamic scoping. Lowercase the name so the match follows the mode.
local key=$1
[[ -n $icase ]] && key=${key,,}
case $key in
*.tar.zst|*.tzst)
tar --zstd -xf "$1" -C "$2"
;;
*)
zstd -d -q -c -- "$1" > "$2/$(basename "${1%.*}")"
;;
esac
} }
local _uncpio
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_uncpio() _uncpio()
{ {
# CPIO requires careful directory handling # cpio extracts into the current directory, so we cd into the target
(cd "$2" && cpio -id < "../$1") >/dev/null 2>&1 # directory and read from an absolute path to the archive.
local src
src=$(realpath -- "$1") || return 1
(cd "$2" && cpio -id < "$src") >/dev/null 2>&1
} }
local _uncabextract
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_uncabextract() _uncabextract()
{ {
@@ -142,6 +198,7 @@ utaz()
cabextract "$1" -d "$2/" >/dev/null 2>&1 cabextract "$1" -d "$2/" >/dev/null 2>&1
} }
local _undeb
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_undeb() _undeb()
{ {
@@ -149,22 +206,63 @@ utaz()
dpkg-deb -x "$1" "$2/" >/dev/null 2>&1 dpkg-deb -x "$1" "$2/" >/dev/null 2>&1
} }
local _unrpm
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_unrpm() _unrpm()
{ {
# Extracts CPIO-based payload from an RPM package # Extracts CPIO-based payload from an RPM package
# Needs rpm2cpio and cpio # Needs rpm2cpio and cpio. pipefail (subshell-scoped) so that a failing
rpm2cpio "$1" | (cd "$2/" && cpio -idmv) >/dev/null 2>&1 # rpm2cpio is not masked by cpio's exit status.
(
set -o pipefail
rpm2cpio "$1" | (cd "$2/" && cpio -idm)
) >/dev/null 2>&1
} }
# Move every entry (hidden ones included) of directory $1 up into its
# parent, then remove $1. Refuses (returns 1, $1 left untouched) if any
# entry would overwrite something already present in the parent.
# The directory is first renamed to a temporary name so that an archive
# holding a top-level entry named like the host directory (foo.zip -> foo/foo)
# does not collide with itself. Runs in a subshell so the shopt calls do not
# leak into the interactive shell.
local _utaz_flatten
# shellcheck disable=SC2329
_utaz_flatten()
(
shopt -s nullglob dotglob
local dir=$1 parent tmp child
parent=$(dirname -- "$dir")
tmp="${parent}/.utaz.$$.${RANDOM}"
mv -- "$dir" "$tmp" || return 1
for child in "$tmp"/*; do
if [[ -e "${parent}/${child##*/}" || -L "${parent}/${child##*/}" ]]; then
disp W "${parent}/${child##*/} already exists, not overwriting."
mv -- "$tmp" "$dir"
return 1
fi
done
for child in "$tmp"/*; do
mv -- "$child" "${parent}/" || return 1
done
rmdir -- "$tmp"
)
local PARSED local PARSED
PARSED=$(getopt -o hdcn --long help,delete,create-dir,no-dir -n 'utaz' -- "$@") PARSED=$(getopt -o hdcnis --long help,delete,create-dir,no-dir,ignore-case,case-sensitive -n 'utaz' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after # shellcheck disable=SC2181 # getopt return code is checked immediately after
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
disp E "Invalid options, use \"utaz --help\" to display usage." disp E "Invalid options, use \"utaz --help\" to display usage."
return 1 return 1
fi fi
eval set -- "$PARSED" # getopt emits a shell-quoted, normalised argument list ("--" terminates the
# options); _getopt_split rebuilds the arguments from it (spaces and quotes
# intact, no eval). The flags are declared local so that none leaks in from
# the caller's environment.
local willrm="" createdir="" nodir="" icase="" csens=""
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
-h|--help) -h|--help)
@@ -175,19 +273,23 @@ utaz()
printf "\t-h, --help\t\tDisplay that help screen\n" printf "\t-h, --help\t\tDisplay that help screen\n"
printf "\t-d, --delete\t\tIf decompression succeeded, delete the source file\n" printf "\t-d, --delete\t\tIf decompression succeeded, delete the source file\n"
printf "\t-c, --create-dir\tAlways create a host directory\n" printf "\t-c, --create-dir\tAlways create a host directory\n"
printf "\t-n, --no-dir\t\tNever create a host directory\n\n" printf "\t-n, --no-dir\t\tNever create a host directory\n"
printf "\t-i, --ignore-case\tMatch archive extensions case-insensitively\n"
printf "\t-s, --case-sensitive\tMatch archive extensions case-sensitively (default)\n\n"
printf "Supported archive format:\n" printf "Supported archive format:\n"
printf "\t- zip\n" printf "\t- zip\n"
printf "\t- tar.gz, .tgz\n" printf "\t- tar.gz, .tgz\n"
printf "\t- tar.bz2, .tbz2\n" printf "\t- tar.bz2, .tbz2\n"
printf "\t- tar.xz, .txz\n" printf "\t- tar.xz, .txz\n"
printf "\t- tar.lz, .tlz\n" printf "\t- tar.lz, .tlz\n"
printf "\t- lzo\n"
printf "\t- rar\n" printf "\t- rar\n"
printf "\t- arj\n" printf "\t- arj\n"
printf "\t- lha, lzh\n" printf "\t- lha, lzh\n"
printf "\t- ace\n" printf "\t- ace\n"
printf "\t- 7z, p7z\n" printf "\t- 7z, p7z\n"
printf "\t- zst\n" printf "\t- tar\n"
printf "\t- zst, tar.zst, .tzst\n"
printf "\t- cpio\n" printf "\t- cpio\n"
printf "\t- cab\n" printf "\t- cab\n"
printf "\t- deb\n" printf "\t- deb\n"
@@ -195,15 +297,23 @@ utaz()
return 0 return 0
;; ;;
-d|--delete) -d|--delete)
local willrm=1 willrm=1
shift shift
;; ;;
-c|--create-dir) -c|--create-dir)
local createdir=1 createdir=1
shift shift
;; ;;
-n|--no-dir) -n|--no-dir)
local nodir=1 nodir=1
shift
;;
-i|--ignore-case)
icase=1
shift
;;
-s|--case-sensitive)
csens=1
shift shift
;; ;;
--) --)
@@ -222,24 +332,48 @@ utaz()
[[ ${#FILES[@]} -eq 0 ]] && FILES=(".") [[ ${#FILES[@]} -eq 0 ]] && FILES=(".")
# Apply defaults from [compress] configuration if not overridden by flags # Apply defaults from [compress] configuration if not overridden by flags
[[ -z ${willrm+x} ]] && [[ ${UTAZ_DEFAULT_DELETE:-0} -eq 1 ]] && willrm=1 [[ -z $willrm ]] && [[ ${UTAZ_DEFAULT_DELETE:-0} -eq 1 ]] && willrm=1
if [[ -z ${createdir+x} && -z ${nodir+x} ]]; then if [[ -z $createdir && -z $nodir ]]; then
case "${UTAZ_DEFAULT_DIR_MODE:-auto}" in case "${UTAZ_DEFAULT_DIR_MODE:-auto}" in
always) createdir=1 ;; always) createdir=1 ;;
never) nodir=1 ;; never) nodir=1 ;;
esac esac
fi fi
[[ -n ${createdir} && -n ${nodir} ]] && \ [[ -n ${createdir} && -n ${nodir} ]] && {
disp E "The --create-dir and --no-dir options are mutually exclusive." disp E "The --create-dir and --no-dir options are mutually exclusive."
return 1
}
# Extension matching: the flags win over the configured default. After this
# block "icase" is non-empty if and only if matching is case-insensitive.
[[ -n $icase && -n $csens ]] && {
disp E "The --ignore-case and --case-sensitive options are mutually exclusive."
return 1
}
if [[ -z $icase && -z $csens && ${UTAZ_DEFAULT_IGNORE_CASE:-0} -eq 1 ]]; then
icase=1
fi
# Loop variables are local: this function lives in the interactive shell.
local zitem f
for zitem in "${FILES[@]}"; do for zitem in "${FILES[@]}"; do
# A name starting with "-" would be taken as an option by the tools below
[[ $zitem == -* ]] && zitem="./${zitem}"
# Build list of input files to process, with whitespace-safe handling. # Build list of input files to process, with whitespace-safe handling.
local targets=() local targets=()
if [[ -f "$zitem" ]]; then if [[ -f "$zitem" ]]; then
targets=("$zitem") targets=("$zitem")
elif [[ -d "$zitem" ]]; then elif [[ -d "$zitem" ]]; then
mapfile -d '' -t targets < <(find "$zitem" -mindepth 1 -maxdepth 1 -print0 2>/dev/null) # Only direct children are considered (no recursion); NUL-separated
# so names containing spaces or newlines survive.
# ("mapfile -d" needs bash 4.4, the profile supports 4.3)
local _zt
targets=()
while IFS= read -r -d '' _zt; do
targets+=("$_zt")
done < <(find "$zitem" -mindepth 1 -maxdepth 1 -print0 2>/dev/null)
if [[ ${#targets[@]} -eq 0 ]]; then if [[ ${#targets[@]} -eq 0 ]]; then
disp I "Directory ${zitem} is empty, skipping." disp I "Directory ${zitem} is empty, skipping."
continue continue
@@ -250,9 +384,19 @@ utaz()
fi fi
for f in "${targets[@]}"; do for f in "${targets[@]}"; do
# "key" is the name the extension patterns are matched against:
# lowercased in ignore-case mode, untouched otherwise.
local key=$f
[[ -n $icase ]] && key=${f,,}
# Host directory name: archive name without its extension. A
# trailing ".tar" is dropped too, so foo.tar.gz gives "foo", not
# "foo.tar". The ".tar" test uses "key" so it follows the case mode
# (the extension is 4 characters either way).
local dir="${f%.*}" local dir="${f%.*}"
[[ ${key%.*} == *.tar ]] && dir="${dir%.*}"
local extractor="" local extractor=""
case "$f" in case "$key" in
*.zip) *.zip)
extractor="_ununzip" extractor="_ununzip"
;; ;;
@@ -266,6 +410,9 @@ utaz()
extractor="_unxz" extractor="_unxz"
;; ;;
*.tar.lz|*.tlz) *.tar.lz|*.tlz)
extractor="_unlzip"
;;
*.lzo)
extractor="_unlzop" extractor="_unlzop"
;; ;;
*.tar) *.tar)
@@ -286,7 +433,7 @@ utaz()
*.7z|*.p7z) *.7z|*.p7z)
extractor="_un7z" extractor="_un7z"
;; ;;
*.zst) *.zst|*.tzst)
extractor="_unzstd" extractor="_unzstd"
;; ;;
*.cpio) *.cpio)
@@ -307,7 +454,9 @@ utaz()
;; # Skip non-archive files ;; # Skip non-archive files
esac esac
# Verify binary existence # Verify the extractor's binary exists. The helper name minus its
# "_un" prefix is the program name (_un7z -> 7z); deb/rpm/lzip need
# special handling because their programs are named differently.
local cmd=${extractor//_un/} local cmd=${extractor//_un/}
if [[ $cmd == "deb" ]]; then if [[ $cmd == "deb" ]]; then
command -v -- dpkg-deb >/dev/null 2>&1 || { command -v -- dpkg-deb >/dev/null 2>&1 || {
@@ -323,6 +472,12 @@ utaz()
disp E "The program 'cpio' is not installed, aborting." disp E "The program 'cpio' is not installed, aborting."
continue continue
} }
elif [[ $cmd == "lzip" ]]; then
# plzip is a drop-in parallel replacement for lzip
command -v -- lzip >/dev/null 2>&1 || command -v -- plzip >/dev/null 2>&1 || {
disp E "Neither 'lzip' nor 'plzip' is installed, skipping ${f}."
continue
}
else else
command -v -- "${cmd}" >/dev/null 2>&1 || { command -v -- "${cmd}" >/dev/null 2>&1 || {
disp E "Binary ${cmd} necessary to extract ${f} is missing." disp E "Binary ${cmd} necessary to extract ${f} is missing."
@@ -331,18 +486,23 @@ utaz()
fi fi
disp I "Processing archive ${f} with ${extractor}..." disp I "Processing archive ${f} with ${extractor}..."
if ! mkdir -p "${dir}"; then # Track whether we create the target directory so a corrupted
disp E "The filesystem can't create directories, exit!" && # archive never wipes a pre-existing directory of the same name.
return 1 local dir_created=0
[[ -d "${dir}" ]] || dir_created=1
if ! mkdir -p -- "${dir}"; then
disp E "Can't create directory ${dir}, skipping ${f}."
continue
fi fi
${extractor} "${f}" "${dir}" ${extractor} "${f}" "${dir}"
case $? in case $? in
0) 0)
[[ -n ${willrm} ]] && [[ -n ${willrm} ]] &&
rm -f "${f}" && disp I "File ${zitem}/${f} deleted." rm -f -- "${f}" && disp I "File ${f} deleted."
;; ;;
1) 1)
# Most extractors use 1 for "finished with warnings"
if [[ -n ${willrm} ]]; then if [[ -n ${willrm} ]]; then
disp W "Compression program returned a warning: deletion canceled." disp W "Compression program returned a warning: deletion canceled."
else else
@@ -351,40 +511,40 @@ utaz()
;; ;;
*) *)
disp E "The compressed file ${f} seems corrupted, failed." disp E "The compressed file ${f} seems corrupted, failed."
rm -rf "${dir}" >/dev/null 2>&1 [[ ${dir_created} -eq 1 ]] && rm -rf -- "${dir}" >/dev/null 2>&1
continue continue
;; ;;
esac esac
# Decide whether the host directory is kept. Flattening is never
# done on a directory that existed before extraction, as it would
# also move the user's own files out of it.
local flatten=0
if [[ -n ${createdir} ]]; then if [[ -n ${createdir} ]]; then
disp I "Archive extracted successfully in subdirectory." :
elif [[ ${dir_created} -ne 1 ]]; then
:
elif [[ -n ${nodir} ]]; then elif [[ -n ${nodir} ]]; then
shopt -s nullglob flatten=1
for child in "${dir}"/*; do else
mv -- "$child" . # Auto mode: flatten only if the archive already holds a single
done # top-level directory (it then acts as the host directory).
shopt -u nullglob # nullglob makes the array empty when nothing matches; dotglob
rmdir -- "${dir}" # makes hidden entries count, so ".git" alone is not ignored.
local _had_ng=0 _had_dg=0
shopt -q nullglob && _had_ng=1
shopt -q dotglob && _had_dg=1
shopt -s nullglob dotglob
local contents=( "${dir}"/* )
(( _had_ng )) || shopt -u nullglob
(( _had_dg )) || shopt -u dotglob
[[ ${#contents[@]} -eq 1 && -d "${contents[0]}" && ! -L "${contents[0]}" ]] && flatten=1
fi
if [[ $flatten -eq 1 ]] && _utaz_flatten "${dir}"; then
disp I "Archive extracted successfully, no subdirectory needed." disp I "Archive extracted successfully, no subdirectory needed."
else else
# Set nullglob to ensure the array is empty if no files match disp I "Archive extracted successfully in subdirectory."
shopt -s nullglob
local contents=( "${dir}"/* )
# Check if exactly one item exists and if that item is a directory
if [[ ${#contents[@]} -eq 1 ]] && [[ -d "${contents[0]}" ]]; then
# Single directory detected
shopt -s nullglob
for child in "${contents[0]}"/*; do
mv -- "$child" .
done
shopt -u nullglob
rmdir -- "${dir}"
disp I "Archive extracted successfully, no subdirectory needed."
else
disp I "Archive extracted successfully in subdirectory."
fi
shopt -u nullglob
fi fi
done done
done done
@@ -395,18 +555,54 @@ export -f utaz
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Compress directories or files into one or more archive # Compress directories or files into one or more archive
# Usage: taz [option] [--parallel=<n>] [--format=<format>] [directory1 ... directoryN] # Usage: taz [option] [--parallel=<n|auto>] [--format=<format>] [directory1 ... directoryN]
# Options: # Options:
# -h, --help Display that help screen # -h, --help Display that help screen
# -d, --delete Delete source file or directory after success # -d, --delete Delete source file or directory after success
# -f, --format Chose archive format in the given list. If several format are # -f, --format Chose archive format in the given list. If several format are
# given, the smalest is kept # given, the smalest is kept
# -p, --parallel Number of threads to use (if allowed by underlying utility) # -K, --keep-all Keep every produced version instead of only the smallest
# -p, --parallel Number of threads to use, or 'auto' to use detected CPU count
# -v, --verbose Display progress where possible # -v, --verbose Display progress where possible
# -q, --quiet Display less messages (only errors and warnings) # -q, --quiet Display less messages (only errors and warnings)
# -1, .., -9 Compression level to use [1=fast/biggest, 9=slow/smallest] # -1, .., -9 Compression level to use [1=fast/biggest, 9=slow/smallest]
taz() taz()
{ {
# Compressor helpers. All share the same signature:
# $1 = file to compress, $2 = thread count, $3 = level (1-9),
# $4 = non-empty for verbose output
# and keep the source (--keep), writing "$1.<ext>" next to it. They return
# 127 when no suitable program is installed (fatal for the whole run), any
# other non-zero status on a compression failure (that format is skipped).
# As with utaz, nested functions are global, "local" does not scope them.
# Resolve runtime CPU count for --parallel=auto.
local _taz_detect_cpus
_taz_detect_cpus()
{
local cpus=1
if command -v nproc >/dev/null 2>&1; then
cpus=$(nproc 2>/dev/null)
elif command -v getconf >/dev/null 2>&1; then
cpus=$(getconf _NPROCESSORS_ONLN 2>/dev/null)
fi
[[ $cpus =~ ^[1-9][0-9]*$ ]] || cpus=1
printf "%s\n" "$cpus"
}
# Render a byte count as a human-readable size (B/KB/MB/GB/...), base 1024.
local _taz_human
# shellcheck disable=SC2329
_taz_human()
{
if command -v numfmt >/dev/null 2>&1; then
numfmt --to=iec --suffix=B -- "$1" 2>/dev/null && return 0
fi
printf "%sB\n" "$1"
}
local _doxz
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_doxz() _doxz()
{ {
@@ -427,10 +623,11 @@ taz()
return $? return $?
} }
local _dolz
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_dolz() _dolz()
{ {
local procopt="--threads $2" local procopt=(--threads "$2")
local command=plzip local command=plzip
command -v plzip >/dev/null 2>&1 || { command -v plzip >/dev/null 2>&1 || {
@@ -439,7 +636,7 @@ taz()
return 127 return 127
} }
command=lzip command=lzip
local procopt="" local procopt=()
[[ $2 -gt 1 ]] && [[ $2 -gt 1 ]] &&
disp W "lzip doesn't support multithreading, falling back to 1 thread." && disp W "lzip doesn't support multithreading, falling back to 1 thread." &&
disp W "Consider installing plzip to obtain multithreading abilities." disp W "Consider installing plzip to obtain multithreading abilities."
@@ -447,17 +644,18 @@ taz()
local opt=() local opt=()
[[ $4 ]] && opt=('-vv') [[ $4 ]] && opt=('-vv')
opt+=("$procopt") opt+=("${procopt[@]}")
# Compress with lzip (lzma) # Compress with lzip (lzma)
$command "${opt[@]}" --keep "-$3" "$1" $command "${opt[@]}" --keep "-$3" "$1"
return $? return $?
} }
local _dogz
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_dogz() _dogz()
{ {
local procopt="--processes $2" local procopt=(--processes "$2")
local command=pigz local command=pigz
command -v pigz >/dev/null 2>&1 || { command -v pigz >/dev/null 2>&1 || {
@@ -465,8 +663,8 @@ taz()
disp E "Programs 'pigz' or 'gzip' are not installed, aborting." disp E "Programs 'pigz' or 'gzip' are not installed, aborting."
return 127 return 127
} }
local command="gzip --compress" local command=gzip
local procopt="" local procopt=()
[[ $2 -gt 1 ]] && [[ $2 -gt 1 ]] &&
disp W "gzip doesn't support multithreading, falling back to 1 thread." && disp W "gzip doesn't support multithreading, falling back to 1 thread." &&
disp W "Consider installing pigz to obtain multithreading abilities." disp W "Consider installing pigz to obtain multithreading abilities."
@@ -474,17 +672,18 @@ taz()
local opt=() local opt=()
[[ $4 ]] && opt=('--verbose') [[ $4 ]] && opt=('--verbose')
opt+=("$procopt") opt+=("${procopt[@]}")
# Compresse au format bz2 # Compress with gzip
$command "${opt[@]}" --keep "-$3" "$1" $command "${opt[@]}" --keep "-$3" "$1"
return $? return $?
} }
local _dobz2
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_dobz2() _dobz2()
{ {
local procopt="-p$2" local procopt=("-p$2")
local command=pbzip2 local command=pbzip2
command -v pbzip2 >/dev/null 2>&1 || { command -v pbzip2 >/dev/null 2>&1 || {
@@ -493,7 +692,7 @@ taz()
return 127 return 127
} }
local command=bzip2 local command=bzip2
local procopt="" local procopt=()
[[ $2 -gt 1 ]] && [[ $2 -gt 1 ]] &&
disp W "bzip2 doesn't support multithreading, falling back to 1 thread." && disp W "bzip2 doesn't support multithreading, falling back to 1 thread." &&
disp W "Consider installing pbzip2 to obtain multithreading abilities." disp W "Consider installing pbzip2 to obtain multithreading abilities."
@@ -501,13 +700,14 @@ taz()
local opt=() local opt=()
[[ $4 ]] && opt=('-v') [[ $4 ]] && opt=('-v')
opt+=("$procopt") opt+=("${procopt[@]}")
# Compress with bz2 # Compress with bz2
$command "${opt[@]}" --compress --keep "-$3" "$1" $command "${opt[@]}" --compress --keep "-$3" "$1"
return $? return $?
} }
local _dolzo
# shellcheck disable=SC2329 # shellcheck disable=SC2329
_dolzo() _dolzo()
{ {
@@ -520,30 +720,38 @@ taz()
[[ $4 ]] && verb=('-v') [[ $4 ]] && verb=('-v')
[[ $2 -gt 1 ]] && disp W "lzop doesn't support multithreading, falling back to 1 thread." [[ $2 -gt 1 ]] && disp W "lzop doesn't support multithreading, falling back to 1 thread."
# Compresse au format lzo # Compress with lzo
lzop "${verb[@]}" --keep "-$3" "$1" lzop "${verb[@]}" --keep "-$3" "$1"
return $? return $?
} }
local PARSED local PARSED
PARSED=$(getopt -o hdf:p:vq123456789 --long help,delete,format:,parallel:,verbose,quiet --name "taz" -- "$@") PARSED=$(getopt -o hdf:Kp:vq123456789 --long help,delete,format:,keep-all,parallel:,verbose,quiet --name "taz" -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after # shellcheck disable=SC2181 # getopt return code is checked immediately after
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
disp E "Invalid options, use \"taz --help\" to display usage." disp E "Invalid options, use \"taz --help\" to display usage."
return 1 return 1
fi fi
eval set -- "$PARSED" # See utaz: restore the getopt-normalised argument list, and keep every
# flag local so nothing leaks in from the caller's environment.
local willrm="" keepall="" verbose="" quiet="" nproc="" complevel=""
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
local compforms=()
local _f _list
while true; do while true; do
case "$1" in case "$1" in
-h|--help) -h|--help)
printf "taz: archive all files of a directory.\n\n" printf "taz: archive all files of a directory.\n\n"
printf "Usage: taz [option] [--parallel=<n>] [--format=<format>] [directory1 ... directoryN]\n\n" printf "Usage: taz [option] [--parallel=<n|auto>] [--format=<format>] [directory1 ... directoryN]\n\n"
printf "Options:\n" printf "Options:\n"
printf "\t-h, --help\tDisplay that help screen\n" printf "\t-h, --help\tDisplay that help screen\n"
printf "\t-d, --delete\tDelete source file or directory after success\n" printf "\t-d, --delete\tDelete source file or directory after success\n"
printf "\t-f, --format\tChose archive format in the given list. If several format are" printf "\t-f, --format\tChose archive format in the given list. If several format are\n"
printf "\t\t\tgiven, the smalest is kept\n" printf "\t\t\tgiven, the smalest produced archive is kept\n"
printf "\t-p, --parallel\tNumber of threads to use (if allowed by underlying utility)\n" printf "\t-K, --keep-all\tKeep every produced version instead of only the smallest\n"
printf "\t-p, --parallel\tNumber of threads, or 'auto' for runtime CPU count\n"
printf "\t-v, --verbose\tDisplay progress where possible\n" printf "\t-v, --verbose\tDisplay progress where possible\n"
printf "\t-q, --quiet\tDisplay less messages (only errors and warnings)\n" printf "\t-q, --quiet\tDisplay less messages (only errors and warnings)\n"
printf "\t-1, .., -9\tCompression level to use [1=fast/biggest, 9=slow/smallest]\n\n" printf "\t-1, .., -9\tCompression level to use [1=fast/biggest, 9=slow/smallest]\n\n"
@@ -561,27 +769,36 @@ taz()
;; ;;
-d|--delete) -d|--delete)
local willrm=1 willrm=1
shift shift
;; ;;
-f|--format) -f|--format)
local compform=$2 # Accept comma/space separated lists and repeated -f flags.
# "read -a" splits without pathname expansion (unlike an unquoted
# expansion, which would glob "*" against the current directory).
IFS=', ' read -ra _list <<< "$2"
compforms+=("${_list[@]}")
shift 2 shift 2
;; ;;
-K|--keep-all)
keepall=1
shift
;;
-p|--parallel) -p|--parallel)
local nproc=$2 nproc=$2
shift 2 shift 2
;; ;;
-v|--verbose) -v|--verbose)
local verbose=1 verbose=1
shift shift
;; ;;
-q|--quiet) -q|--quiet)
local quiet=1 quiet=1
shift shift
;; ;;
@@ -603,62 +820,224 @@ taz()
# The remaining arguments after -- are the files/directories to process, # The remaining arguments after -- are the files/directories to process,
# "." is used if none is given # "." is used if none is given
local FILES=("$@") local FILES=("$@")
[[ ${#FILES[@]} -eq 0 ]] && FILES=(".") [[ ${#FILES[@]} -eq 0 ]] && FILES=(".")
[[ ! $compform ]] && compform=${TAZ_DEFAULT_FORMAT:-lz} # Defaults from the [compress] configuration for anything not set by flags
[[ ! $nproc ]] && nproc=${TAZ_DEFAULT_THREADS:-1} [[ -z $nproc ]] && nproc=${TAZ_DEFAULT_THREADS:-auto}
[[ ! $complevel ]] && complevel=${TAZ_DEFAULT_LEVEL:-6} [[ -z $complevel ]] && complevel=${TAZ_DEFAULT_LEVEL:-6}
[[ $verbose -gt 1 && $quiet -gt 1 ]] && if [[ ! $complevel =~ ^[1-9]$ ]]; then
disp E "Invalid compression level '${complevel}' (expected 1 to 9)."
return 1
fi
[[ -n $verbose && -n $quiet ]] && {
disp E "The --verbose and --quiet options can't be used together." disp E "The --verbose and --quiet options can't be used together."
return 1
}
# disp() hides info messages when QUIET=1; being local, this only lasts
# for the duration of this call (dynamic scoping).
# shellcheck disable=SC2034 # read by disp
[[ -n $quiet ]] && local QUIET=1
# Fall back to the configured (or built-in) default format list.
if [[ ${#compforms[@]} -eq 0 ]]; then
IFS=', ' read -ra _list <<< "${TAZ_DEFAULT_FORMAT:-lz}"
compforms+=("${_list[@]}")
fi
# Reject unknown formats early so we never dispatch to a missing _do*.
# Membership test: the padded list is matched against " <fmt> " so that
# only whole words match (e.g. "z" or "lz xz" are rejected).
local _supported=" lz xz bz2 gz lzo tar "
for _f in "${compforms[@]}"; do
[[ $_supported == *" $_f "* ]] || {
disp E "Unsupported format '${_f}' (supported: lz xz bz2 gz lzo tar)."
return 1
}
done
# Backward compatibility: 0 previously meant auto-detect.
[[ $nproc == 0 ]] && nproc=auto
if [[ $nproc == auto ]]; then
nproc=$(_taz_detect_cpus)
elif [[ ! $nproc =~ ^[1-9][0-9]*$ ]]; then
disp E "Invalid value for --parallel: '$nproc' (expected auto or a positive integer)."
return 1
fi
# Map each format to the extension its compressor appends to the input
# name. "tar" is handled separately (its output is always "$item.tar").
local -A _taz_ext=( [lz]=lz [xz]=xz [bz2]=bz2 [gz]=gz [lzo]=lzo )
local item
for item in "${FILES[@]}"; do for item in "${FILES[@]}"; do
local donetar=0 local donetar=0
# Arguments naming the tar members; the default keeps the path as typed
local tar_src=()
# Normalise the name, since archive names are built as "$item.<ext>":
# - strip trailing slashes (shell completion adds them), otherwise
# "dir/.tar" would be created inside the directory being archived;
# - "." / ".." (or "x/..") have no usable name, so resolve them;
# - protect names starting with "-" from being read as options.
while [[ ${#item} -gt 1 && $item == */ ]]; do
item=${item%/}
done
case ${item##*/} in
.|..)
item=$(realpath -- "$item") || continue
# Store the bare directory name, not its absolute path
tar_src=(-C "${item%/*}" -- "${item##*/}")
;;
esac
if [[ $item == / ]]; then
disp E "Refusing to archive the root directory, skipping."
continue
fi
[[ $item == -* ]] && item="./${item}"
[[ ${#tar_src[@]} -eq 0 ]] && tar_src=(-- "$item")
disp I "Processing $item..." disp I "Processing $item..."
if [[ ! -e $item ]]; then
disp W "Path ${item} does not exist, skipping."
continue
fi
if [[ -d "$item" ]]; then if [[ -d "$item" ]]; then
# Directories can only be compressed through an intermediate tar.
if [[ -e "$item.tar" ]]; then
disp E "${item}.tar already exists, not overwriting it, skipping."
continue
fi
disp I "\t Creating $item.tar... " disp I "\t Creating $item.tar... "
if ! tar -cf "$item.tar" "$item"; then if ! tar -cf "$item.tar" "${tar_src[@]}"; then
disp E "tar file creation failed, skipping to next item." disp E "tar file creation failed, skipping to next item."
rm -f -- "$item.tar"
continue continue
fi fi
local donetar=1 donetar=1
fi fi
# File actually handed to the compressors
local fname=$item local fname=$item
[[ $donetar -gt 0 ]] && fname=$item.tar [[ $donetar -gt 0 ]] && fname=$item.tar
# Skip compression part if tar is asked # Compress the archive with every requested format; the smallest
if [[ $compform != "tar" ]]; then # resulting file is kept and the others are discarded (unless --keep-all).
disp I "\t Compressing archive..." local candidates=() fmt exec_code aborted=0 tar_requested=0
local exec_code=0 for fmt in "${compforms[@]}"; do
"_do$compform" "$fname" "$nproc" "$complevel" "$verbose" || exec_code=$? if [[ $fmt == tar ]]; then
[[ ! $exec_code -eq 0 ]] && case $exec_code in tar_requested=1
if [[ $donetar -eq 0 ]]; then
# Plain file: "tar" means wrapping it in a tar. It must be
# a separate candidate, never the source file itself, as
# non-winning candidates get deleted below.
if [[ -e "$item.tar" ]]; then
disp W "${item}.tar already exists, skipping the tar format."
continue
fi
if ! tar -cf "$item.tar" "${tar_src[@]}"; then
disp W "tar creation failed, skipping this format."
rm -f -- "$item.tar"
continue
fi
fi
candidates+=("$item.tar")
continue
fi
disp I "\t Compressing archive with ${fmt}..."
# "_do<fmt>" is resolved dynamically (formats were validated above).
# "|| exec_code=$?" captures the status without tripping set -e.
exec_code=0
"_do$fmt" "$fname" "$nproc" "$complevel" "$verbose" || exec_code=$?
case $exec_code in
0)
candidates+=("$fname.${_taz_ext[$fmt]}")
;;
127) 127)
disp E "Compression program unavailable, aborting." disp E "Compression program for '${fmt}' unavailable, aborting."
return 127 aborted=1
break
;; ;;
*) *)
disp E "Compression program returned an error, not deleting anything if asked, skipping to next item." disp W "Compression with '${fmt}' failed, skipping this format."
continue
;; ;;
esac esac
done
[[ $donetar -gt 0 ]] && rm "$fname" # Remove partial output produced before an unrecoverable abort.
local c
if [[ $aborted -eq 1 ]]; then
for c in "${candidates[@]}"; do
[[ $c != "$fname" ]] && rm -f -- "$c"
done
[[ $donetar -gt 0 ]] && rm -f -- "$fname"
return 127
fi
# Select the smallest archive among the produced candidates.
# _csize maps each existing candidate to its size in bytes; winner_size
# starts at -1 meaning "no candidate seen yet".
local winner="" winner_size=-1 sz
local -A _csize=()
for c in "${candidates[@]}"; do
[[ -f $c ]] || continue
sz=$(stat -c%s -- "$c" 2>/dev/null) || continue
_csize["$c"]=$sz
if [[ $winner_size -lt 0 || $sz -lt $winner_size ]]; then
winner_size=$sz
winner=$c
fi
done
if [[ -z $winner ]]; then
disp E "No archive could be produced for ${item}, skipping to next item."
[[ $donetar -gt 0 ]] && rm -f -- "$fname"
continue
fi
# In verbose mode, list every produced archive with its size and how
# much larger it is than the smallest one that will be kept.
if [[ -n $verbose && ${#_csize[@]} -gt 0 ]]; then
disp I "\t Produced archives:"
for c in "${candidates[@]}"; do
[[ -n ${_csize[$c]+x} ]] || continue
if [[ $c == "$winner" ]]; then
disp "\t ${c} $(_taz_human "${_csize[$c]}") (kept, smallest)"
else
disp "\t ${c} $(_taz_human "${_csize[$c]}") (+$(_taz_human "$(( ${_csize[$c]} - winner_size ))"))"
fi
done
fi
# Discard every archive but the smallest, unless --keep-all was given.
# Either way, drop the intermediate tar when it was not itself a
# requested output format.
if [[ -z $keepall ]]; then
for c in "${candidates[@]}"; do
[[ $c != "$winner" ]] && rm -f -- "$c"
done
fi
[[ $donetar -gt 0 && $tar_requested -eq 0 ]] && rm -f -- "$fname"
if [[ -n $keepall ]]; then
[[ ${#compforms[@]} -gt 1 ]] &&
disp I "\t Kept all produced archives (smallest: ${winner}, $(_taz_human "$winner_size"))."
else
[[ ${#compforms[@]} -gt 1 ]] &&
disp I "\t Kept smallest archive: ${winner} ($(_taz_human "$winner_size"))."
fi fi
if [[ $willrm ]]; then if [[ $willrm ]]; then
disp I "\t Deleting original source as asked... " disp I "\t Deleting original source as asked... "
rm -r "$item" rm -r -- "$item"
fi fi
done done
unset quiet
} }
export -f taz export -f taz
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
load_conf "compress"
# EOF # EOF
+512
View File
@@ -0,0 +1,512 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * 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.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * 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 OWNER 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,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# Common helpers, then the ones private to this module (helpers/conf.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
_profile_load_helpers "${BASH_SOURCE[0]}"
# ------------------------------------------------------------------------------
# Save or update a key=value pair in a section of the profile configuration file.
# The user configuration ($HOME/.profile.conf) is updated when it exists,
# otherwise the installation configuration ($PROFILE_CONF or $MYPATH/profile.conf)
# is used. The section header is created automatically when absent.
#
# An existing key is replaced in place (duplicates in the same section are
# dropped); a new key is added at the end of the section's active lines, before
# its trailing blank/comment lines, so it never ends up under the banner of the
# next section. The running shell is NOT updated: the value is read again by
# parse_conf/load_conf the next time the profile is sourced.
#
# The value is stored verbatim, but parse_conf trims surrounding whitespace and
# quotes, drops backticks and "$(", and expands "$VAR" references when it reads
# the file back; values relying on those are therefore not preserved.
#
# Usage: conf_save <section> <key> <value>
# section : INI section name without brackets, e.g. "prompt" for [prompt]
# key : variable name to set (alphanumeric and underscore only)
# value : value to assign (may be empty, must be a single line)
conf_save()
{
if [[ $# -ne 3 ]]; then
disp E "Usage: conf_save <section> <key> <value>"
return 1
fi
local section="$1" key="$2" value="$3"
if ! [[ "$section" =~ ^[a-zA-Z0-9_]+$ ]]; then
disp E "conf_save: invalid section name '${section}' (alphanumeric and underscore only)."
return 1
fi
if ! [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
disp E "conf_save: invalid key name '${key}' (alphanumeric and underscore only)."
return 1
fi
# A line break would split the entry and corrupt the file.
if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then
disp E "conf_save: the value must not contain line breaks."
return 1
fi
local conf_file
conf_file=$(_conf_path)
# Follow a symlink (e.g. dotfiles manager) so the link itself is not
# replaced by a regular file when the temporary file is moved over it.
[[ -L "$conf_file" ]] && conf_file=$(readlink -f -- "$conf_file")
# Directory part of the path; a bare file name lives in the current directory.
local conf_dir=.
[[ "$conf_file" == */* ]] && conf_dir="${conf_file%/*}"
[[ -d "$conf_dir" ]] || mkdir -p "$conf_dir" || {
disp E "conf_save: unable to create configuration directory: ${conf_dir}"
return 1
}
if [[ ! -e "$conf_file" ]]; then
{
printf "[%s]\n" "$section"
printf "%s=%s\n" "$key" "$value"
} > "$conf_file" || {
disp E "conf_save: unable to write configuration file: ${conf_file}"
return 1
}
return 0
fi
# Temporary file next to the target so the final mv is an atomic rename.
local tmp_file="${conf_file}.tmp.$$"
# The value is passed via ENVIRON (not awk -v) so backslashes are preserved
# verbatim; awk -v would interpret C escape sequences (\t, \n, \e, ...).
# awk state machine: in_sec is true while inside the target section, wrote
# once the key has been emitted, saw_sec once the section has been seen.
_CONF_SAVE_VAL="$value" \
awk -v sec="$section" -v key="$key" '
# Emit the held trailing blank/comment lines
function flush_pending( i) {
for (i = 1; i <= np; i++) print pend[i]
np = 0
}
BEGIN {
val = ENVIRON["_CONF_SAVE_VAL"]
in_sec = 0
saw_sec = 0
wrote = 0
np = 0
}
{
# Section header (leading blanks allowed, as parse_conf does)
if ($0 ~ /^[[:space:]]*\[[^]]+\][[:space:]]*$/) {
# Leaving the target section without having found the key:
# add it now, before the section trailing comments.
if (in_sec && !wrote) {
print key "=" val
wrote = 1
}
flush_pending()
if ($0 ~ ("^[[:space:]]*\\[" sec "\\][[:space:]]*$")) {
in_sec = 1
saw_sec = 1
} else {
in_sec = 0
}
print
next
}
# Until the key is written, hold blank and comment lines: if the
# section ends without the key, it must go before them.
if (in_sec && !wrote && $0 ~ /^[[:space:]]*([#;].*)?$/) {
pend[++np] = $0
next
}
flush_pending()
# Existing assignment of the key: replace the first, drop the others
if (in_sec && $0 ~ ("^[[:space:]]*" key "[[:space:]]*=")) {
if (!wrote) {
print key "=" val
wrote = 1
}
next
}
print
}
END {
if (in_sec && !wrote) {
print key "=" val
wrote = 1
}
flush_pending()
if (!saw_sec) {
if (NR > 0) print ""
print "[" sec "]"
print key "=" val
}
}
' "$conf_file" > "$tmp_file" || {
rm -f "$tmp_file"
disp E "conf_save: unable to update configuration file: ${conf_file}"
return 1
}
# mktemp-style default permissions must not replace the original ones
# (the file may hold private values); best effort, GNU chmod only.
chmod --reference="$conf_file" -- "$tmp_file" 2>/dev/null
mv -- "$tmp_file" "$conf_file" || {
rm -f "$tmp_file"
disp E "conf_save: unable to replace configuration file: ${conf_file}"
return 1
}
}
export -f conf_save
# ------------------------------------------------------------------------------
# Display the profile configuration file, with optional section and key filters.
# The same file resolution as conf_save is used: $HOME/.profile.conf when
# present, otherwise $PROFILE_CONF or $MYPATH/profile.conf.
#
# Usage: conf_dump [options] [pattern]
# -s, --section NAME : Only display the given section
# -a, --all : Also show commented-out keys (default values, in white)
# pattern : Only display keys whose name contains this substring
#
# Output colours:
# green — key is explicitly set (uncommented) in the config file
# white — key is present but commented out (shows the default value)
conf_dump()
{
local section="" key_pattern="" show_all=0
local PARSED
PARSED=$(getopt -o hs:a --long help,section:,all -n 'conf_dump' -- "$@")
# shellcheck disable=SC2181
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"conf_dump --help\" to display usage."
return 1
fi
# Restore the getopt-normalised (shell-quoted) argument list, "--" ends options
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "conf_dump: Display the profile configuration file, with optional filters.\n\n"
printf "Usage: conf_dump [options] [pattern]\n\n"
printf "Options:\n"
printf "\t-h, --help\t\tDisplay this help screen\n"
printf "\t-s, --section NAME\tOnly display the given section\n"
printf "\t-a, --all\t\tAlso show commented-out keys (default values)\n\n"
printf "Arguments:\n"
printf "\tpattern\tOnly display keys whose name contains this substring\n\n"
printf "Output colours:\n"
printf "\t\033[1;32mgreen\033[0m — key is explicitly set (active)\n"
printf "\t\033[1;97mwhite\033[0m — key is commented out (default value)\n"
return 0
;;
-s|--section)
section="$2"
shift 2
;;
-a|--all)
show_all=1
shift
;;
--)
shift
break
;;
*)
disp E "Invalid option, use \"conf_dump --help\" to display options list"
return 1
;;
esac
done
[[ $# -gt 0 ]] && key_pattern="$1"
local conf_file
conf_file=$(_conf_path)
if [[ ! -f "$conf_file" ]]; then
disp E "conf_dump: configuration file not found: ${conf_file}"
return 1
fi
# Honour NO_COLOR like disp does: empty colour strings give plain output.
local c_sec="${Blue:-}" c_active="${BGreen:-}" c_default="${BIWhite:-}" c_rst="${RESETCOL:-}"
if [[ -n ${NO_COLOR:-} ]]; then
c_sec="" c_active="" c_default="" c_rst=""
fi
# Everything is passed via ENVIRON, not awk -v, to avoid awk interpreting
# backslash escapes (colours hold literal \e sequences; a filter could
# contain a backslash). The file is given twice: the first pass only
# collects the active keys, the second one prints.
_CONF_DUMP_SEC="$c_sec" \
_CONF_DUMP_KEY_ACTIVE="$c_active" \
_CONF_DUMP_KEY_DEFAULT="$c_default" \
_CONF_DUMP_RST="$c_rst" \
_CONF_DUMP_SECF="$section" \
_CONF_DUMP_KEYF="$key_pattern" \
awk -v show_all="$show_all" '
BEGIN {
c_sec = ENVIRON["_CONF_DUMP_SEC"]
c_active = ENVIRON["_CONF_DUMP_KEY_ACTIVE"]
c_default = ENVIRON["_CONF_DUMP_KEY_DEFAULT"]
c_rst = ENVIRON["_CONF_DUMP_RST"]
sec_filter = ENVIRON["_CONF_DUMP_SECF"]
key_filter = ENVIRON["_CONF_DUMP_KEYF"]
# Shell colour vars contain literal \e[…m strings; convert to
# the actual ESC byte so awk print emits real ANSI sequences.
gsub(/\\e/, "\033", c_sec)
gsub(/\\e/, "\033", c_active)
gsub(/\\e/, "\033", c_default)
gsub(/\\e/, "\033", c_rst)
in_target = 0
current_sec = ""
hdr_printed = 0
found = 0
# active[sec:key] — keys set (uncommented) anywhere in the file,
# filled by the first pass.
# seen[sec:key] — keys already printed, to show each commented
# default at most once.
}
# First pass (FNR == NR): record the active keys, print nothing.
# An active key suppresses its commented default wherever the latter
# appears in the section (before or after it).
FNR == NR {
line = $0; sub(/\r$/, "", line)
if (line ~ /^[[:space:]]*\[[^]]+\][[:space:]]*$/) {
pass1_sec = line
sub(/^[[:space:]]*\[/, "", pass1_sec)
sub(/\][[:space:]]*$/, "", pass1_sec)
} else if (line ~ /^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=/) {
k = line; sub(/[[:space:]]*=.*$/, "", k); sub(/^[[:space:]]*/, "", k)
active[pass1_sec ":" k] = 1
}
next
}
{
sub(/\r$/, "")
# Section header (leading blanks allowed, as parse_conf does)
if ($0 ~ /^[[:space:]]*\[[^]]+\][[:space:]]*$/) {
current_sec = $0
sub(/^[[:space:]]*\[/, "", current_sec)
sub(/\][[:space:]]*$/, "", current_sec)
in_target = (sec_filter == "" || current_sec == sec_filter)
hdr_printed = 0
next
}
if (!in_target) next
# Active (uncommented) key=value — always shown
if ($0 ~ /^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=/) {
key = $0; sub(/[[:space:]]*=.*$/, "", key); sub(/^[[:space:]]*/, "", key)
val = $0; sub(/^[^=]*=/, "", val)
if (key_filter != "" && index(key, key_filter) == 0) next
if (!hdr_printed) {
if (found) print ""
print c_sec "[" current_sec "]" c_rst
hdr_printed = 1; found = 1
}
seen[current_sec ":" key] = 1
print " " c_active key c_rst "=" val
next
}
# Commented-out key=value — shown only with show_all, and only when
# the key is not set for real anywhere in the same section.
# The value displayed is the live environment value (what load_conf
# actually exported); when the key is not set in the environment we
# fall back to the value written in the comment, i.e. the documented
# default. Each key is shown at most once.
if (show_all && $0 ~ /^[[:space:]]*#[[:space:]]*[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=/) {
line = $0; sub(/^[[:space:]]*#[[:space:]]*/, "", line)
key = line; sub(/[[:space:]]*=.*$/, "", key); sub(/^[[:space:]]*/, "", key)
if (active[current_sec ":" key] || seen[current_sec ":" key]) next
seen[current_sec ":" key] = 1
if (key_filter != "" && index(key, key_filter) == 0) next
val = ENVIRON[key]
if (val == "") { val = line; sub(/^[^=]*=[[:space:]]*/, "", val) }
if (!hdr_printed) {
if (found) print ""
print c_sec "[" current_sec "]" c_rst
hdr_printed = 1; found = 1
}
print " " c_default key c_rst "=" val
}
}
' "$conf_file" "$conf_file"
}
export -f conf_dump
# ------------------------------------------------------------------------------
# Set TERM to the best terminal capability available on this system.
# Called automatically at sourcing time.
#
# If TERM is already set to a specific value (not empty, not the sentinel
# "smart"), it is honoured as-is — standard Unix behaviour.
# If TERM is empty or set to "smart", multiplexer hints are checked first
# (TMUX, STY), then terminal emulator hints (TERM_PROGRAM, VTE_VERSION,
# WT_SESSION), then terminfo is probed in preference order:
# xterm-256color → xterm-color → xterm → vt100. COLORTERM=truecolor|24bit
# enables the *-direct (24-bit) entries in those probes.
#
# Usage: term_set
term_set()
{
local _current="${TERM:-}"
# Specific value already set — nothing to do.
if [[ -n "$_current" && "$_current" != "smart" ]]; then
export TERM="$_current"
return 0
fi
# Return true when terminfo has an entry for the given terminal type.
# (Nested functions are global in bash; it is removed with unset -f below.)
local _term_has
_term_has()
{
tput -T "$1" longname >/dev/null 2>&1
}
# True-color flag: COLORTERM=truecolor|24bit is set by the emulator. It does
# not select an entry by itself; it only enables the *-direct entries in the
# probes below, each of which falls back to its *-256color counterpart.
local _truecolor=0
[[ "${COLORTERM:-}" == "truecolor" || "${COLORTERM:-}" == "24bit" ]] && _truecolor=1
local _candidate=""
# Probes run in priority order and stop at the first terminfo entry found
# (_candidate non-empty). Multiplexers come first: they provide their own
# terminal and inherit the emulator's variables (TERM_PROGRAM, VTE_VERSION,
# ...), which would otherwise wrongly select the outer emulator's entry.
# 1. tmux — prefer *-direct when truecolor, then *-256color, then screen-256color.
if [[ -n "${TMUX:-}" ]]; then
if (( _truecolor )); then
_term_has "tmux-direct" && _candidate="tmux-direct"
[[ -z "$_candidate" ]] && _term_has "screen-direct" && _candidate="screen-direct"
fi
[[ -z "$_candidate" ]] && _term_has "tmux-256color" && _candidate="tmux-256color"
[[ -z "$_candidate" ]] && _term_has "screen-256color" && _candidate="screen-256color"
fi
# 2. GNU screen — prefer screen-direct when truecolor, then screen-256color.
if [[ -z "$_candidate" && -n "${STY:-}" ]]; then
if (( _truecolor )); then
_term_has "screen-direct" && _candidate="screen-direct"
fi
[[ -z "$_candidate" ]] && _term_has "screen-256color" && _candidate="screen-256color"
[[ -z "$_candidate" ]] && _term_has "screen" && _candidate="screen"
fi
# 3. Terminal programme name hints.
if [[ -z "$_candidate" ]]; then
case "${TERM_PROGRAM:-}" in
iTerm.app)
if (( _truecolor )); then
_term_has "xterm-direct" && _candidate="xterm-direct"
fi
[[ -z "$_candidate" ]] && _term_has "xterm-256color" && _candidate="xterm-256color"
;;
WezTerm)
if (( _truecolor )); then
_term_has "xterm-direct" && _candidate="xterm-direct"
fi
if [[ -z "$_candidate" ]]; then
_term_has "wezterm" && _candidate="wezterm"
fi
[[ -z "$_candidate" ]] && _term_has "xterm-256color" && _candidate="xterm-256color"
;;
Hyper|vscode)
if (( _truecolor )); then
_term_has "xterm-direct" && _candidate="xterm-direct"
fi
[[ -z "$_candidate" ]] && _term_has "xterm-256color" && _candidate="xterm-256color"
;;
esac
fi
# 4. VTE-based terminals (GNOME Terminal, Tilix, Xfce Terminal, …).
if [[ -z "$_candidate" && -n "${VTE_VERSION:-}" ]]; then
if (( _truecolor )); then
_term_has "vte-direct" && _candidate="vte-direct"
fi
[[ -z "$_candidate" ]] && _term_has "vte-256color" && _candidate="vte-256color"
[[ -z "$_candidate" ]] && _term_has "xterm-256color" && _candidate="xterm-256color"
fi
# 5. Windows Terminal.
if [[ -z "$_candidate" && -n "${WT_SESSION:-}" ]]; then
if (( _truecolor )); then
_term_has "xterm-direct" && _candidate="xterm-direct"
fi
[[ -z "$_candidate" ]] && _term_has "xterm-256color" && _candidate="xterm-256color"
fi
# 6. Generic terminfo probe in preference order.
if [[ -z "$_candidate" ]]; then
local _t
for _t in xterm-256color xterm-color xterm vt100; do
_term_has "$_t" && { _candidate="$_t"; break; }
done
fi
unset -f _term_has
export TERM="${_candidate:-vt100}"
}
export -f term_set
# ------------------------------------------------------------------------------
term_set
# EOF
+125 -36
View File
@@ -34,84 +34,106 @@
# * OF SUCH DAMAGE. # * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Common helpers (helpers/common.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
load_conf "debug"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Display a backtrace # Display a backtrace of the current call stack (innermost frame first), one
# line per frame: function, file where it is defined, line of the call site.
# Usage: backtrace # Usage: backtrace
function backtrace() # shellcheck disable=SC2120 # only --help is a possible argument
backtrace()
{ {
if [[ "${1:-}" == -h || "${1:-}" == --help ]]; then
printf "backtrace: print the current call stack, innermost frame first.\n"
printf "Usage: backtrace\n"
return 0
fi
printf "========= Call stack =========\n" printf "========= Call stack =========\n"
local i=1 # We begin at 1 to ignore backtrace itself local i=1 # We begin at 1 to ignore backtrace itself
while [[ $i -lt ${#FUNCNAME[@]} ]]; do while [[ $i -lt ${#FUNCNAME[@]} ]]; do
# BASH_SOURCE[i] is the file defining FUNCNAME[i], and BASH_LINENO[i-1]
# the line of that file from which FUNCNAME[i-1] was called.
printf '%15s() %s:%d\n' \ printf '%15s() %s:%d\n' \
"${FUNCNAME[$i]}" "${BASH_SOURCE[$i]}" "${BASH_LINENO[$(( i-1 ))]}" "${FUNCNAME[$i]}" "${BASH_SOURCE[$i]}" "${BASH_LINENO[$(( i-1 ))]}"
((i++)) i=$(( i + 1 ))
done done
unset i
printf "==============================\n" printf "==============================\n"
} }
export -f backtrace
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Function to be trapped for errors investigation # Handler installed by settrace as ERR trap: report the failure and the call
function error() # stack, then give back the exit status of the failed command so the trap does
# not alter the shell behaviour (e.g. under "set -e").
# Everything goes to stderr: with errtrace a failure inside $(...) or a pipe
# would otherwise inject the trace into the captured output.
# Exported because an exported settrace may be used from child bash processes,
# where the trap needs this function to exist.
error()
{ {
local errcode=$? local errcode=$?
backtrace printf "Command failed with exit status %d\n" "$errcode" >&2
backtrace >&2
return $errcode return $errcode
} }
export -f error
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Activate or deactivate error trapping to display backtrace # Activate or deactivate error trapping to display backtrace
# Usage: settrace <--on|--off|--status> # Usage: settrace <--on|--off|--status> [--force]
# Startup behaviour is set by SETTRACE_DEFAULT_ENABLED in the [debug] section.
settrace() settrace()
{ {
local status="off"
[[ $(trap -p ERR) ]] && status="on"
#trap -p ERR
local PARSED local PARSED
PARSED=$(getopt -oh --long help,on,off,status,force -- "$@") PARSED=$(getopt -oh --long help,on,off,status,force -n settrace -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after # shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"settrace --help\" to display usage." disp E "Invalid options, use \"settrace --help\" to display usage."
return 1 return 1
fi fi
eval set -- "$PARSED" # getopt emits a shell-quoted, normalised argument list ("--" ends options)
local force=0 local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
# First pass: gather the requested action and flags. --on, --off and
# --status are mutually exclusive; --force only modifies --on and --off.
local action="" force=0
while true; do while true; do
case $1 in case $1 in
-h|--help) -h|--help)
printf "Try to activate backtrace display for script debugging.\n\n" printf "Try to activate backtrace display for script debugging.\n\n"
printf "Options:\n" printf "Options:\n"
printf "\t--on\t\tActivate backtrace generation\n" printf "\t--on\t\tActivate backtrace generation\n"
printf "\t--force\t\tForce replacement of existing trap (use with --on)\n" printf "\t--force\t\tReplace (with --on) or remove (with --off) an ERR trap\n"
printf "\t--off\t\tDeactivate backtrace generation\n\n" printf "\t\t\tnot installed by settrace\n"
printf "That function active a trap event on error. If the script you want to\n" printf "\t--off\t\tDeactivate backtrace generation\n"
printf "debug overload the ERR bash trap, it will not work.\n" printf "\t--status\tDisplay whether the ERR trap is set\n\n"
printf "That function activates a trap event on error. If the script you want to\n"
printf "debug overloads the ERR bash trap, it will not work.\n\n"
printf "To activate it at every interactive startup, set SETTRACE_DEFAULT_ENABLED=1\n"
printf "in the [debug] section of the profile configuration.\n"
return 0 return 0
;; ;;
--on) --on|--off|--status)
if [[ ${status} == "on" ]] && [[ $force -eq 0 ]]; then if [[ -n $action ]]; then
disp E "ERR signal trap is already set. Use --force to replace it." disp E "The --on, --off and --status options are mutually exclusive."
return 1 return 1
fi fi
trap "error" ERR action="${1#--}"
shift shift
;; ;;
--force) --force)
force=1 force=1
shift shift
;; ;;
--off)
if [[ ${status} != "on" ]]; then
disp W "ERR signal trap is already unset!"
fi
trap - ERR
shift
;;
--status)
disp I "Trap signal is ${status}."
shift
;;
--) --)
shift shift
break break
@@ -119,13 +141,80 @@ settrace()
*) *)
disp E "Invalid options, use \"settrace --help\" to display usage." disp E "Invalid options, use \"settrace --help\" to display usage."
return 1 return 1
;; ;;
esac esac
done done
unset status force
# Second pass: evaluate the current trap state, then apply the action.
# "trap -p ERR" prints the exact command line that re-creates the trap, so
# comparing it tells our own trap ("on") from one set by someone else
# ("foreign"); empty output means no trap at all ("off").
# Limitation: bash hides the ERR trap from code running in a function (and
# its subshells) unless errtrace is set. Our own trap is always seen since
# settrace sets errtrace with it, but a foreign trap set without errtrace is
# reported as "off" (and can then be replaced without --force).
local status="off" current
current=$(trap -p ERR)
if [[ -n $current ]]; then
status="foreign"
[[ $current == "trap -- 'error' ERR" ]] && status="on"
fi
case $action in
on)
if [[ $status == "on" ]]; then
disp W "ERR signal trap is already set."
return 0
fi
if [[ $status == "foreign" && $force -eq 0 ]]; then
disp E "Another ERR signal trap is already set. Use --force to replace it."
return 1
fi
# errtrace makes functions and subshells inherit the ERR trap
set -o errtrace
trap 'error' ERR
;;
off)
if [[ $status == "foreign" && $force -eq 0 ]]; then
disp E "The ERR signal trap was not set by settrace. Use --force to remove it."
return 1
fi
[[ $status == "off" ]] && disp W "ERR signal trap is already unset!"
trap - ERR
set +o errtrace
;;
status)
case $status in
foreign) disp I "Trap signal on error is set, but not by settrace." ;;
*) disp I "Trap signal on error is ${status}." ;;
esac
;;
"")
disp E "No action specified, use \"settrace --help\" to display usage."
return 1
;;
esac
} }
export -f settrace export -f settrace
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Optional trace activation at startup (SETTRACE_DEFAULT_ENABLED, default 0).
# Interactive shells only: this file is sourced by non-interactive shells too,
# where an ERR trap would add noise to scripts. [[ $- == *i* ]] is used because
# profile.sh sets INTERACTIVE only after the modules are loaded.
# disp is not defined yet (modules load alphabetically), so the trap is set only
# when none exists, a path that never calls disp. An existing trap is left alone.
# Written as "if" so the sourcing status stays 0 when nothing is done.
case "${SETTRACE_DEFAULT_ENABLED:-0}" in
1|[Yy][Ee][Ss]|[Tt][Rr][Uu][Ee]|[Oo][Nn])
if [[ $- == *i* && -z $(trap -p ERR) ]]; then
settrace --on
fi
;;
esac
# ------------------------------------------------------------------------------
# EOF # EOF
+677 -16
View File
@@ -34,6 +34,12 @@
# * OF SUCH DAMAGE. # * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Common helpers (helpers/common.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
load_conf disp
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Color definitions # Color definitions
set_colors() set_colors()
@@ -42,7 +48,7 @@ set_colors()
export DEFAULTFG='\e[0;39m' export DEFAULTFG='\e[0;39m'
export DEFAULTBG='\e[0;49m' export DEFAULTBG='\e[0;49m'
export DEFAULTCOL="${DEFAULTBG}${DEFAULTFG}" export DEFAULTCOL="${DEFAULTBG}${DEFAULTFG}"
export RESETCOL=$'\e[0m' export RESETCOL='\e[0m'
# Regular Colors # Regular Colors
export Black='\e[0;30m' export Black='\e[0;30m'
@@ -122,16 +128,128 @@ export -f set_colors
# Display a message # Display a message
# Usage: disp <type> <message> # Usage: disp <type> <message>
# Types: # Types:
# I : info (green) # I : info (green), stdout
# W : warning (yellow) # W : warning (yellow), stderr
# E : error (red) # E : error (red), stderr
# D : debug (cyan) # D : debug (cyan), stderr, shown only when DEBUG is an integer > 1
# Without a recognised type (case-insensitive), the arguments are the message
# itself, printed unprefixed on stdout like an info.
#
# Environment:
# NO_COLOR non-empty: no colour or reset sequence is emitted
# QUIET=1 hides info and unprefixed messages (warnings/errors remain)
# COLUMNS terminal width used for wrapping (tput cols otherwise, 80 fallback)
#
# The message is wrapped to the terminal width on spaces, ANSI sequences count
# for no width, and continuation lines are aligned after the prefix. Backslash
# escapes (\t, \e, ...) are interpreted when printing (printf %b), but wrapping
# is computed before that, so a literal "\t" counts as two characters.
# A message hidden by QUIET/DEBUG still returns 0.
disp() disp()
{ {
local _disp_print_wrapped
_disp_print_wrapped()
{
local prefix="$1"
local target_fd="$2"
shift 2
local message="$*"
local cols="${COLUMNS:-}"
if [[ -z "$cols" || ! "$cols" =~ ^[0-9]+$ || "$cols" -lt 20 ]]; then
cols=$(tput cols 2>/dev/null)
fi
[[ -z "$cols" || ! "$cols" =~ ^[0-9]+$ || "$cols" -lt 20 ]] && cols=80
# Match ANSI CSI sequences in real-ESC, literal \e and literal \033 forms
# so their on-screen width (zero) is excluded from all width calculations.
local ansi_re=$'\e\\[[0-9;]*[A-Za-z]'
ansi_re+='|\\e\[[0-9;]*[A-Za-z]'
ansi_re+='|\\033\[[0-9;]*[A-Za-z]'
# The hanging-indent width is the prefix's VISIBLE length (ANSI stripped),
# so callers no longer need to pass a separate plain-text length.
local _pv=$prefix
while [[ $_pv =~ $ansi_re ]]; do _pv=${_pv/"${BASH_REMATCH[0]}"/}; done
local indent_len=0
(( ${#_pv} > 0 )) && indent_len=$(( ${#_pv} + 1 ))
local width=$((cols - indent_len))
(( width < 10 )) && width=10
# ANSI-aware soft wrap (fold -s semantics): break at the last space that
# keeps a line within `width` VISIBLE columns, hard-breaking only when a
# run has no space. ANSI escapes are copied through at zero width so they
# no longer inflate the measured width and cause premature wrapping.
local wrapped="" line="" line_vis=0 last_space=-1
local _i=0 _n=${#message}
while (( _i < _n )); do
local _c=${message:_i:1}
# Only a real ESC or a literal backslash can start an ANSI sequence;
# testing this first avoids copying the message tail (and running
# the regex) for every ordinary character.
# shellcheck disable=SC1003 # a literal backslash
if [[ $_c == $'\e' || $_c == '\' ]]; then
local _sub=${message:_i}
if [[ $_sub =~ ^($ansi_re) ]]; then
line+=${BASH_REMATCH[0]}
_i=$(( _i + ${#BASH_REMATCH[0]} ))
continue
fi
fi
_i=$(( _i + 1 ))
line+=$_c
(( ++line_vis ))
[[ $_c == ' ' ]] && last_space=${#line}
if (( line_vis > width )); then
if (( last_space > 0 )); then
wrapped+=${line:0:last_space-1}$'\n'
line=${line:last_space}
local _st=$line
while [[ $_st =~ $ansi_re ]]; do _st=${_st/"${BASH_REMATCH[0]}"/}; done
line_vis=${#_st}
else
local _keep=${line: -1}
wrapped+=${line:0:${#line}-1}$'\n'
line=$_keep
line_vis=1
fi
last_space=-1
fi
done
wrapped+=$line
# RESETCOL closes any colour left open by the message. It is skipped
# under NO_COLOR so that no escape sequence at all is emitted.
local reset="${RESETCOL:-}"
[[ -n ${NO_COLOR:-} ]] && reset=""
# >&"$target_fd" sends the output to stdout (1) or stderr (2). The
# first line carries the prefix, continuation lines are indented to
# line up with the message body.
local first_line=1
local line
while IFS= read -r line || [[ -n "$line" ]]; do
if (( first_line )); then
if [[ -n "$prefix" ]]; then
printf "%b\n" "${prefix} ${line}${reset}" >&"$target_fd"
else
printf "%b\n" "${line}${reset}" >&"$target_fd"
fi
first_line=0
else
printf "%*s%b\n" "$indent_len" "" "${line}${reset}" >&"$target_fd"
fi
done <<< "$wrapped"
}
# Handle NO_COLOR: disable colors if set # Handle NO_COLOR: disable colors if set
local color_enabled=1 local color_enabled=1
[[ -n $NO_COLOR ]] && color_enabled=0 [[ -n ${NO_COLOR:-} ]] && color_enabled=0
# Each branch ends with an "if" (never a bare "[[ ]] && cmd") so that a
# message hidden by QUIET/DEBUG still returns 0: a non-zero status would
# trip "set -e", ERR traps, and "disp ... && next" chains.
case ${1^^} in case ${1^^} in
"I") "I")
if [[ $color_enabled -eq 1 ]]; then if [[ $color_enabled -eq 1 ]]; then
@@ -140,8 +258,9 @@ disp()
local heads="[ info ]" local heads="[ info ]"
fi fi
shift shift
[[ -z $QUIET || $QUIET -ne 1 ]] && \ if [[ ${QUIET:-} != 1 ]]; then
printf "%b\n" "${heads} $*${RESETCOL}" _disp_print_wrapped "$heads" 1 "$*"
fi
;; ;;
"W") "W")
if [[ $color_enabled -eq 1 ]]; then if [[ $color_enabled -eq 1 ]]; then
@@ -150,7 +269,7 @@ disp()
local heads="[ Warning ]" local heads="[ Warning ]"
fi fi
shift shift
printf "%b\n" "${heads} $*${RESETCOL}" >&2 _disp_print_wrapped "$heads" 2 "$*"
;; ;;
"E") "E")
if [[ $color_enabled -eq 1 ]]; then if [[ $color_enabled -eq 1 ]]; then
@@ -159,7 +278,7 @@ disp()
local heads="[ ERROR ]" local heads="[ ERROR ]"
fi fi
shift shift
printf "%b\n" "${heads} $*${RESETCOL}" >&2 _disp_print_wrapped "$heads" 2 "$*"
;; ;;
"D") "D")
if [[ $color_enabled -eq 1 ]]; then if [[ $color_enabled -eq 1 ]]; then
@@ -168,12 +287,17 @@ disp()
local heads="[ debug ]" local heads="[ debug ]"
fi fi
shift shift
[[ -n $DEBUG && $DEBUG -gt 1 ]] && \ # Shown only when DEBUG is an integer greater than 1; sent to stderr
printf "%b\n" "${heads} $*${RESETCOL}" # like the other diagnostics, so it never pollutes captured output.
if [[ ${DEBUG:-} =~ ^[0-9]+$ && $DEBUG -gt 1 ]]; then
_disp_print_wrapped "$heads" 2 "$*"
fi
;; ;;
* ) * )
[[ -z $QUIET || $QUIET -ne 1 ]] && \ # No (known) type: the whole argument list is the message, as for info
printf "%b\n" "$*" if [[ ${QUIET:-} != 1 ]]; then
_disp_print_wrapped "" 1 "$*"
fi
;; ;;
esac esac
} }
@@ -181,8 +305,545 @@ export -f disp
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Load disp section variables # ------------------------------------------------------------------------------
load_conf disp # Render Markdown files with terminal formatting
# Usage: mdcat [file]
mdcat()
{
local _mdcat_style_inline
_mdcat_style_inline()
{
local text="$1"
local bold_on="" italic_on="" code_on="" style_off=""
local link_text_on="" link_url_on=""
if [[ -z $NO_COLOR ]]; then
bold_on=$'\e[1m'
italic_on=$'\e[3m'
code_on="${On_IBlack}${BIWhite}"
link_text_on=$'\e[4;96m'
link_url_on="$IBlack"
style_off="$RESETCOL"
fi
# Inline transforms. Code spans and backslash-escaped punctuation
# (\<, \>, \_, \*, ...) are lifted out first and replaced by numbered
# placeholders (\x01N\x02 and \x03N\x04), so that nothing inside them is
# reinterpreted (links, emphasis); they are put back, code spans styled,
# at the very end. Then links, bare URLs and emphasis.
local -a spans=() escs=()
local ph_open=$'\x01' ph_close=$'\x02' esc_open=$'\x03' esc_close=$'\x04'
while [[ "$text" =~ \`([^\`]+)\` ]]; do
local match="${BASH_REMATCH[0]}"
local before="${text%%"$match"*}"
local after="${text#*"$match"}"
spans+=("${BASH_REMATCH[1]}")
text="${before}${ph_open}$(( ${#spans[@]} - 1 ))${ph_close}${after}"
done
while [[ "$text" =~ \\([[:punct:]]) ]]; do
local match="${BASH_REMATCH[0]}"
local before="${text%%"$match"*}"
local after="${text#*"$match"}"
escs+=("${BASH_REMATCH[1]}")
text="${before}${esc_open}$(( ${#escs[@]} - 1 ))${esc_close}${after}"
done
# Links: [label](url)
while [[ "$text" =~ \[([^][]+)\]\(([^()]+)\) ]]; do
local match="${BASH_REMATCH[0]}"
local label="${BASH_REMATCH[1]}"
local url="${BASH_REMATCH[2]}"
local before="${text%%"$match"*}"
local after="${text#*"$match"}"
if [[ "$label" == "$url" ]]; then
if [[ -z $NO_COLOR ]]; then
text="${before}${link_text_on}${label}${style_off}${after}"
else
text="${before}${label}${after}"
fi
elif [[ -z $NO_COLOR ]]; then
text="${before}${link_text_on}${label}${style_off} ${link_url_on}(${url})${style_off}${after}"
else
text="${before}${label} (${url})${after}"
fi
done
# Style bare URLs without re-matching the same URL forever.
# The prefix capture keeps progress monotonic and prevents freeze loops.
# Skip this transformation in NO_COLOR mode.
if [[ -z $NO_COLOR ]]; then
while [[ "$text" =~ (^|[[:space:]\(])(https?://[^[:space:]\)]+) ]]; do
local match="${BASH_REMATCH[0]}"
local pre="${BASH_REMATCH[1]}"
local url="${BASH_REMATCH[2]}"
local before="${text%%"$match"*}"
local after="${text#*"$match"}"
text="${before}${pre}${link_text_on}${url}${style_off}${after}"
done
fi
while [[ "$text" =~ \*\*([^*]+)\*\* ]]; do
local match="${BASH_REMATCH[0]}"
local val="${BASH_REMATCH[1]}"
local before="${text%%"$match"*}"
local after="${text#*"$match"}"
text="${before}${bold_on}${val}${style_off}${after}"
done
while [[ "$text" =~ __([^_]+)__ ]]; do
local match="${BASH_REMATCH[0]}"
local val="${BASH_REMATCH[1]}"
local before="${text%%"$match"*}"
local after="${text#*"$match"}"
text="${before}${bold_on}${val}${style_off}${after}"
done
while [[ "$text" =~ \*([^*]+)\* ]]; do
local match="${BASH_REMATCH[0]}"
local val="${BASH_REMATCH[1]}"
local before="${text%%"$match"*}"
local after="${text#*"$match"}"
text="${before}${italic_on}${val}${style_off}${after}"
done
# Match _italic_ only when underscores are outside word-like identifiers.
while [[ "$text" =~ (^|[[:space:][:punct:]])_([^[:space:]_][^_]*[^[:space:]_])_([[:space:][:punct:]]|$) ]]; do
local match="${BASH_REMATCH[0]}"
local pre="${BASH_REMATCH[1]}"
local val="${BASH_REMATCH[2]}"
local post="${BASH_REMATCH[3]}"
local before="${text%%"$match"*}"
local after="${text#*"$match"}"
text="${before}${pre}${italic_on}${val}${style_off}${post}${after}"
done
# Put the styled code spans and the escaped characters back (see the
# placeholders above).
local idx
for idx in "${!spans[@]}"; do
text="${text/"${ph_open}${idx}${ph_close}"/"${code_on}${spans[idx]}${style_off}"}"
done
for idx in "${!escs[@]}"; do
text="${text/"${esc_open}${idx}${esc_close}"/"${escs[idx]}"}"
done
printf "%s\n" "$text"
}
local _mdcat_print_hr
_mdcat_print_hr()
{
local cols="${_mdcat_cols:-}"
if [[ -z "$cols" || ! "$cols" =~ ^[0-9]+$ || "$cols" -lt 20 ]]; then
cols=$(tput cols 2>/dev/null)
fi
[[ -z "$cols" || ! "$cols" =~ ^[0-9]+$ || "$cols" -lt 20 ]] && cols=80
local hr
printf -v hr "%*s" "$cols" ""
hr="${hr// /-}"
if [[ -z $NO_COLOR ]]; then
printf "%b%s%b\n" "$IBlack" "$hr" "$RESETCOL"
else
printf "%s\n" "$hr"
fi
}
local _mdcat_print_code_block
_mdcat_print_code_block()
{
local lang="$1"
shift
local -a lines=("$@")
local cols="${_mdcat_cols:-}"
if [[ -z "$cols" || ! "$cols" =~ ^[0-9]+$ || "$cols" -lt 20 ]]; then
cols=$(tput cols 2>/dev/null)
fi
[[ -z "$cols" || ! "$cols" =~ ^[0-9]+$ || "$cols" -lt 20 ]] && cols=80
local max_inner=$((cols - 4))
(( max_inner < 16 )) && max_inner=16
local width=16 line
for line in "${lines[@]}"; do
local line_len=${#line}
(( line_len > width )) && width=$line_len
done
(( width > max_inner )) && width=$max_inner
local border
printf -v border "+-%*s-+" "$width" ""
border="${border// /-}"
local frame_on="" code_on="" off=""
if [[ -z $NO_COLOR ]]; then
frame_on="$IBlack"
code_on="$BIWhite"
off="$RESETCOL"
fi
printf "%b%s%b\n" "$frame_on" "$border" "$off"
if [[ -n "$lang" ]]; then
local tag="language: $lang"
local wrapped_tag
wrapped_tag=$(printf "%s" "$tag" | fold -s -w "$width")
while IFS= read -r line || [[ -n "$line" ]]; do
printf "%b| %b%-*s%b |%b\n" "$frame_on" "$code_on" "$width" "$line" "$frame_on" "$off"
done <<< "$wrapped_tag"
printf "%b%s%b\n" "$frame_on" "$border" "$off"
fi
if [[ ${#lines[@]} -eq 0 ]]; then
printf "%b| %b%-*s%b |%b\n" "$frame_on" "$code_on" "$width" "" "$frame_on" "$off"
else
for line in "${lines[@]}"; do
local wrapped
wrapped=$(printf "%s" "$line" | fold -s -w "$width")
while IFS= read -r wline || [[ -n "$wline" ]]; do
printf "%b| %b%-*s%b |%b\n" "$frame_on" "$code_on" "$width" "$wline" "$frame_on" "$off"
done <<< "$wrapped"
done
fi
printf "%b%s%b\n" "$frame_on" "$border" "$off"
}
local _mdcat_print_table
# That function is a bit slow, we need to try to optimize it
_mdcat_print_table()
{
local _mdcat_parse_table_row
# Split one table row into cells, joined with $sep (set by the caller,
# reached through dynamic scoping). Cells are separated by '|' only; an
# escaped "\|" is not supported. Trimming uses parameter expansion
# (strip leading, then trailing, whitespace) to avoid forking sed.
_mdcat_parse_table_row()
{
local input="$1" j
local line="$input"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
line="${line#|}"
line="${line%|}"
# Parse Markdown cells using only '|' separators and preserve spaces inside cells.
local -a cells=()
local cell rest="$line"
while :; do
if [[ "$rest" == *'|'* ]]; then
cell="${rest%%|*}"
rest="${rest#*|}"
else
cell="$rest"
rest=""
fi
cell="${cell#"${cell%%[![:space:]]*}"}"
cell="${cell%"${cell##*[![:space:]]}"}"
cells+=("$cell")
[[ -z "$rest" ]] && break
done
if [[ ${#cells[@]} -eq 0 ]]; then
printf '%s' ""
return
fi
local joined="${cells[0]}"
for ((j=1; j<${#cells[@]}; ++j)); do
joined+="$sep${cells[j]}"
done
printf '%s' "$joined"
}
local -a lines=("$@")
local -a table_rows=()
local -a col_widths=()
local -A cell_styled=() cell_vislen=()
local i j ncols=0 orow=0
local sep=$'\x1f'
# Parse header and data rows, skipping the Markdown separator row.
# Each cell is styled exactly once here; the styled text and its visible
# width (ANSI stripped) are cached and reused when drawing, so the costly
# _mdcat_style_inline runs once per cell instead of twice.
for ((i=0; i<${#lines[@]}; ++i)); do
(( i == 1 )) && continue
local parsed
parsed=$(_mdcat_parse_table_row "${lines[i]}")
table_rows+=("$parsed")
local -a row=()
IFS="$sep" read -r -a row <<< "$parsed"
(( ncols < ${#row[@]} )) && ncols=${#row[@]}
for ((j=0; j<${#row[@]}; ++j)); do
local styled visible key="$orow:$j"
styled=$(_mdcat_style_inline "${row[j]}")
# Visible width: expand the literal \e sequences, then strip
# every ANSI CSI sequence (no sed fork per cell).
printf -v visible '%b' "$styled"
while [[ $visible =~ $'\e'\[[0-9\;]*[A-Za-z] ]]; do
visible=${visible/"${BASH_REMATCH[0]}"/}
done
cell_styled["$key"]="$styled"
cell_vislen["$key"]=${#visible}
[[ -z "${col_widths[j]}" ]] && col_widths[j]=0
(( col_widths[j] < ${#visible} )) && col_widths[j]=${#visible}
done
orow=$((orow + 1))
done
# Ensure all width slots are initialized before drawing borders.
for ((j=0; j<ncols; ++j)); do
[[ -z "${col_widths[j]}" ]] && col_widths[j]=0
done
# Colours are dropped under NO_COLOR, like everywhere else in mdcat.
local c_frame="$IBlack" c_head="$BBlue" c_off="$RESETCOL" c_cell="$RESETCOL"
if [[ -n ${NO_COLOR:-} ]]; then
c_frame="" c_head="" c_off="" c_cell=""
fi
# Draw top border: one run of dashes per column (cell width + 2 spaces)
local border="+" seg
for ((j=0; j<ncols; ++j)); do
printf -v seg '%*s' "$((col_widths[j] + 2))" ""
border+="${seg// /-}+"
done
printf "%b\n" "$c_frame$border$c_off"
# Print header row (cached styled cells)
printf "%b|" "$c_frame"
for ((j=0; j<ncols; ++j)); do
local hkey="0:$j"
local styled_cell="${cell_styled[$hkey]:-}"
local pad=$((col_widths[j] - ${cell_vislen[$hkey]:-0}))
(( pad < 0 )) && pad=0
printf " %b%b%*s%b |" "$c_head" "$styled_cell" "$pad" "" "$c_frame"
done
printf "%b\n" "$c_off"
# Header separator
printf "%b|" "$c_frame"
for ((j=0; j<ncols; ++j)); do
printf -v seg '%*s' "${col_widths[j]}" ""
printf " %s |" "${seg// /-}"
done
printf "%b\n" "$c_off"
# Print data rows (cached styled cells)
for ((i=1; i<${#table_rows[@]}; ++i)); do
printf "%b|" "$c_frame"
for ((j=0; j<ncols; ++j)); do
local key="$i:$j"
local styled_cell="${cell_styled[$key]:-}"
local pad=$((col_widths[j] - ${cell_vislen[$key]:-0}))
(( pad < 0 )) && pad=0
printf " %b%b%*s%b |" "$c_cell" "$styled_cell" "$pad" "" "$c_frame"
done
printf "%b\n" "$c_off"
done
# Draw bottom border
printf "%b\n" "$c_frame$border$c_off"
}
local PARSED
PARSED=$(getopt -o h --long help -n 'mdcat' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"mdcat --help\" to display usage."
return 1
fi
# getopt emits a shell-quoted, normalised argument list ("--" ends options)
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "mdcat: Render a Markdown file with terminal formatting.\n"
printf "Usage: mdcat [file]\n"
printf "If no file is provided, mdcat reads from standard input.\n"
return 0
;;
--)
shift
break
;;
*)
disp E "Invalid option, use \"mdcat --help\" to display options list"
return 1
;;
esac
done
if [[ $# -gt 1 ]]; then
disp E "Too many arguments. Usage: mdcat [file]"
return 1
fi
local input_file=""
if [[ $# -eq 1 ]]; then
input_file="$1"
if [[ ! -f "$input_file" ]]; then
disp E "File not found: $input_file"
return 1
fi
if [[ ! -r "$input_file" ]]; then
disp E "File is not readable: $input_file"
return 1
fi
elif [[ -t 0 ]]; then
disp E "No input provided. Usage: mdcat [file] or: cat file.md | mdcat"
return 1
fi
local in_code=0 code_lang="" raw line
local -a code_lines=()
local in_table=0
local -a table_lines=()
# Resolve the terminal width once for the whole render instead of shelling
# out to tput for every printed line.
local _mdcat_cols="${COLUMNS:-}"
if [[ -z "$_mdcat_cols" || ! "$_mdcat_cols" =~ ^[0-9]+$ || "$_mdcat_cols" -lt 20 ]]; then
_mdcat_cols=$(tput cols 2>/dev/null)
fi
[[ -z "$_mdcat_cols" || ! "$_mdcat_cols" =~ ^[0-9]+$ || "$_mdcat_cols" -lt 20 ]] && _mdcat_cols=80
# Single-line push-back buffer: table detection reads one line ahead, so a
# non-separator look-ahead line must be reprocessed rather than discarded.
local pending_line="" has_pending=0
while :; do
if [[ $has_pending -eq 1 ]]; then
raw="$pending_line"
has_pending=0
elif ! IFS= read -r raw; then
[[ -n "$raw" ]] || break
fi
line="${raw%$'\r'}"
# Table detection: line with |, next line with | and ---
# (never inside a fenced code block, where a table is just sample text)
if [[ $in_code -eq 0 && $in_table -eq 0 && "$line" =~ ^[[:space:]]*\|.*\|[[:space:]]*$ ]]; then
local next="" next_ok=1
IFS= read -r next || next_ok=0
# Accept: | --- | --- | or |:---|---:| etc.
if [[ "$next" =~ ^[[:space:]]*\|[[:space:]]*:?[-]+:?([[:space:]]*\|[[:space:]]*:?[ -]+:?)*\|[[:space:]]*$ ]]; then
in_table=1
table_lines=("$line" "$next")
continue
fi
# Not a separator: push the look-ahead line back so it is not lost.
if [[ $next_ok -eq 1 || -n "$next" ]]; then
pending_line="$next"
has_pending=1
fi
fi
if [[ $in_table -eq 1 ]]; then
# Accept table row if it starts and ends with |
if [[ "$line" =~ ^[[:space:]]*\|.*\|[[:space:]]*$ && ! "$line" =~ ^[[:space:]]*\|[[:space:]]*:?[-]+:?([[:space:]]*\|[[:space:]]*:?[ -]+:?)*\|[[:space:]]*$ ]]; then
table_lines+=("$line")
continue
else
_mdcat_print_table "${table_lines[@]}"
in_table=0
table_lines=()
fi
fi
if [[ $in_code -eq 1 ]]; then
if [[ "$line" =~ ^\`\`\` ]]; then
_mdcat_print_code_block "$code_lang" "${code_lines[@]}"
in_code=0
code_lang=""
code_lines=()
else
code_lines+=("$line")
fi
continue
fi
if [[ "$line" =~ ^\`\`\`[[:space:]]*([^[:space:]]*) ]]; then
in_code=1
code_lang="${BASH_REMATCH[1]}"
code_lines=()
continue
fi
if [[ "$line" =~ ^(#{1,6})[[:space:]]+(.*)$ ]]; then
local lvl=${#BASH_REMATCH[1]}
local title="${BASH_REMATCH[2]}"
local h_on=""
if [[ -z $NO_COLOR ]]; then
case "$lvl" in
1) h_on="$BBlue" ;;
2) h_on="$BCyan" ;;
3) h_on="$BGreen" ;;
*) h_on="$BIWhite" ;;
esac
fi
local h_off="$RESETCOL"
[[ -n $NO_COLOR ]] && h_off=""
printf "%b%s%b\n" "$h_on" "$title" "$h_off"
continue
fi
if [[ "$line" =~ ^[[:space:]]*([\-*_])[[:space:]]*\1[[:space:]]*\1[\-*_[:space:]]*$ ]]; then
_mdcat_print_hr
continue
fi
if [[ "$line" =~ ^([[:space:]]*)\>[[:space:]]?(.*)$ ]]; then
local quote="${BASH_REMATCH[2]}"
quote=$(_mdcat_style_inline "$quote")
if [[ -z $NO_COLOR ]]; then
printf "%s%b|%b %b\n" "${BASH_REMATCH[1]}" "$ICyan" "$RESETCOL" "$quote"
else
printf "%s| %b\n" "${BASH_REMATCH[1]}" "$quote"
fi
continue
fi
if [[ "$line" =~ ^([[:space:]]*)[-+*][[:space:]]+(.*)$ ]]; then
local item="${BASH_REMATCH[2]}"
item=$(_mdcat_style_inline "$item")
if [[ -z $NO_COLOR ]]; then
printf "%s%b*%b %b\n" "${BASH_REMATCH[1]}" "$IGreen" "$RESETCOL" "$item"
else
printf "%s* %b\n" "${BASH_REMATCH[1]}" "$item"
fi
continue
fi
if [[ "$line" =~ ^([[:space:]]*)([0-9]+)\.[[:space:]]+(.*)$ ]]; then
local nitem="${BASH_REMATCH[3]}"
nitem=$(_mdcat_style_inline "$nitem")
if [[ -z $NO_COLOR ]]; then
printf "%s%b%s.%b %b\n" "${BASH_REMATCH[1]}" "$IGreen" "${BASH_REMATCH[2]}" "$RESETCOL" "$nitem"
else
printf "%s%s. %b\n" "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "$nitem"
fi
continue
fi
printf "%b\n" "$(_mdcat_style_inline "$line")"
done < "${input_file:-/dev/stdin}"
# Flush any block still open at end-of-input.
if [[ $in_code -eq 1 ]]; then
_mdcat_print_code_block "$code_lang" "${code_lines[@]}"
fi
if [[ $in_table -eq 1 ]]; then
_mdcat_print_table "${table_lines[@]}"
fi
}
export -f mdcat
# ------------------------------------------------------------------------------
# Load color codes
set_colors set_colors
# EOF # EOF
+297 -63
View File
@@ -34,8 +34,16 @@
# * OF SUCH DAMAGE. # * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Common helpers (helpers/common.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
load_conf "filefct"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Expand wildcards in a file/directory list and quote the results # Expand wildcards in a file/directory list and quote the results: each match is
# wrapped in double quotes (with \, ", $ and ` escaped) and the matches are
# joined with the separator. Items without wildcards are quoted as they are.
# Usage: expandlist [options] <item1 [item2 ... itemN]> # Usage: expandlist [options] <item1 [item2 ... itemN]>
expandlist() expandlist()
{ {
@@ -47,7 +55,10 @@ expandlist()
disp E "Invalid options, use \"expandlist --help\" to display usage." disp E "Invalid options, use \"expandlist --help\" to display usage."
return 1 return 1
fi fi
eval set -- "$PARSED" # Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
@@ -56,7 +67,7 @@ expandlist()
printf "Usage: expandlist [options] <item1 [item2 ... itemN]>\n\n" printf "Usage: expandlist [options] <item1 [item2 ... itemN]>\n\n"
printf "Options:\n" printf "Options:\n"
printf "\t-h, --help\t\tDisplay this help screen\n" printf "\t-h, --help\t\tDisplay this help screen\n"
printf "\t-s, --separator SEP\tSet output separator (default: space)\n" printf "\t-s, --separator SEP\tSet output separator (default: space); \\\\n, \\\\t... are interpreted\n"
printf "\t-n, --newline\t\tUse a newline as separator\n" printf "\t-n, --newline\t\tUse a newline as separator\n"
return 0 return 0
;; ;;
@@ -79,15 +90,25 @@ expandlist()
esac esac
done done
# Backslash escapes in the separator (\n, \t, ...) are interpreted, so the
# configuration file can express a newline as EXPANDLIST_DEFAULT_SEPARATOR=\n
printf -v separator '%b' "$separator"
local item="" result="" matched=0 local item="" result="" matched=0
# Restore the caller's nullglob setting on exit instead of forcing it off.
local _had_nullglob=0
shopt -q nullglob && _had_nullglob=1
shopt -s nullglob shopt -s nullglob
# An empty IFS keeps the unquoted expansion below from splitting on spaces
# (it would cut "my dir/*.txt" in two) while globbing still happens.
local IFS=
for item in "$@"; do for item in "$@"; do
local expanded=() local expanded=()
# True glob expansion when wildcards are present. # True glob expansion when wildcards are present.
if [[ "$item" == *'*'* || "$item" == *'?'* || "$item" == *'['* ]]; then if [[ "$item" == *'*'* || "$item" == *'?'* || "$item" == *'['* ]]; then
# shellcheck disable=SC2206 # We actually want the word splitting # shellcheck disable=SC2206 # Intentional glob expansion, no splitting (IFS is empty)
expanded=( $item ) expanded=( $item )
else else
expanded=( "$item" ) expanded=( "$item" )
@@ -97,16 +118,24 @@ expandlist()
continue continue
fi fi
local content
for content in "${expanded[@]}"; do for content in "${expanded[@]}"; do
if (( matched )); then if (( matched )); then
result+="$separator" result+="$separator"
fi fi
# Escape what stays special inside double quotes so that the output
# can safely be re-evaluated (eval, command substitution, ...).
content=${content//\\/\\\\}
content=${content//\"/\\\"}
content=${content//\$/\\\$}
content=${content//\`/\\\`}
result+="\"$content\"" result+="\"$content\""
matched=1 matched=1
done done
unset content
done done
shopt -u nullglob (( _had_nullglob )) || shopt -u nullglob
printf '%s\n' "$result" printf '%s\n' "$result"
} }
export -f expandlist export -f expandlist
@@ -134,7 +163,10 @@ clean()
return 1 return 1
fi fi
eval set -- "$PARSED" # Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
@@ -180,19 +212,32 @@ clean()
(( ! recursive )) && findopt=(-maxdepth 1) (( ! recursive )) && findopt=(-maxdepth 1)
(( ! force )) && rmopt=(-i) (( ! force )) && rmopt=(-i)
local dir f
for dir in "${dirlist[@]}"; do for dir in "${dirlist[@]}"; do
find "$dir" "${findopt[@]}" -type f \( -name "*~" -o -name "#*#" -o -name "*.bak" -o -name ".~*#" \) -print0 | # "./" keeps a name starting with "-" from being read as a find option.
# -H follows a symlink given as argument (a link to a directory).
[[ $dir == -* ]] && dir="./$dir"
find -H "$dir" "${findopt[@]}" -type f \( -name "*~" -o -name "#*#" -o -name "*.bak" -o -name ".~*#" \) -print0 |
while IFS= read -r -d '' f; do while IFS= read -r -d '' f; do
if (( outshell )); then if (( outshell )); then
# %q prints a re-usable (shell-quoted) form of the name
if (( ${#rmopt[@]} )); then if (( ${#rmopt[@]} )); then
printf 'rm %s -- "%s"\n' "${rmopt[*]}" "$f" printf 'rm %s -- %q\n' "${rmopt[*]}" "$f"
else else
printf 'rm -- "%s"\n' "$f" printf 'rm -- %q\n' "$f"
fi fi
elif (( force )); then
# No confirmation: rm reads no stdin, so leave it attached
# to the find pipe (a /dev/tty redirect would fail when there
# is no controlling terminal, e.g. cron).
rm -- "$f" || disp E "Failed to remove \"$f\"."
else else
rm "${rmopt[@]}" -- "$f" # Interactive rm -i: read the answer from the terminal, not
# from the find pipe that feeds this loop's stdin.
rm -i -- "$f" </dev/tty || disp E "Failed to remove \"$f\"."
fi fi
done done
unset f
done done
} }
export -f clean export -f clean
@@ -212,15 +257,15 @@ mcd()
return 0 return 0
fi fi
if [[ ! $# -eq 1 ]]; then if (( $# != 1 )); then
disp E "Missing parameter. Use \"mcd --help\" to display usage." disp E "Missing parameter. Use \"mcd --help\" to display usage."
return 1 return 1
fi fi
if ! mkdir -pv "$1"; then if ! mkdir -pv -- "$1"; then
disp E "Failed to create directory \"$1\"." disp E "Failed to create directory \"$1\"."
return 1 return 1
fi fi
if ! cd "$1"; then if ! cd -- "$1"; then
disp E "Failed to change to directory \"$1\"." disp E "Failed to change to directory \"$1\"."
return 1 return 1
fi fi
@@ -245,13 +290,16 @@ rmspc()
local mvopt=() local mvopt=()
local PARSED local PARSED
PARSED=$(getopt -o hr:c::vs --long help,recursive,subst-char::,verbose,shell -n 'rmspc' -- "$@") PARSED=$(getopt -o hrc::vs --long help,recursive,subst-char::,verbose,shell -n 'rmspc' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after # shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"rmspc --help\" to display usage." disp E "Invalid options, use \"rmspc --help\" to display usage."
return 1 return 1
fi fi
eval set -- "$PARSED" # Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
@@ -261,11 +309,12 @@ rmspc()
printf "Options:\n" printf "Options:\n"
printf "\t-h, --help\t\tDisplay that help screen\n" printf "\t-h, --help\t\tDisplay that help screen\n"
printf "\t-r, --recursive\t\tTreat subdirectories of the given directory\n" printf "\t-r, --recursive\t\tTreat subdirectories of the given directory\n"
printf "\t-c, --subst-char\tChange the replacement character (default is underscore)\n" printf "\t-c, --subst-char[=C]\tChange the replacement character (default is underscore)\n"
printf "\t-v, --verbose\t\tDisplay more details (recursive mode only)\n" printf "\t-v, --verbose\t\tDisplay more details (recursive mode only)\n"
printf "\t-s, --shell\t\tDo nothing and display commands that would be executed\n\n" printf "\t-s, --shell\t\tDo nothing and display commands that would be executed\n\n"
printf "Note: if the --subst-char option is given without parameters, spaces will be\n" printf "Note: the character must be attached (-c- or --subst-char=-). If the option\n"
printf " replaced with nothing (concatenation).\n" printf " is given without a character, spaces are replaced with nothing\n"
printf " (concatenation). An existing file is never overwritten.\n"
return 0 return 0
;; ;;
-r|--recursive) -r|--recursive)
@@ -296,46 +345,82 @@ rmspc()
esac esac
done done
# getopt only attaches an optional argument written as -cX or --subst-char=X;
# "-c X" leaves X behind as a positional argument, which rmspc does not take
# (it works on the current directory) and would otherwise silently ignore.
if [[ $# -gt 0 ]]; then
disp E "Unexpected argument '$1'. The replacement character must be attached: -c$1 or --subst-char=$1"
return 1
fi
[[ "$substchar" == "none" ]] && substchar="" [[ "$substchar" == "none" ]] && substchar=""
# A slash would turn the new name into a path
if [[ "$substchar" == */* ]]; then
disp E "The replacement character must not contain a slash."
return 1
fi
(( verb )) && mvopt=(-v) (( verb )) && mvopt=(-v)
# Restore the caller's nullglob setting on exit instead of forcing it off.
local _had_nullglob=0
shopt -q nullglob && _had_nullglob=1
shopt -s nullglob shopt -s nullglob
local f
for f in *; do for f in *; do
if (( recurs )) && [[ -d "$f" ]]; then # Symlinks to directories are not followed (no escape from the tree,
# no loops).
if (( recurs )) && [[ -d "$f" && ! -L "$f" ]]; then
( (
local lastdir=$f local lastdir=$f
(( verb )) && disp I "Entering directory $(pwd)/$f ..." if (( verb )); then
disp I "Entering directory $(pwd)/$f ..."
fi
pushd "$f" >/dev/null || return 1 pushd "$f" >/dev/null || return 1
if (( substchar_set )); then # Build the recursive argument list by value: the ${var:+-flag}
rmspc ${recurs:+-r} -c "$substchar" ${verb:+-v} ${shell:+-s} # idiom would expand on the *string* "0" (non-empty) and wrongly
else # force -v/-s on every recursive call.
rmspc ${recurs:+-r} ${verb:+-v} ${shell:+-s} local rargs=(-r)
fi (( verb )) && rargs+=(-v)
(( shell )) && rargs+=(-s)
# Attached form, mandatory for an optional argument (see above)
(( substchar_set )) && rargs+=(--subst-char="$substchar")
rmspc "${rargs[@]}"
popd >/dev/null || return 1 popd >/dev/null || return 1
(( verb )) && disp I "Leaving directory $(pwd)/$lastdir" # "if" rather than "(( verb )) && ...": the subshell status is
# that of its last command and must stay 0 when verb is off.
if (( verb )); then
disp I "Leaving directory $(pwd)/$lastdir"
fi
) )
fi fi
if [[ "$f" == *" "* ]]; then if [[ "$f" == *" "* ]]; then
local newf="${f// /${substchar}}" # Quoted replacement: an unquoted "&" would stand for the match
local newf="${f// /"$substchar"}"
[[ "$f" == "$newf" ]] && continue [[ "$f" == "$newf" ]] && continue
# Never overwrite (mv would silently replace an existing file)
if [[ -e "$newf" || -L "$newf" ]]; then
disp E "Cannot rename \"$f\": \"$newf\" already exists."
continue
fi
if (( shell )); then if (( shell )); then
if (( ${#mvopt[@]} )); then if (( ${#mvopt[@]} )); then
printf 'mv %s -- "%s" "%s"\n' "${mvopt[*]}" "$f" "$newf" printf 'mv %s -- %q %q\n' "${mvopt[*]}" "$f" "$newf"
else else
printf 'mv -- "%s" "%s"\n' "$f" "$newf" printf 'mv -- %q %q\n' "$f" "$newf"
fi fi
else else
mv "${mvopt[@]}" -- "$f" "$newf" || { mv -n "${mvopt[@]}" -- "$f" "$newf" || {
disp E "Failed renaming \"$f\" to \"$newf\"." disp E "Failed renaming \"$f\" to \"$newf\"."
continue continue
} }
fi fi
fi fi
done done
shopt -u nullglob unset f
(( _had_nullglob )) || shopt -u nullglob
} }
export -f rmspc export -f rmspc
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@@ -345,7 +430,7 @@ export -f rmspc
# Display statistics about a file tree # Display statistics about a file tree
# Usage: file_stats [options] [path] # Usage: file_stats [options] [path]
# Options: # Options:
# -H, --human Human readable sizes\n" # -H, --human Human readable sizes
# -d, --details Display details (min/max/average/median) # -d, --details Display details (min/max/average/median)
# -m, --average Display only average size # -m, --average Display only average size
# -M, --median Display only median size # -M, --median Display only median size
@@ -370,7 +455,10 @@ file_stats()
disp E "Invalid options, use \"file_stats --help\" to display usage." disp E "Invalid options, use \"file_stats --help\" to display usage."
return 1 return 1
fi fi
eval set -- "$PARSED" # Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
@@ -450,41 +538,73 @@ file_stats()
done done
[[ -n "$1" ]] && path="$1" [[ -n "$1" ]] && path="$1"
if [[ ! -e "$path" ]]; then
disp E "file_stats: no such file or directory: ${path}"
return 1
fi
# "./" keeps a name starting with "-" from being read as a find option
[[ $path == -* ]] && path="./$path"
# Prepare find filters # Prepare find filters
local find_cmd=(find "$path" -type f) local find_cmd=(find "$path" -type f)
# Single extension filter # Single extension filter (case-insensitive). When combined with
# --ext-list, a file must satisfy both, as find ANDs the tests.
if [[ -n "$ext_filter" ]]; then if [[ -n "$ext_filter" ]]; then
find_cmd+=(-iname "*.$ext_filter") find_cmd+=(-iname "*.$ext_filter")
fi fi
# Extension list filter # Extension list filter: ( -iname *.a -o -iname *.b ... )
local exts
if [[ -n "$ext_list" ]]; then if [[ -n "$ext_list" ]]; then
IFS=',' read -ra exts <<< "$ext_list" IFS=',' read -ra exts <<< "$ext_list"
find_cmd+=('(') find_cmd+=('(')
local i
for i in "${!exts[@]}"; do for i in "${!exts[@]}"; do
[[ $i -ne 0 ]] && find_cmd+=(-o) [[ $i -ne 0 ]] && find_cmd+=(-o)
find_cmd+=(-iname "*.${exts[$i]}") find_cmd+=(-iname "*.${exts[$i]}")
done done
unset i
find_cmd+=(')') find_cmd+=(')')
fi fi
unset exts
# Minimum/maximum size filters (evaluated in bytes) # Minimum/maximum size filters (evaluated in bytes)
if [[ -n "$min_size" || -n "$max_size" ]]; then
if ! command -v numfmt >/dev/null 2>&1; then
disp E "file_stats: --min/--max require 'numfmt' (GNU coreutils). Please install it."
return 1
fi
fi
local bytes
if [[ -n "$min_size" ]]; then if [[ -n "$min_size" ]]; then
find_cmd+=(-size +"$(numfmt --from=iec "$min_size")"c) bytes=$(numfmt --from=iec "$min_size" 2>/dev/null) || {
disp E "file_stats: invalid --min size: ${min_size}"
return 1
}
# find's "-size +N" means "strictly more than N bytes"; the minimum
# is inclusive, hence N-1 (nothing to filter for a minimum of 0).
(( bytes > 0 )) && find_cmd+=(-size +"$(( bytes - 1 ))"c)
fi fi
if [[ -n "$max_size" ]]; then if [[ -n "$max_size" ]]; then
find_cmd+=(-size -"$(( $(numfmt --from=iec "$max_size") + 1 ))"c) bytes=$(numfmt --from=iec "$max_size" 2>/dev/null) || {
disp E "file_stats: invalid --max size: ${max_size}"
return 1
}
# "-size -N" means "less than N bytes": N+1 makes the maximum inclusive.
find_cmd+=(-size -"$(( bytes + 1 ))"c)
fi fi
# Execution # Execution: one size per line, sorted ascending (the median needs it),
# then summarised by awk. The path goes through ENVIRON, as awk -v would
# interpret backslashes in it.
"${find_cmd[@]}" -printf "%s\n" 2>/dev/null | sort -n | \ "${find_cmd[@]}" -printf "%s\n" 2>/dev/null | sort -n | \
FILE_STATS_PATH="$path" \
awk -v human="$human" -v details="$details" -v only_avg="$only_avg" \ awk -v human="$human" -v details="$details" -v only_avg="$only_avg" \
-v only_med="$only_med" -v only_count="$only_count" \ -v only_med="$only_med" -v only_count="$only_count" \
-v only_total="$only_total" -v show_all="$show_all" -v path="$path" ' -v only_total="$only_total" -v show_all="$show_all" '
# Convert function # Convert function (i and units are local: extra parameters)
function human_readable(x) { function human_readable(x, i, units) {
split("B KiB MiB GiB TiB", units) split("B KiB MiB GiB TiB", units)
i = 1 i = 1
while (x >= 1024 && i < 5) { while (x >= 1024 && i < 5) {
@@ -505,14 +625,17 @@ file_stats()
total += $1 total += $1
if (min == "" || $1 < min) min = $1 if (min == "" || $1 < min) min = $1
if (max == "" || $1 > max) max = $1 if (max == "" || $1 > max) max = $1
if ($1 == 0) bucket[0]++ # Histogram bucket b holds sizes in [1024^b, 1024^(b+1)); bucket 0
else { # also takes empty files. Computed with an integer loop: a
b = int(log($1)/log(1024)) # log() ratio can fall just below an exact power of 1024.
bucket[b]++ v = $1; b = 0
} while (v >= 1024) { v = int(v / 1024); b++ }
bucket[b]++
if (b > maxb) maxb = b
} }
END { END {
path = ENVIRON["FILE_STATS_PATH"]
count = NR count = NR
if (count == 0) { if (count == 0) {
print "No files found." print "No files found."
@@ -548,8 +671,10 @@ file_stats()
if (details) { if (details) {
print "\nSize histogram:" print "\nSize histogram:"
# Use a separate array for the loop to avoid collision # Walk the buckets in ascending order (awk "for (b in array)" gives
for (b in bucket) { # no defined order); empty buckets are skipped.
for (b = 0; b <= maxb; b++) {
if (!(b in bucket)) continue
# Pre-calculate label parts # Pre-calculate label parts
# 1024^0 = 1 (B), 1024^1 = 1K, etc. # 1024^0 = 1 (B), 1024^1 = 1K, etc.
low = (b == 0) ? 0 : (1024^b) low = (b == 0) ? 0 : (1024^b)
@@ -559,8 +684,7 @@ file_stats()
(b == 0) ? "0" : human_readable(low), (b == 0) ? "0" : human_readable(low),
human_readable(high)) human_readable(high))
# We store buckets in an array, access them by index b printf "%-25s : %6d files\n", label, bucket[b]
printf "%-25s : %6d fichiers\n", label, bucket[b]
} }
} }
}' }'
@@ -588,7 +712,10 @@ findbig()
disp E "Invalid options, use \"findbig --help\" to display usage." disp E "Invalid options, use \"findbig --help\" to display usage."
return 1 return 1
fi fi
eval set -- "$PARSED" # Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
@@ -608,10 +735,6 @@ findbig()
;; ;;
-l|--limit) -l|--limit)
limit="$2" limit="$2"
[[ "$limit" =~ ^[0-9]+$ ]] || {
disp E "Invalid limit: must be a positive integer."
return 1
}
shift 2 shift 2
;; ;;
-x|--one-fs) -x|--one-fs)
@@ -629,7 +752,16 @@ findbig()
esac esac
done done
# Checked here so that the configured default (FINDBIG_DEFAULT_LIMIT) is
# validated as well as -l.
if [[ ! "$limit" =~ ^[1-9][0-9]*$ ]]; then
disp E "Invalid limit '${limit}': must be a positive integer."
return 1
fi
local dir="${1:-.}" local dir="${1:-.}"
# "./" keeps a name starting with "-" from being read as a find option
[[ $dir == -* ]] && dir="./$dir"
# Prepare find arguments in an array for cleaner handling # Prepare find arguments in an array for cleaner handling
local find_args=(-L "$dir") local find_args=(-L "$dir")
@@ -637,12 +769,34 @@ findbig()
find_args+=(-type f) find_args+=(-type f)
# Logic: find files, print size and path, sort numeric reverse, take N # Logic: find files, print size and path, sort numeric reverse, take N
if (( details )); then local _fd
_fd=$(command -v fd 2>/dev/null || command -v fdfind 2>/dev/null)
if [[ -n "$_fd" ]]; then
local _fd_args=(--follow --no-ignore --hidden --absolute-path -t f)
(( one_fs )) && _fd_args+=(--one-file-system)
_fd_args+=(. "$dir")
if (( details )); then
local line
"$_fd" "${_fd_args[@]}" --exec stat --printf="%s %n\n" 2>/dev/null \
| sort -rn | head -n "$limit" \
| while IFS= read -r line; do
local path="${line#* }"
ls -ld -- "$path"
done
unset line
else
"$_fd" "${_fd_args[@]}" --exec stat --printf="%s %n\n" 2>/dev/null \
| sort -rn | head -n "$limit"
fi
elif (( details )); then
local line
find "${find_args[@]}" -printf "%s %p\n" 2>/dev/null | sort -rn | head -n "$limit" | find "${find_args[@]}" -printf "%s %p\n" 2>/dev/null | sort -rn | head -n "$limit" |
while IFS= read -r line; do while IFS= read -r line; do
local path="${line#* }" local path="${line#* }"
ls -ld -- "$path" ls -ld -- "$path"
done done
unset line
else else
find "${find_args[@]}" -printf "%s %p\n" 2>/dev/null | sort -rn | head -n "$limit" find "${find_args[@]}" -printf "%s %p\n" 2>/dev/null | sort -rn | head -n "$limit"
fi fi
@@ -671,7 +825,10 @@ findzero()
disp E "Invalid options, use \"findzero --help\" to display usage." disp E "Invalid options, use \"findzero --help\" to display usage."
return 1 return 1
fi fi
eval set -- "$PARSED" # Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
@@ -708,12 +865,58 @@ findzero()
done done
local dir="${1:-.}" local dir="${1:-.}"
local find_args=("-L" "$dir" "-type" "f" "-empty") # "./" keeps a name starting with "-" from being read as a find option
[[ $dir == -* ]] && dir="./$dir"
# Symlinks are followed to report/count empty targets, but never when
# deleting: with -L, a link to an empty file matches and "delete" would
# remove the link itself.
local follow=1
(( delete )) && follow=0
local find_args=()
(( follow )) && find_args+=("-L")
find_args+=("$dir" "-type" "f" "-empty")
(( one_fs )) && find_args+=("-xdev") (( one_fs )) && find_args+=("-xdev")
# Execution logic # Execution logic
if (( delete )); then local _fd
_fd=$(command -v fd 2>/dev/null || command -v fdfind 2>/dev/null)
if [[ -n "$_fd" ]]; then
local _fd_args=(--no-ignore --hidden --absolute-path -t f --size -1b)
(( follow )) && _fd_args+=(--follow)
(( one_fs )) && _fd_args+=(--one-file-system)
_fd_args+=(. "$dir")
# fd's "--size -1b" means "at most 1 byte", so it also lists files of
# exactly one byte. Every result is therefore re-checked with [[ -s ]]
# (true when the size is not zero) and skipped if not empty: deleting a
# one-byte file would be data loss.
if (( delete )); then
disp W "Deleting empty files in $dir..."
local f
"$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do
[[ -s "$f" ]] && continue
printf "%s\n" "$f"
rm -f -- "$f"
done
unset f
elif (( details )); then
local f
"$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do
[[ -s "$f" ]] && continue
ls -ls -- "$f"
done
unset f
else
local f
"$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do
if [[ ! -s "$f" ]]; then printf "%s\n" "$f"; fi
done
unset f
fi
elif (( delete )); then
disp W "Deleting empty files in $dir..." disp W "Deleting empty files in $dir..."
find "${find_args[@]}" -delete -print find "${find_args[@]}" -delete -print
elif (( details )); then elif (( details )); then
@@ -745,7 +948,10 @@ finddead()
disp E "Invalid options, use \"finddead --help\" to display usage." disp E "Invalid options, use \"finddead --help\" to display usage."
return 1 return 1
fi fi
eval set -- "$PARSED" # Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
@@ -782,12 +988,42 @@ finddead()
done done
local dir="${1:-.}" local dir="${1:-.}"
# "./" keeps a name starting with "-" from being read as a find option
[[ $dir == -* ]] && dir="./$dir"
# -xtype l searches for links that do not point to an existing file # -xtype l searches for links that do not point to an existing file
local find_args=("$dir" "-xtype" "l") local find_args=("$dir" "-xtype" "l")
(( one_fs )) && find_args+=("-xdev") (( one_fs )) && find_args+=("-xdev")
# Execution logic # Execution logic
if (( delete )); then local _fd
_fd=$(command -v fd 2>/dev/null || command -v fdfind 2>/dev/null)
if [[ -n "$_fd" ]]; then
# fd -t l lists all symlinks; post-filter broken ones (target unreachable via -e)
local _fd_args=(--no-ignore --hidden --absolute-path -t l)
(( one_fs )) && _fd_args+=(--one-file-system)
_fd_args+=(. "$dir")
if (( delete )); then
disp W "Deleting dead symlinks in $dir..."
local f
"$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do
if [[ ! -e "$f" ]]; then printf "%s\n" "$f"; rm -f -- "$f"; fi
done
unset f
elif (( details )); then
local f
"$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do
if [[ ! -e "$f" ]]; then ls -ls -- "$f"; fi
done
unset f
else
local f
"$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do
if [[ ! -e "$f" ]]; then printf "%s\n" "$f"; fi
done
unset f
fi
elif (( delete )); then
disp W "Deleting dead symlinks in $dir..." disp W "Deleting dead symlinks in $dir..."
find "${find_args[@]}" -delete -print find "${find_args[@]}" -delete -print
elif (( details )); then elif (( details )); then
@@ -800,6 +1036,4 @@ export -f finddead
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
load_conf "filefct"
# EOF # EOF
+439 -23
View File
@@ -34,12 +34,21 @@
# * OF SUCH DAMAGE. # * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
load_conf "fun"
# Common helpers, then the ones private to this module (helpers/fun.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
_profile_load_helpers "${BASH_SOURCE[0]}"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Make non-IT peoples think you're busy doing something hard # Make non-IT peoples think you're busy doing something hard: print the lines of
# a hexdump of random data that contain a pattern, until a key is pressed.
# Usage: busy [options] [pattern] # Usage: busy [options] [pattern]
# Options: # Options:
# --delay=<ms> : add a delay between each line output (milliseconds) # -p, --pattern <hex> : the string to search for in the hexdump output
# pattern : the string to search for in the hexdump output (default is "ca fe") # (default is "ca fe"); may also be given as argument
# -d, --delay <ms> : add a delay between each line output (milliseconds)
busy() busy()
{ {
local pattern="${BUSY_DEFAULT_PATTERN:-ca fe}" delay_ms="${BUSY_DEFAULT_DELAY:-0}" local pattern="${BUSY_DEFAULT_PATTERN:-ca fe}" delay_ms="${BUSY_DEFAULT_DELAY:-0}"
@@ -53,7 +62,10 @@ busy()
disp E "Invalid options, use \"busy --help\" to display usage." disp E "Invalid options, use \"busy --help\" to display usage."
return 1 return 1
fi fi
eval set -- "$PARSED" # Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
@@ -63,7 +75,8 @@ busy()
printf "Options:\n" printf "Options:\n"
printf "\t-h, --help\t\tDisplay this help screen\n" printf "\t-h, --help\t\tDisplay this help screen\n"
printf "\t-p, --pattern PATTERN\tHex pattern to search (default: \"ca fe\")\n" printf "\t-p, --pattern PATTERN\tHex pattern to search (default: \"ca fe\")\n"
printf "\t-d, --delay MS\t\tDelay between matches in milliseconds\n" printf "\t-d, --delay MS\t\tDelay between matches in milliseconds\n\n"
printf "It runs until a key is pressed.\n"
return 0 return 0
;; ;;
-p|--pattern) -p|--pattern)
@@ -72,8 +85,307 @@ busy()
;; ;;
-d|--delay) -d|--delay)
delay_ms="$2" delay_ms="$2"
if ! [[ "$delay_ms" =~ ^[0-9]+$ ]]; then shift 2
disp E "Invalid delay: must be an integer (milliseconds)." ;;
--)
shift
break
;;
*)
disp E "Invalid option: $1"
return 1
;;
esac
done
# A pattern given as argument (words joined by spaces) overrides -p
[[ $# -gt 0 ]] && pattern="$*"
# Checked here so that the configured default is validated as well as -d
if ! _is_uint "$delay_ms"; then
disp E "Invalid delay '${delay_ms}': must be an integer (milliseconds)."
return 1
fi
local delay_s
delay_s=$(_fun_ms_to_s "$delay_ms")
command -v hexdump >/dev/null 2>&1 || {
disp E "busy: 'hexdump' is required but not installed (util-linux or bsdmainutils)."
return 1
}
_fun_need_tty busy || return 1
# Monitor /dev/urandom. grep -e protects a pattern starting with "-";
# IFS= read keeps the line's leading blanks.
(
hexdump -C < /dev/urandom | grep -iF --line-buffered -e "$pattern" | \
while IFS= read -r line; do
printf '%s\n' "$line"
[[ $delay_ms -gt 0 ]] && sleep "$delay_s"
done
) & local sub_pid=$!
_fun_stop_on_key "$sub_pid"
}
export -f busy
# ------------------------------------------------------------------------------
# Simulate a long and complex compilation process
# Usage: fake_compile [options]
# Options:
# --min-delay=<ms> : minimum delay between output lines (milliseconds, default: 40)
# --max-delay=<ms> : maximum delay between output lines (milliseconds, default: 150)
# --lang=<lang> : source language preset: c (default), cpp, java, python, random
# --errors : inject fake compilation errors at the end of the build
fake_compile()
{
local min_ms="${FAKE_COMPILE_DEFAULT_MIN_DELAY:-40}" \
max_ms="${FAKE_COMPILE_DEFAULT_MAX_DELAY:-150}" \
lang="${FAKE_COMPILE_DEFAULT_LANG:-c}" with_errors=0
local PARSED
# Short: h, n:, x:, l:, e
# Long: help, min-delay:, max-delay:, lang:, errors
PARSED=$(getopt -o hn:x:l:e --long help,min-delay:,max-delay:,lang:,errors -n 'fake_compile' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"fake_compile --help\" to display usage."
return 1
fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "fake_compile: Simulate a complex compilation process.\n\n"
printf "Usage: fake_compile [options]\n\n"
printf "Options:\n"
printf "\t-h, --help\t\t\tDisplay this help screen\n"
printf "\t-n, --min-delay MS\t\tMin delay between lines in milliseconds (default: 40)\n"
printf "\t-x, --max-delay MS\t\tMax delay between lines in milliseconds (default: 150)\n"
printf "\t-l, --lang LANG\t\t\tLanguage preset: c, cpp, java, python, random (default: c)\n"
printf "\t-e, --errors\t\t\tInject fake compilation errors at the end\n\n"
printf "It runs until a key is pressed.\n"
return 0
;;
-n|--min-delay)
min_ms="$2"
if ! [[ "$min_ms" =~ ^[0-9]+$ ]]; then
disp E "Invalid min-delay: must be an integer (milliseconds)."
return 1
fi
shift 2
;;
-x|--max-delay)
max_ms="$2"
if ! [[ "$max_ms" =~ ^[0-9]+$ ]]; then
disp E "Invalid max-delay: must be an integer (milliseconds)."
return 1
fi
shift 2
;;
-l|--lang)
lang="$2"
case "$lang" in
c|cpp|java|python|random) ;;
*)
disp E "Invalid lang: must be one of: c, cpp, java, python, random."
return 1
;;
esac
shift 2
;;
-e|--errors)
with_errors=1
shift
;;
--)
shift
break
;;
*)
disp E "Invalid option: $1"
return 1
;;
esac
done
# Validated here (not only in the options) so that the values coming from
# the configuration file are checked too.
if ! _is_uint "$min_ms" || ! _is_uint "$max_ms"; then
disp E "min-delay and max-delay must be integers (milliseconds)."
return 1
fi
case "$lang" in
c|cpp|java|python|random) ;;
*)
disp E "Invalid lang '${lang}': must be one of: c, cpp, java, python, random."
return 1
;;
esac
if [[ $# -gt 0 ]]; then
disp E "Unexpected argument: $1. Use \"fake_compile --help\" to display usage."
return 1
fi
if [[ $min_ms -gt $max_ms ]]; then
disp E "min-delay ($min_ms) must be <= max-delay ($max_ms)."
return 1
fi
_fun_need_tty fake_compile || return 1
(
# Sleep for a random duration between min_ms and max_ms (RANDOM only
# spans 0..32767, which bounds the usable range).
rand_sleep() {
local r=$(( min_ms + RANDOM % (max_ms - min_ms + 1) ))
sleep "$(_fun_ms_to_s "$r")"
}
c_files=( "main" "utils" "parser" "lexer" "codegen" "optimizer"
"allocator" "scheduler" "resolver" "runtime" "buffer"
"hashmap" "io" "net" "crypto" "compress" )
cpp_files=( "Application" "Controller" "AbstractFactory" "Singleton"
"Observer" "Strategy" "Builder" "Facade" "Proxy"
"Iterator" "Decorator" "CommandDispatcher" "EventLoop"
"MemoryPool" "ThreadSafe" )
java_files=( "Application" "Service" "Repository" "Controller" "Entity"
"Configuration" "SecurityConfig" "DataSourceConfig"
"RestTemplate" "ExceptionHandler" "Validator" )
python_files=( "setup" "config" "utils" "models" "views" "serializers"
"migrations/0001_initial" "migrations/0002_auto" "tests"
"signals" "admin" "apps" "urls" "wsgi" "celery" "tasks" )
warnings=(
"warning: implicit declaration of function"
"warning: unused variable [-Wunused-variable]"
"warning: comparison between signed and unsigned integer expressions"
"warning: suggest parentheses around operand of '!'"
"warning: format '%d' expects 'int', but argument has type 'long int'"
"warning: control reaches end of non-void function [-Wreturn-type]"
"warning: deprecated conversion from string literal to 'char*'"
"warning: address of local variable taken"
)
errors=(
"error: 'NULL' was not declared in this scope"
"error: expected ';' before '}' token"
"error: undefined reference to 'main'"
"error: too few arguments to function"
"error: invalid use of incomplete type"
)
case "$lang" in
c) files=("${c_files[@]}"); ext=".c" ;;
cpp) files=("${cpp_files[@]}"); ext=".cpp" ;;
java) files=("${java_files[@]}"); ext=".java" ;;
python) files=("${python_files[@]}"); ext=".py" ;;
esac
while true; do
if [[ "$lang" == "random" ]]; then
_langs=("c" "cpp" "java" "python")
_pick="${_langs[$((RANDOM % 4))]}"
case "$_pick" in
c) files=("${c_files[@]}"); ext=".c" ;;
cpp) files=("${cpp_files[@]}"); ext=".cpp" ;;
java) files=("${java_files[@]}"); ext=".java" ;;
python) files=("${python_files[@]}"); ext=".py" ;;
esac
fi
total=${#files[@]}
i=0
for f in "${files[@]}"; do
i=$(( i + 1 ))
warn_count=$(( RANDOM % 3 ))
printf "[ %2d/%2d ] Compiling %s%s ...\n" "$i" "$total" "$f" "$ext"
rand_sleep
w=0
while [[ $w -lt $warn_count ]]; do
wline="${warnings[$((RANDOM % ${#warnings[@]}))]}"
printf " %s%s:%d:%d: %s\n" \
"$f" "$ext" "$(( RANDOM % 200 + 1 ))" "$(( RANDOM % 80 + 1 ))" "$wline"
rand_sleep
w=$(( w + 1 ))
done
done
if [[ $with_errors -eq 1 ]]; then
for eline in "${errors[@]}"; do
ef="${files[$((RANDOM % ${#files[@]}))]}"
printf " %s%s:%d:%d: %s\n" \
"$ef" "$ext" "$(( RANDOM % 200 + 1 ))" "$(( RANDOM % 80 + 1 ))" "$eline"
rand_sleep
done
printf "\nBuild FAILED: %d error(s), %d warning(s)\n" \
"${#errors[@]}" "$(( RANDOM % 20 + 5 ))"
else
printf "\nBuild SUCCEEDED: 0 error(s), %d warning(s)\n" \
"$(( RANDOM % 15 + 2 ))"
fi
printf "\n"
done
) & local sub_pid=$!
_fun_stop_on_key "$sub_pid"
}
export -f fake_compile
# ------------------------------------------------------------------------------
# Simulate a dramatic hacking sequence
# Usage: hack [options]
# Options:
# --target=<ip> : target IP address (default: random)
# --min-delay=<ms> : minimum delay between output lines (milliseconds, default: 60)
# --max-delay=<ms> : maximum delay between output lines (milliseconds, default: 250)
hack()
{
local min_ms="${HACK_DEFAULT_MIN_DELAY:-60}" \
max_ms="${HACK_DEFAULT_MAX_DELAY:-250}" \
target=""
local PARSED
# Short: h, t:, n:, x:
# Long: help, target:, min-delay:, max-delay:
PARSED=$(getopt -o ht:n:x: --long help,target:,min-delay:,max-delay: -n 'hack' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"hack --help\" to display usage."
return 1
fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "hack: Simulate a dramatic hacking sequence.\n\n"
printf "Usage: hack [options]\n\n"
printf "Options:\n"
printf "\t-h, --help\t\t\tDisplay this help screen\n"
printf "\t-t, --target IP\t\t\tTarget IP address (default: random)\n"
printf "\t-n, --min-delay MS\t\tMin delay between output lines in milliseconds (default: 60)\n"
printf "\t-x, --max-delay MS\t\tMax delay between output lines in milliseconds (default: 250)\n\n"
printf "It runs until a key is pressed.\n"
return 0
;;
-t|--target)
target="$2"
shift 2
;;
-n|--min-delay)
min_ms="$2"
if ! [[ "$min_ms" =~ ^[0-9]+$ ]]; then
disp E "Invalid min-delay: must be an integer (milliseconds)."
return 1
fi
shift 2
;;
-x|--max-delay)
max_ms="$2"
if ! [[ "$max_ms" =~ ^[0-9]+$ ]]; then
disp E "Invalid max-delay: must be an integer (milliseconds)."
return 1 return 1
fi fi
shift 2 shift 2
@@ -89,28 +401,132 @@ busy()
esac esac
done done
# Convert milliseconds to seconds for 'sleep' if ! _is_uint "$min_ms" || ! _is_uint "$max_ms"; then
local delay_s disp E "min-delay and max-delay must be integers (milliseconds)."
delay_s=$(awk "BEGIN{ return 1
printf \"%.3f\", $delay_ms / 1000 }") fi
if [[ $# -gt 0 ]]; then
disp E "Unexpected argument: $1. Use \"hack --help\" to display usage."
return 1
fi
if [[ $min_ms -gt $max_ms ]]; then
disp E "min-delay ($min_ms) must be <= max-delay ($max_ms)."
return 1
fi
_fun_need_tty hack || return 1
# Monitor /dev/urandom
( (
hexdump -C < /dev/urandom | grep -iF --line-buffered "$pattern" | \ # Sleep for a random duration between min_ms and max_ms (RANDOM only
while read -r line; do # spans 0..32767, which bounds the usable range).
echo "$line" rand_sleep() {
[[ $delay_ms -gt 0 ]] && sleep "$delay_s" local r=$(( min_ms + RANDOM % (max_ms - min_ms + 1) ))
sleep "$(_fun_ms_to_s "$r")"
}
rand_ip() { printf '%d.%d.%d.%d\n' \
$(( RANDOM%223+1 )) $(( RANDOM%255 )) \
$(( RANDOM%255 )) $(( RANDOM%254+1 )); }
rand_mac() { printf '%02x:%02x:%02x:%02x:%02x:%02x\n' \
$(( RANDOM%256 )) $(( RANDOM%256 )) $(( RANDOM%256 )) \
$(( RANDOM%256 )) $(( RANDOM%256 )) $(( RANDOM%256 )); }
rand_hash() { printf '%04x%04x%04x%04x%04x%04x%04x%04x' \
$RANDOM $RANDOM $RANDOM $RANDOM \
$RANDOM $RANDOM $RANDOM $RANDOM; }
ports=( 22 80 443 3306 5432 6379 8080 8443 27017 )
services=( "ssh" "http" "https" "mysql" "postgresql" "redis" "http-alt" "https-alt" "mongodb" )
cve_ids=( "CVE-2024-3094" "CVE-2023-44487" "CVE-2024-6387" "CVE-2021-44228" "CVE-2022-0847" )
os_list=( "Linux 5.15.x" "Linux 6.1.x" "Ubuntu 22.04 LTS" "Debian 12" "CentOS Stream 9" )
users=( "root" "admin" "www-data" "postgres" "redis" "deploy" )
passwords=( "password123" "admin2024" "letmein!" "Sup3rS3cr3t" "qwerty" "123456" )
fixed_target="$target"
while true; do
if [[ -z "$fixed_target" ]]; then
target="$(rand_ip)"
else
target="$fixed_target"
fi
printf "[*] Initializing attack sequence against %s\n" "$target"
rand_sleep
# Phase 1 — port scan
printf "[*] Starting port scan...\n"
rand_sleep
open_ports=()
for idx in "${!ports[@]}"; do
if (( RANDOM % 3 != 0 )); then
printf " %-6s open %s\n" "${ports[$idx]}/tcp" "${services[$idx]}"
open_ports+=( "${ports[$idx]}/${services[$idx]}" )
rand_sleep
fi
done done
printf "[+] %d open port(s) found.\n" "${#open_ports[@]}"
rand_sleep
# Phase 2 — OS fingerprinting
printf "[*] OS fingerprinting...\n"
rand_sleep
printf "[+] Target OS: %s (MAC: %s)\n" \
"${os_list[$((RANDOM % ${#os_list[@]}))]}" "$(rand_mac)"
rand_sleep
# Phase 3 — CVE check
printf "[*] Checking known vulnerabilities...\n"
rand_sleep
vuln_count=$(( RANDOM % 3 + 1 ))
v=0
while [[ $v -lt $vuln_count ]]; do
printf "[!] Potential vulnerability: %s\n" "${cve_ids[$((RANDOM % ${#cve_ids[@]}))]}"
rand_sleep
v=$(( v + 1 ))
done
# Phase 4 — exploit
printf "[*] Loading exploit module...\n"; rand_sleep
printf "[*] Bypassing firewall rules...\n"; rand_sleep
printf "[*] Injecting payload"
dots=0
while [[ $dots -lt 6 ]]; do
printf "."
rand_sleep
dots=$(( dots + 1 ))
done
printf "\n"
printf "[+] Shell obtained on %s\n" "$target"
rand_sleep
# Phase 5 — hash dumping
printf "[*] Dumping password hashes...\n"
rand_sleep
for u in "${users[@]}"; do
printf " %-12s : \$6\$%s\n" "$u" "$(rand_hash)"
rand_sleep
done
# Phase 6 — cracking
printf "[*] Cracking hashes (wordlist: rockyou.txt)...\n"
rand_sleep
cracked=$(( RANDOM % ${#users[@]} + 1 ))
c=0
while [[ $c -lt $cracked ]]; do
printf "[+] Cracked: %-12s -> %s\n" \
"${users[$c]}" "${passwords[$((RANDOM % ${#passwords[@]}))]}"
rand_sleep
c=$(( c + 1 ))
done
printf "\n[+] -------- ACCESS GRANTED -------- [+]\n"
printf "[*] Cleaning logs on %s...\n" "$target"
rand_sleep
printf "[+] Done. Have a nice day.\n"
printf "\n"
done
) & local sub_pid=$! ) & local sub_pid=$!
IFS= read -r -n 1 -s _ </dev/tty _fun_stop_on_key "$sub_pid"
kill -- -"$sub_pid" 2>/dev/null || kill "$sub_pid" 2>/dev/null
wait "$sub_pid" 2>/dev/null
return 0
} }
export -f hack
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
load_conf "fun"
# EOF # EOF
+702
View File
@@ -0,0 +1,702 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * 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.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * 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 OWNER 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,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
load_conf git
# Common helpers, then the ones private to this module (helpers/git.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
_profile_load_helpers "${BASH_SOURCE[0]}"
# ------------------------------------------------------------------------------
# Built-in defaults (can be overridden from [git] section in profile.conf)
: "${GIT_MAIN_BRANCH:=main}"
: "${GIT_DEFAULT_REMOTE:=origin}"
: "${GIT_WIP_PREFIX:=wip}"
: "${GIT_GACP_AUTO_ADD:=1}"
# ------------------------------------------------------------------------------
# Display compact git status + branch tracking information
# Usage: gst [path]
gst()
{
local PARSED
PARSED=$(getopt -o h --long help -n 'gst' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"gst --help\" to display usage."
return 1
fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "gst: Display short git status and branch tracking info.\n"
printf "Usage: gst [path]\n"
return 0
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"gst --help\" to display usage."
return 1
;;
esac
done
local target="${1:-.}"
if ! command -v git >/dev/null 2>&1; then
disp E "git command not found."
return 1
fi
if ! git -C "$target" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
disp E "'$target' is not inside a git repository."
return 1
fi
git -C "$target" status --short --branch
}
export -f gst
# ------------------------------------------------------------------------------
# Show a readable commit graph
# Usage: ggraph [-n limit]
ggraph()
{
local PARSED
PARSED=$(getopt -o hn: --long help,limit: -n 'ggraph' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"ggraph --help\" to display usage."
return 1
fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
local limit=30
while true; do
case "$1" in
-h|--help)
printf "ggraph: Display decorated git history graph.\n"
printf "Usage: ggraph [-n limit]\n"
printf "Options:\n"
printf "\t-n, --limit\tNumber of commits to display (default: 30)\n"
return 0
;;
-n|--limit)
limit="$2"
shift 2
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"ggraph --help\" to display usage."
return 1
;;
esac
done
[[ $limit =~ ^[1-9][0-9]*$ ]] || {
disp E "Invalid limit: must be a positive integer."
return 1
}
_git_require_repo || return 1
git log --graph --decorate --oneline --all --max-count="$limit"
}
export -f ggraph
# ------------------------------------------------------------------------------
# Sync current branch with remote (fetch + rebase)
# Usage: gsync [remote]
gsync()
{
local PARSED
PARSED=$(getopt -o h --long help -n 'gsync' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"gsync --help\" to display usage."
return 1
fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "gsync: Fetch and rebase current branch onto its remote tracking branch.\n"
printf "Usage: gsync [remote]\n"
return 0
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"gsync --help\" to display usage."
return 1
;;
esac
done
_git_require_repo || return 1
local branch tracking remote upstream
branch=$(_git_current_branch) || return 1
upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null) || true
# Without argument, fetch the remote the branch actually tracks, so the
# fetched refs and the rebase target always match; fall back to the
# configured default remote.
remote="${1:-}"
if [[ -z $remote ]]; then
if tracking=$(_git_tracking "$branch"); then
remote="${tracking%%$'\t'*}"
else
remote="$GIT_DEFAULT_REMOTE"
fi
fi
disp I "Fetching from $remote..."
git fetch --prune "$remote" || return 1
if [[ -z $upstream ]]; then
disp W "No upstream configured for $branch, skipping rebase."
disp I "Set one with: git branch --set-upstream-to ${remote}/${branch} ${branch}"
return 0
fi
disp I "Rebasing $branch onto $upstream..."
git rebase "$upstream"
}
export -f gsync
# ------------------------------------------------------------------------------
# Add, commit, and push changes with automatic pull/rebase if needed
# Usage: gacp -m "message" [file1 file2 ...]
gacp()
{
local PARSED
PARSED=$(getopt -o ham: --long help,auto,message: -n 'gacp' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"gacp --help\" to display usage."
return 1
fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
local msg="" auto_add=0
case "${GIT_GACP_AUTO_ADD,,}" in
1|true|yes|on) auto_add=1 ;;
esac
while true; do
case "$1" in
-h|--help)
printf "gacp: Run git add, git commit, and git push in one command.\n"
printf "Usage: gacp [-a] -m \"message\" [file1 file2 ...]\n"
printf "Options:\n"
printf "\t-a, --auto\tAutomatically add all modified files (git add -A)\n"
printf "\t-m, --message\tCommit message (mandatory)\n"
printf "\n"
printf "If files are provided, only those paths are added (-a is ignored).\n"
printf "If no file is provided and -a is active, all changes are added with git add -A.\n"
printf "Default for -a can be set via GIT_GACP_AUTO_ADD in profile.conf.\n"
printf "If the remote branch moved forward, gacp pulls with rebase before pushing.\n"
return 0
;;
-a|--auto)
auto_add=1
shift
;;
-m|--message)
msg="$2"
shift 2
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"gacp --help\" to display usage."
return 1
;;
esac
done
_git_require_repo || return 1
if [[ -z $msg ]]; then
disp E "Missing commit message. Use -m or --message."
return 1
fi
local branch upstream remote tracking_branch behind counts tracking
branch=$(_git_current_branch) || return 1
# Remote and branch tracked by the current branch (empty when none, or when
# it tracks another local branch)
if tracking=$(_git_tracking "$branch"); then
remote="${tracking%%$'\t'*}"
tracking_branch="${tracking#*$'\t'}"
upstream="${remote}/${tracking_branch}"
fi
if [[ $# -gt 0 ]]; then
auto_add=0
disp I "Adding selected paths..."
git add -- "$@" || return 1
elif [[ $auto_add -eq 1 ]]; then
disp I "Adding all changes..."
git add -A || return 1
else
disp E "No files specified. Use -a/--auto or provide file paths."
return 1
fi
if git diff --cached --quiet; then
disp W "No staged changes to commit."
return 1
fi
disp I "Creating commit..."
git commit -m "$msg" || return 1
if [[ -z $upstream ]]; then
remote="$GIT_DEFAULT_REMOTE"
tracking_branch="$branch"
fi
disp I "Fetching from $remote..."
git fetch --prune "$remote" || return 1
if git rev-parse --verify --quiet "refs/remotes/${remote}/${tracking_branch}" >/dev/null; then
counts=$(git rev-list --left-right --count HEAD..."${remote}/${tracking_branch}" 2>/dev/null) || return 1
read -r _ behind <<< "$counts"
if [[ ${behind:-0} -gt 0 ]]; then
disp I "Remote branch is ahead, rebasing before push..."
git pull --rebase "$remote" "$tracking_branch" || {
disp E "Rebase failed. Your commit is kept locally: fix the conflicts (or 'git rebase --abort') and push manually."
return 1
}
fi
fi
if [[ -n $upstream ]]; then
disp I "Pushing to $upstream..."
git push || return 1
else
disp I "Pushing and setting upstream to ${remote}/${branch}..."
git push -u "$remote" "$branch" || return 1
fi
disp I "gacp complete."
return 0
}
export -f gacp
# ------------------------------------------------------------------------------
# Reset local branch to exact upstream state (stash local changes first)
# Usage: greset [target]
greset()
{
local PARSED
PARSED=$(getopt -o hx --long help,with-ignored -n 'greset' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"greset --help\" to display usage."
return 1
fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
local clean_ignored=0
while true; do
case "$1" in
-h|--help)
printf "greset: Reset current branch to upstream, stashing local changes first.\n"
printf "Usage: greset [target]\n"
printf "Options:\n"
printf "\t-x, --with-ignored\tAlso remove ignored files (git clean -fdx); they are\n"
printf "\t\t\t\tnot stashed, so this cannot be undone\n"
printf "\n"
printf "Default target is current branch upstream (@{u}).\n"
printf "If no upstream exists, fallback target is <remote>/<branch>.\n"
printf "This command stashes local modifications (tracked + untracked),\n"
printf "drops local unpushed commits by hard-reset, and cleans untracked files.\n"
return 0
;;
-x|--with-ignored)
clean_ignored=1
shift
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"greset --help\" to display usage."
return 1
;;
esac
done
_git_require_repo || return 1
local branch upstream target remote old_head stash_msg stash_out stash_created=0 dropped=0 root
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || return 1
upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null) || true
target="$1"
# A target starting with "-" would be taken for an option by git
if [[ $target == -* ]]; then
disp E "Invalid target '$target'."
return 1
fi
if [[ -z $target ]]; then
if [[ -n $upstream ]]; then
target="$upstream"
elif [[ $branch == "HEAD" ]]; then
disp E "HEAD is detached and has no upstream: give the target explicitly."
return 1
else
remote="$GIT_DEFAULT_REMOTE"
target="${remote}/${branch}"
fi
fi
if [[ -z $remote ]]; then
remote="${target%%/*}"
fi
old_head=$(git rev-parse HEAD 2>/dev/null) || return 1
# Fetch and validate the target BEFORE touching the working tree: with an
# unknown target, the function must fail without having stashed anything.
# Only fetch when the derived remote is an actual configured remote; a bare
# tag or local-branch target has no remote to update from.
if git remote get-url "$remote" >/dev/null 2>&1; then
disp I "Fetching from $remote..."
git fetch --prune "$remote" || return 1
else
disp I "Target '$target' is not a remote ref; skipping fetch."
fi
if ! git rev-parse --verify --quiet "${target}^{commit}" >/dev/null; then
disp E "Target '$target' does not exist."
return 1
fi
# "status --porcelain" covers the whole repository (untracked files
# included), whatever the current subdirectory is.
if [[ -n $(git status --porcelain 2>/dev/null) ]]; then
stash_msg="greset:${branch}:$(date +'%Y-%m-%d %H:%M:%S')"
disp I "Stashing local changes as '$stash_msg'..."
local stash_before stash_after
stash_before=$(git rev-parse --quiet --verify refs/stash 2>/dev/null) || true
stash_out=$(git stash push -u -m "$stash_msg" 2>&1) || {
disp E "Failed to stash local changes."
printf "%s\n" "$stash_out"
return 1
}
stash_after=$(git rev-parse --quiet --verify refs/stash 2>/dev/null) || true
# A new stash means the top refs/stash sha changed (locale-independent).
if [[ -n $stash_after && $stash_after != "$stash_before" ]]; then
stash_created=1
fi
fi
dropped=$(git rev-list --count "${target}..${old_head}" 2>/dev/null || printf "0")
disp W "Hard-resetting $branch to $target..."
git reset --hard "$target" || return 1
# git clean only works below the current directory: run it from the root so
# it covers the same tree as the reset and the stash above.
root=$(git rev-parse --show-toplevel) || return 1
if (( clean_ignored )); then
git -C "$root" clean -fdx || return 1
else
git -C "$root" clean -fd || return 1
fi
if (( stash_created )); then
disp I "Local changes were stashed. Use 'git stash list' and 'git stash pop' when needed."
fi
disp I "greset complete. Dropped local-only commits: $dropped"
disp I "Previous HEAD was ${old_head:0:12}; 'git reset --hard ${old_head:0:12}' restores it."
return 0
}
export -f greset
# ------------------------------------------------------------------------------
# Create a quick WIP commit for local checkpointing
# Usage: gwip [message]
gwip()
{
local PARSED
PARSED=$(getopt -o h --long help -n 'gwip' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"gwip --help\" to display usage."
return 1
fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "gwip: Create a local checkpoint commit with all tracked/untracked changes.\n"
printf "Usage: gwip [message]\n"
return 0
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"gwip --help\" to display usage."
return 1
;;
esac
done
_git_require_repo || return 1
local msg
if [[ $# -gt 0 ]]; then
msg="$*"
else
msg="$GIT_WIP_PREFIX: $(date +'%Y-%m-%d %H:%M:%S')"
fi
git add -A || return 1
git commit -m "$msg"
}
export -f gwip
# ------------------------------------------------------------------------------
# Delete merged local branches (except protected branches)
# Usage: gprune [-D|--deep] [main-branch]
#
# Default mode: deletes branches already merged into <main-branch> locally
# (git branch --merged).
# Deep mode (-D/--deep): additionally deletes branches whose tracking remote
# has disappeared (remote pruned after a merge request / pull request merge).
# Those branches are detected via 'git fetch --prune' + remote-tracking gone.
# This is the common case when the MR was merged upstream and the remote
# branch was deleted by the forge. Deletion uses 'git branch -D' (force)
# because the local branch has no merged ancestor that git can verify locally.
gprune()
{
local PARSED deep=0
PARSED=$(getopt -o hD --long help,deep -n 'gprune' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"gprune --help\" to display usage."
return 1
fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "gprune: Delete local branches already merged into main branch.\n\n"
printf "Usage: gprune [-D|--deep] [main-branch]\n\n"
printf "Options:\n"
printf "\t-D, --deep\tAlso delete branches whose upstream was removed\n"
printf "\t\t\t(remote deleted after MR/PR merge). Uses 'git branch -D'.\n"
printf "\t-h, --help\tDisplay this help screen\n\n"
printf "Arguments:\n"
printf "\tmain-branch\tBase branch to check merges against (default: auto-detected)\n"
return 0
;;
-D|--deep)
deep=1
shift
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"gprune --help\" to display usage."
return 1
;;
esac
done
_git_require_repo || return 1
local base="${1:-$(_git_default_branch "$GIT_DEFAULT_REMOTE")}" current deleted=0 b
current=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || return 1
if ! git rev-parse --verify --quiet "refs/heads/${base}" >/dev/null; then
disp E "Base branch '$base' does not exist locally."
return 1
fi
# Branches that are never deleted: the base, the checked-out one, the usual
# trunk names and the configured main branch.
local -a protected=("$base" "$current" master main develop dev "$GIT_MAIN_BRANCH")
_gprune_is_protected()
{
local p
for p in "${protected[@]}"; do
[[ $1 == "$p" ]] && return 0
done
return 1
}
# ── Standard mode: branches locally merged into base ──────────────────────
disp I "Pruning branches merged into $base..."
# for-each-ref gives plain names (git branch output carries "*", "+" and
# colour markers); "-d" refuses anything not fully merged.
while IFS= read -r b; do
[[ -z $b ]] && continue
_gprune_is_protected "$b" && continue
if git branch -d "$b" >/dev/null 2>&1; then
printf "Deleted (merged): %s\n" "$b"
deleted=$(( deleted + 1 ))
fi
done < <(git for-each-ref --merged="$base" --format='%(refname:short)' refs/heads/)
# ── Deep mode: branches whose remote tracking ref was deleted upstream ─────
if (( deep )); then
disp I "Deep mode: pruning remote-tracking refs, then checking for gone branches..."
git fetch --prune --quiet || disp W "Fetch failed: using the remote-tracking refs as they are."
local upstream sha
# %(upstream) is the full ref configured as upstream, and stays set once
# the remote branch is gone, whereas "git rev-parse <branch>@{upstream}"
# fails then. It also avoids parsing the localized "[gone]" marker.
while IFS=$'\t' read -r b upstream; do
[[ -z $b ]] && continue
_gprune_is_protected "$b" && continue
[[ -z $upstream ]] && continue # no tracking branch at all — skip
# If the upstream ref still exists, it is not gone.
git show-ref --verify --quiet "$upstream" 2>/dev/null && continue
sha=$(git rev-parse --short "$b" 2>/dev/null)
if git branch -D "$b" >/dev/null 2>&1; then
printf "Deleted (gone upstream): %s (%s)\n" "$b" "$sha"
deleted=$(( deleted + 1 ))
fi
done < <(git for-each-ref --format='%(refname:short)%09%(upstream)' refs/heads/)
fi
unset -f _gprune_is_protected
# "if" keeps the exit status at 0 when branches were deleted
if (( deleted == 0 )); then
disp I "No branches to delete."
fi
}
export -f gprune
# ------------------------------------------------------------------------------
# Print repository root path
# Usage: groot
groot()
{
local PARSED
PARSED=$(getopt -o hg --long help,go -n 'groot' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"groot --help\" to display usage."
return 1
fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
local do_go=0
while true; do
case "$1" in
-h|--help)
printf "groot: Display the absolute path of the current repository root.\n"
printf "Usage: groot [-g|--go]\n"
printf "Options:\n"
printf "\t-g, --go\tChange current directory to repository root\n"
return 0
;;
-g|--go)
do_go=1
shift
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"groot --help\" to display usage."
return 1
;;
esac
done
_git_require_repo || return 1
local root
root=$(git rev-parse --show-toplevel) || return 1
if (( do_go )); then
cd "$root" || {
disp E "Failed to move to repository root: $root"
return 1
}
return 0
fi
printf "%s\n" "$root"
}
export -f groot
# ------------------------------------------------------------------------------
# EOF
+65 -9
View File
@@ -34,55 +34,111 @@
# * OF SUCH DAMAGE. # * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
load_conf "help"
# Common helpers, then the ones private to this module (helpers/help.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
_profile_load_helpers "${BASH_SOURCE[0]}"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Display list of commands and general informations # Display list of commands and general informations
# Usage: help # Usage: help [command]
help() help()
{ {
# shellcheck disable=SC2154 # color code in disp.sh # With a command name, delegate to its own --help output. Only shell
# shellcheck disable=SC2059 # printf format is a color variable # functions are accepted: running an arbitrary external program with an
printf "${BIWhite}Welcome to your profile! Here is a list of available commands:${DEFAULTCOL}\n\n" # option it may not understand could have side effects.
if [[ $# -gt 0 && "$1" != "--help" && "$1" != "-h" ]]; then
local cmd="$1"
if [[ $cmd == -* ]]; then
disp E "Invalid option: $cmd. Usage: help [command]"
return 1
elif declare -F -- "$cmd" >/dev/null 2>&1; then
"$cmd" --help
elif command -v -- "$cmd" >/dev/null 2>&1; then
disp E "'$cmd' is not a profile command: try \"$cmd --help\" or \"man $cmd\"."
return 1
else
disp E "Unknown command: $cmd"
return 1
fi
return
fi
# Bold title, without colour codes when NO_COLOR is set (like disp does)
if [[ -n ${NO_COLOR:-} ]]; then
printf "Welcome to your profile! Here is a list of available commands:\n\n"
else
# shellcheck disable=SC2154 # colour variables are defined by disp.sh
printf "%b%s%b\n\n" "$BIWhite" \
"Welcome to your profile! Here is a list of available commands:" "$DEFAULTCOL"
fi
printf "backtrace\tPrint the current call stack\n"
printf "busy\t\tMonitor /dev/urandom for a hex pattern — look busy\n" printf "busy\t\tMonitor /dev/urandom for a hex pattern — look busy\n"
printf "check_updates\tCheck for new versions of profile\n" printf "check_updates\tCheck for new versions of profile\n"
printf "clean\t\tErase backup files in given directories, optionally recursive\n" printf "clean\t\tErase backup files in given directories, optionally recursive\n"
printf "conf_dump\tDisplay the profile configuration file\n"
printf "conf_save\tSave or update a key=value pair in a profile configuration section\n"
printf "disp\t\tDisplay formatted info/warning/error/debug messages\n" printf "disp\t\tDisplay formatted info/warning/error/debug messages\n"
printf "dwl\t\tDownload a URL using curl, wget, or fetch transparently\n" printf "dwl\t\tDownload a URL using curl, wget, or fetch transparently\n"
printf "expandlist\tExpand glob expressions into a quoted, separated list\n" printf "expandlist\tExpand glob expressions into a quoted, separated list\n"
printf "fake_compile\tSimulate a long compilation process — look busy\n"
printf "file_stats\tDisplay file size statistics for a path\n" printf "file_stats\tDisplay file size statistics for a path\n"
printf "findbig\t\tFind the biggest files in the given or current directory\n" printf "findbig\t\tFind the biggest files in the given or current directory\n"
printf "finddead\tFind dead symbolic links in the given or current directory\n" printf "finddead\tFind dead symbolic links in the given or current directory\n"
printf "findzero\tFind empty files in the given or current directory\n" printf "findzero\tFind empty files in the given or current directory\n"
printf "gacp\t\tAdd, commit and push changes (auto-pull if needed)\n"
printf "genpwd\t\tGenerate one or more random secure passwords with configurable constraints\n" printf "genpwd\t\tGenerate one or more random secure passwords with configurable constraints\n"
printf "get_pkgmgr\tDetect the package manager of the running distribution\n"
printf "ggraph\t\tDisplay decorated git history graph\n"
printf "gpid\t\tGive the list of PIDs matching the given process name(s)\n" printf "gpid\t\tGive the list of PIDs matching the given process name(s)\n"
printf "gprune\t\tDelete local branches already merged, or after remote deletion (MR / PR)\n"
printf "greset\t\tReset branch to upstream (stash local, drop local commits)\n"
printf "groot\t\tDisplay repository root path (or cd to it with -g)\n"
printf "gsync\t\tFetch and rebase current branch onto upstream\n"
printf "gst\t\tDisplay short git status and branch tracking info\n"
printf "gwip\t\tCreate a quick WIP checkpoint commit\n"
printf "hack\t\tSimulate a dramatic hacking sequence — look dangerous\n"
printf "help\t\tDisplay this list, or the usage of a profile command (help <command>)\n"
printf "isipv4\t\tTell if the given parameter is a valid IPv4 address\n" printf "isipv4\t\tTell if the given parameter is a valid IPv4 address\n"
printf "isipv6\t\tTell if the given parameter is a valid IPv6 address\n" printf "isipv6\t\tTell if the given parameter is a valid IPv6 address\n"
printf "kt\t\tKill a process and all its children (kill tree)\n"
printf "ku\t\tKill all processes owned by the given user name or ID\n" printf "ku\t\tKill all processes owned by the given user name or ID\n"
printf "locale_check\tTell whether a locale is installed (canonical name on stdout)\n"
printf "matrix\t\tConsole screensaver with Matrix-style digital rain (binary, kana, ascii charset)\n" printf "matrix\t\tConsole screensaver with Matrix-style digital rain (binary, kana, ascii charset)\n"
printf "mcd\t\tCreate a directory and immediately move into it\n" printf "mcd\t\tCreate a directory and immediately move into it\n"
printf "mdcat\t\tRender Markdown files in terminal with colors, tables and code frames\n"
printf "meteo\t\tDisplay weather forecast for the configured or given city\n" printf "meteo\t\tDisplay weather forecast for the configured or given city\n"
printf "myextip\t\tGet information about your public IP address\n" printf "myextip\t\tGet information about your public IP address\n"
printf "pkgs\t\tSearch for a pattern in installed package names (dpkg/rpm, supports -i)\n" printf "pinfo\t\tDisplay system information read from /proc and /sys only\n"
printf "pkgf\t\tFind which installed package owns a given file (distro-aware)\n"
printf "pkgi\t\tInstall packages (distro-aware, sudo/doas when needed)\n"
printf "pkgr\t\tUninstall packages; -p purges configuration, -d removes unneeded dependencies\n"
printf "pkgs\t\tSearch for a pattern in installed package names (distro-aware)\n"
printf "ppg\t\tLook for the given pattern in running processes\n" printf "ppg\t\tLook for the given pattern in running processes\n"
printf "ppn\t\tList processes matching an exact command name\n" printf "ppn\t\tList processes matching an exact command name\n"
printf "ppu\t\tList processes owned by a specific user\n" printf "ppu\t\tList processes owned by a specific user\n"
printf "profile_upgrade\tUpgrade profile to the latest version (git pull or archive)\n" printf "profile_upgrade\tUpgrade profile to the latest version (git pull or archive)\n"
printf "pwdscore\tCalculate the strength score of a given password\n" printf "pwdscore\tCalculate the strength score of a given password\n"
printf "rain\t\tConsole screensaver with falling-rain effect (multiple color themes)\n" printf "rain\t\tConsole screensaver with falling-rain effect (multiple color themes)\n"
printf "rainbow\t\tFull-screen rainbow screensaver using background colors only\n"
printf "rmhost\t\tRemove host (name and IP) from SSH known_hosts; supports --all-users as root\n" printf "rmhost\t\tRemove host (name and IP) from SSH known_hosts; supports --all-users as root\n"
printf "rmspc\t\tReplace spaces in filenames with underscores (or a custom character)\n" printf "rmspc\t\tReplace spaces in filenames with underscores (or a custom character)\n"
printf "setlocale\tSet console locale to any installed locale\n" printf "setlocale\tSet console locale to any installed locale\n"
printf " * setc\tSet locale to standard C (POSIX)\n" printf " * setc\tSet locale to standard C (POSIX)\n"
printf " * set*\tLocale shortcuts generated from SET_LOCALE in profile.conf\n" printf " * set*\tLocale shortcuts generated from SET_LOCALE in profile.conf\n"
printf "settrace\tActivate or deactivate ERR trap to display backtrace on script errors\n"
printf "set_theme\tSwitch the prompt colour theme; no argument lists available themes\n" printf "set_theme\tSwitch the prompt colour theme; no argument lists available themes\n"
printf "showinfo\tDisplay welcome banner and system information (figlet + neofetch/fastfetch)\n" printf "settrace\tActivate or deactivate ERR trap to display backtrace on script errors\n"
printf "showinfo\tDisplay welcome banner and system information (figlet + fastfetch/neofetch)\n"
printf "ssr\t\tSSH into a server as root, forwarding extra ssh options\n" printf "ssr\t\tSSH into a server as root, forwarding extra ssh options\n"
printf "taz\t\tCompress files and directories into a chosen archive format\n" printf "taz\t\tCompress files and directories into a chosen archive format\n"
printf "term_set\tSet TERM to the best available terminal capability (auto-detect or honour config)\n"
printf "urlencode\tURL-encode a string\n" printf "urlencode\tURL-encode a string\n"
printf "utaz\t\tSmartly uncompress archives (zip, tar.gz/bz2/xz/lz, rar, arj, lha, ace, 7z, zst, cpio, cab, deb, rpm)\n" printf "utaz\t\tSmartly uncompress archives (zip, tar.gz/bz2/xz/lz, rar, arj, lha, ace, 7z, zst, cpio, cab, deb, rpm)\n"
printf "ver\t\tDisplay the installed profile version\n\n" printf "ver\t\tDisplay the installed profile version\n"
printf "\nPlease use <command> --help to obtain usage details.\n" printf "\nPlease use <command> --help or help <command> to obtain usage details.\n"
} }
export -f help export -f help
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
+279 -21
View File
@@ -34,6 +34,12 @@
# * OF SUCH DAMAGE. # * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Common helpers (helpers/common.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
load_conf info
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Show profile version # Show profile version
# Usage: ver # Usage: ver
@@ -48,7 +54,10 @@ ver()
return 1 return 1
fi fi
eval set -- "$PARSED" # Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
-h|--help) -h|--help)
@@ -66,7 +75,7 @@ ver()
esac esac
done done
[[ -z $PROFVERSION ]] && \ [[ -z ${PROFVERSION:-} ]] && \
disp W "No version defined. Profile is probably badly installed." && \ disp W "No version defined. Profile is probably badly installed." && \
return 1 return 1
disp "Profile version $PROFVERSION." disp "Profile version $PROFVERSION."
@@ -88,7 +97,10 @@ meteo()
disp E "Invalid options, use \"meteo --help\" to display usage." disp E "Invalid options, use \"meteo --help\" to display usage."
return 1 return 1
fi fi
eval set -- "$PARSED" # Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
-h|--help) -h|--help)
@@ -127,6 +139,255 @@ export -f meteo
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Display a system information block using only /proc and /sys — no external tools.
# Usage: pinfo
pinfo()
{
local _row
_row() {
printf " %b%-12s%b %b%s%b\n" "$_lbl" "$1" "$_rst" "$_val" "$2" "$_rst"
}
local PARSED
PARSED=$(getopt -o h --long help -n 'pinfo' -- "$@")
# shellcheck disable=SC2181
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"pinfo --help\" to display usage."
return 1
fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "pinfo: Display system information from /proc and /sys (no external tools required).\n"
printf "Usage: pinfo\n"
return 0
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"pinfo --help\" to display usage."
return 1
;;
esac
done
# --- Hostname ---
local hostname_str
if [[ -r /proc/sys/kernel/hostname ]]; then
read -r hostname_str < /proc/sys/kernel/hostname
else
hostname_str="${HOSTNAME:-unknown}"
fi
# --- OS release (security: parsed line by line, never sourced) ---
# /usr/lib/os-release is the documented fallback location. Values may be
# wrapped in double or single quotes.
local os_name="Unknown" os_version="" _osr_file=""
if [[ -r /etc/os-release ]]; then
_osr_file=/etc/os-release
elif [[ -r /usr/lib/os-release ]]; then
_osr_file=/usr/lib/os-release
fi
if [[ -n $_osr_file ]]; then
local _osr_key _osr_val
while IFS='=' read -r _osr_key _osr_val; do
_osr_val="${_osr_val//\"/}"
_osr_val="${_osr_val//\'/}"
case "$_osr_key" in
NAME) os_name="$_osr_val" ;;
VERSION_ID) os_version="$_osr_val" ;;
esac
done < "$_osr_file"
fi
# --- Kernel release ---
local kernel_str="unknown"
[[ -r /proc/sys/kernel/osrelease ]] && read -r kernel_str < /proc/sys/kernel/osrelease
# --- Architecture (set by bash at startup from the ELF interpreter) ---
local arch_str="${HOSTTYPE:-unknown}"
# --- Uptime (seconds from /proc/uptime) ---
local uptime_str="unknown"
if [[ -r /proc/uptime ]]; then
local _upraw
read -r _upraw _ < /proc/uptime
local _upsec="${_upraw%%.*}"
local _days=$(( _upsec / 86400 ))
local _hours=$(( (_upsec % 86400) / 3600 ))
local _mins=$(( (_upsec % 3600) / 60 ))
local _secs=$(( _upsec % 60 ))
uptime_str=""
(( _days > 0 )) && uptime_str+="${_days}d "
(( _hours > 0 || _days > 0 )) && uptime_str+="${_hours}h "
uptime_str+="${_mins}m ${_secs}s"
fi
# --- Load average ---
local load_str="unknown"
if [[ -r /proc/loadavg ]]; then
local _l1 _l5 _l15
read -r _l1 _l5 _l15 _ < /proc/loadavg
load_str="${_l1} ${_l5} ${_l15} (1/5/15 min)"
fi
# --- CPU model and logical/physical core count (pure bash) ---
local cpu_model="unknown" cpu_threads=0
local -A _seen_cores=()
local _cur_phys="" _cpu_line
if [[ -r /proc/cpuinfo ]]; then
while IFS= read -r _cpu_line; do
case "$_cpu_line" in
"model name"*|"Model name"*|"Hardware"*)
[[ "$cpu_model" == "unknown" ]] && cpu_model="${_cpu_line#*: }"
;;
"processor"*)
cpu_threads=$(( cpu_threads + 1 ))
;;
"physical id"*)
_cur_phys="${_cpu_line#*: }"
;;
"core id"*)
_seen_cores["${_cur_phys}:${_cpu_line#*: }"]=1
;;
esac
done < /proc/cpuinfo
fi
local cpu_cores="${#_seen_cores[@]}"
(( cpu_cores == 0 )) && cpu_cores=$cpu_threads
# --- Memory (pure bash, /proc/meminfo, values in kB) ---
# MemAvailable only exists since Linux 3.14; on older kernels it is
# estimated as free + buffers + cached.
local mem_total=0 mem_available=-1 mem_free=0 mem_buffers=0 mem_cached=0
local swap_total=0 swap_free=0
if [[ -r /proc/meminfo ]]; then
local _mkey _mval _munit
while read -r _mkey _mval _munit; do
case "${_mkey%:}" in
MemTotal) mem_total="$_mval" ;;
MemAvailable) mem_available="$_mval" ;;
MemFree) mem_free="$_mval" ;;
Buffers) mem_buffers="$_mval" ;;
Cached) mem_cached="$_mval" ;;
SwapTotal) swap_total="$_mval" ;;
SwapFree) swap_free="$_mval" ;;
esac
done < /proc/meminfo
fi
(( mem_available < 0 )) && mem_available=$(( mem_free + mem_buffers + mem_cached ))
local mem_total_mib=$(( mem_total / 1024 ))
local mem_used_mib=$(( (mem_total - mem_available) / 1024 ))
local swap_total_mib=$(( swap_total / 1024 ))
local swap_used_mib=$(( (swap_total - swap_free) / 1024 ))
# --- Process count (glob over numeric /proc entries) ---
local proc_count=0 _pdir
for _pdir in /proc/[0-9]*/; do
[[ -d "$_pdir" ]] && proc_count=$(( proc_count + 1 ))
done
# --- Shell and terminal ---
local shell_str="${BASH:-bash} ${BASH_VERSION%\(*}"
local term_str="${TERM:-unknown}"
# --- GPU (no external tools required; sysfs first, then /proc/bus/pci) ---
local -a gpu_list=()
local _gdir _gname _uev_key _uev_val _uev_driver _uev_pci
# Preferred: sysfs drm — each card has a device/uevent file readable
# without root, giving the PCI id and the kernel driver in use. A label
# file (rarely provided by drivers) wins when present.
for _gdir in /sys/class/drm/card[0-9]*/device; do
[[ -d "$_gdir" ]] || continue
# Connector entries (card0-eDP-1, card0-HDMI-A-1, ...) also carry a
# "device" link to the same GPU: keep the plain "cardN" entries only.
_gname="${_gdir%/device}"
[[ "${_gname##*/}" =~ ^card[0-9]+$ ]] || continue
_gname=""
_uev_driver="" _uev_pci=""
if [[ -r "$_gdir/uevent" ]]; then
while IFS='=' read -r _uev_key _uev_val; do
case "$_uev_key" in
DRIVER) _uev_driver="$_uev_val" ;;
PCI_ID) _uev_pci="$_uev_val" ;;
esac
done < "$_gdir/uevent"
[[ -n "$_uev_pci" ]] && _gname="PCI ${_uev_pci}"
[[ -n "$_uev_driver" ]] && _gname+=" (${_uev_driver})"
fi
if [[ -r "$_gdir/label" ]]; then
read -r _gname < "$_gdir/label"
elif [[ -r "$_gdir/../label" ]]; then
read -r _gname < "$_gdir/../label"
fi
[[ -z "$_gname" ]] && _gname="unknown GPU"
gpu_list+=("$_gname")
done
# Fallback: /proc/bus/pci/devices. It holds no human-readable name: the
# tab-separated fields are the bus address, vendor+device ids (8 hex
# digits), IRQ, the base addresses and sizes, then the kernel driver name
# as the last field when one is bound. Only well-known GPU drivers are
# reported, as vendor ids alone also match every other device of a vendor.
if (( ${#gpu_list[@]} == 0 )) && [[ -r /proc/bus/pci/devices ]]; then
local -a _pci=()
local _pci_id _pci_drv
while IFS=$'\t' read -r -a _pci; do
_pci_drv="${_pci[-1]// /}"
case "$_pci_drv" in
i915|xe|amdgpu|radeon|nouveau|nvidia|virtio-pci|vmwgfx|qxl|bochs-drm|cirrus|mgag200|ast)
_pci_id="${_pci[1]}"
gpu_list+=("PCI ${_pci_id:0:4}:${_pci_id:4:4} (${_pci_drv})")
;;
esac
done < /proc/bus/pci/devices
fi
# --- Render ---
# Colours are dropped under NO_COLOR, like everywhere else in the profile.
local _lbl="${BIWhite:-}" _val="${ICyan:-}" _rst="${DEFAULTCOL:-}"
if [[ -n ${NO_COLOR:-} ]]; then
_lbl="" _val="" _rst=""
fi
printf "\n"
_row "Hostname" "$hostname_str"
_row "OS" "${os_name}${os_version:+ $os_version}"
_row "Kernel" "$kernel_str"
_row "Arch" "$arch_str"
_row "Uptime" "$uptime_str"
_row "Load avg" "$load_str"
_row "CPU" "$cpu_model"
_row "Cores" "${cpu_cores} physical, ${cpu_threads} logical"
if (( ${#gpu_list[@]} > 0 )); then
local _gi
for _gi in "${!gpu_list[@]}"; do
local _gpu_lbl="GPU"
(( ${#gpu_list[@]} > 1 )) && _gpu_lbl="GPU $(( _gi + 1 ))"
_row "$_gpu_lbl" "${gpu_list[$_gi]}"
done
fi
_row "Memory" "${mem_used_mib} MiB used / ${mem_total_mib} MiB total"
if (( swap_total > 0 )); then
_row "Swap" "${swap_used_mib} MiB used / ${swap_total_mib} MiB total"
fi
_row "Processes" "$proc_count"
_row "Shell" "$shell_str"
_row "Terminal" "$term_str"
printf "\n"
unset -f _row
}
export -f pinfo
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Display system general information # Display system general information
# Usage: showinfo # Usage: showinfo
@@ -140,7 +401,10 @@ showinfo()
disp E "Invalid options, use \"showinfo --help\" to display usage." disp E "Invalid options, use \"showinfo --help\" to display usage."
return 1 return 1
fi fi
eval set -- "$PARSED" # Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
@@ -160,9 +424,13 @@ showinfo()
esac esac
done done
local hostname_str local hostname_str figopt=()
local figopt=() # /proc first: the hostname binary is missing from minimal installations
hostname_str="$(hostname)" if [[ -r /proc/sys/kernel/hostname ]]; then
read -r hostname_str < /proc/sys/kernel/hostname
else
hostname_str="${HOSTNAME:-$(hostname 2>/dev/null)}"
fi
printf "\n" printf "\n"
if command -v figlet >/dev/null 2>&1; then if command -v figlet >/dev/null 2>&1; then
@@ -174,26 +442,16 @@ showinfo()
fi fi
printf "\n" printf "\n"
if command -v neofetch >/dev/null 2>&1; then if command -v fastfetch >/dev/null 2>&1; then
neofetch
elif command -v fastfetch >/dev/null 2>&1; then
fastfetch fastfetch
elif command -v neofetch >/dev/null 2>&1; then
neofetch
else else
( pinfo "$@"
if [[ -s /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
printf "%s %s\n" "$NAME" "$VERSION"
else
cat /proc/version
fi
printf "Uptime: %s\n" "$(uptime -p)"
)
fi fi
} }
export -f showinfo export -f showinfo
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
load_conf info
# EOF # EOF
+114 -16
View File
@@ -34,14 +34,76 @@
# * OF SUCH DAMAGE. # * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Common helpers (helpers/common.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
load_conf lang
# ------------------------------------------------------------------------------
# check if a given locale is installed on the system.
# The comparison is tolerant to the various ways the codeset can be written
# across distributions (UTF-8, UTF8, utf8, utf-8, ISO-8859-1, iso88591, ...).
# On success the canonical name, exactly as the system spells it, is printed on
# stdout so callers can feed it verbatim to `export LANG=...` etc.
# Usage: canonical=$(locale_check <locale>) || handle-missing
locale_check() locale_check()
{ {
locale -a | grep -qx "$1" || { if [[ "${1:-}" == -h || "${1:-}" == --help ]]; then
disp W "Locale '$1' is not installed on this system." printf "locale_check: tell whether a locale is installed.\n"
return 1 printf "Usage: locale_check <locale>\n"
} printf "Prints the name of the locale as the system spells it; status 1 if it is not installed.\n"
return 0 return 0
fi
local requested="${1:-}"
[[ -z $requested ]] && { disp W "No locale given to check."; return 1; }
# Split "lang_TERRITORY[.codeset][@modifier]" into base, codeset, modifier.
local req_body="$requested" req_cs="" req_mod=""
[[ $req_body == *@* ]] && { req_mod="${req_body##*@}"; req_body="${req_body%@*}"; }
[[ $req_body == *.* ]] && { req_cs="${req_body##*.}"; req_body="${req_body%.*}"; }
# Normalise the codeset: lowercase then strip every non-alphanumeric char so
# that UTF-8, UTF8, utf8 and utf-8 all compare equal (likewise ISO variants).
local req_cs_norm="${req_cs,,}"
req_cs_norm="${req_cs_norm//[^a-z0-9]/}"
local cand body cs mod cs_norm fallback=""
while IFS= read -r cand; do
[[ -z $cand ]] && continue
body="$cand"; cs=""; mod=""
[[ $body == *@* ]] && { mod="${body##*@}"; body="${body%@*}"; }
[[ $body == *.* ]] && { cs="${body##*.}"; body="${body%.*}"; }
cs_norm="${cs,,}"
cs_norm="${cs_norm//[^a-z0-9]/}"
# Base language and modifier must match exactly.
[[ $body == "$req_body" && $mod == "$req_mod" ]] || continue
# A requested codeset must match (normalised); if none was requested,
# accept any codeset the system provides.
[[ -z $req_cs_norm || $cs_norm == "$req_cs_norm" ]] || continue
# With no codeset requested, UTF-8 is preferred: "locale -a" may list
# an older charset first (fr_FR, fr_FR.iso88591, fr_FR.utf8). Any other
# match is kept as a fallback in case no UTF-8 variant exists.
if [[ -z $req_cs_norm && $cs_norm != "utf8" ]]; then
[[ -z $fallback ]] && fallback="$cand"
continue
fi
printf '%s\n' "$cand"
return 0
done < <(locale -a 2>/dev/null)
if [[ -n $fallback ]]; then
printf '%s\n' "$fallback"
return 0
fi
disp W "Locale '$requested' is not installed on this system."
return 1
} }
export -f locale_check
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@@ -56,7 +118,10 @@ setlocale()
disp E "Invalid options, use \"setlocale --help\" to display usage." disp E "Invalid options, use \"setlocale --help\" to display usage."
return 1 return 1
fi fi
eval set -- "$PARSED" # Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
-h|--help) -h|--help)
@@ -80,7 +145,15 @@ setlocale()
local loc=$1 local loc=$1
[[ -z $loc ]] && disp E "No locale specified." && return 1 [[ -z $loc ]] && disp E "No locale specified." && return 1
locale_check "$loc" || return 1 # Resolve to the exact spelling installed on this system, tolerant to the
# various codeset writings (UTF-8, UTF8, utf8, utf-8, ...).
loc=$(locale_check "$loc") || return 1
# LC_ALL overrides every other variable (setc sets it), and so does any
# per-category variable inherited from the environment: drop them all so
# that the new locale really takes effect.
unset LC_ALL LC_ADDRESS LC_IDENTIFICATION LC_MEASUREMENT LC_NAME \
LC_PAPER LC_TELEPHONE
export LANG=$loc export LANG=$loc
export LC_MESSAGES=$loc export LC_MESSAGES=$loc
@@ -101,8 +174,15 @@ export -f setlocale
# Usage: setc # Usage: setc
setc() setc()
{ {
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
printf "setc: Set the console locale to the standard C (POSIX) locale.\n"
printf "Usage: setc\n"
return 0
fi
# Locale definitions # Locale definitions
export LC_ALL=C export LC_ALL=C
export LANG=C
disp I "Locale changed to standard C (POSIX)." disp I "Locale changed to standard C (POSIX)."
} }
export -f setc export -f setc
@@ -158,25 +238,43 @@ build_locale_shortcuts()
fname="set${alias}" fname="set${alias}"
# Optional collision warning # The generated function must never replace the real commands
if declare -F "$fname" >/dev/null 2>&1; then if [[ "$fname" == "setlocale" || "$fname" == "setc" ]]; then
disp W "Ignoring locale alias '$alias' in SET_LOCALE: '$fname' is a profile command."
continue
fi
# Collision warning. A function that already calls setlocale is one of
# these shortcuts, typically inherited through the environment from a
# parent shell that loaded the profile: replacing it is not worth a
# warning at every nested shell.
if declare -F "$fname" >/dev/null 2>&1 && \
! declare -f "$fname" 2>/dev/null | grep -q 'setlocale'; then
disp W "Overriding existing function '$fname'." disp W "Overriding existing function '$fname'."
fi fi
# Build function dynamically # Build function dynamically. %q quotes the locale so that nothing in
# shellcheck disable=SC2016 # the configuration value can be interpreted by eval. The shortcut
eval "${fname}() { setlocale \"$loc\"; }" # answers --help without changing anything ("help <command>" calls it).
local qloc
printf -v qloc '%q' "$loc"
eval "${fname}() {
if [[ \"\${1:-}\" == -h || \"\${1:-}\" == --help ]]; then
printf '%s: switch the console locale (see setlocale --help).\n' \"\${FUNCNAME[0]}\"
return 0
fi
setlocale ${qloc}
}"
# shellcheck disable=SC2163 # shellcheck disable=SC2163
export -f "$fname" export -f "$fname"
done done
unset cfg item alias loc fname locale_items unset cfg item alias loc fname locale_items qloc
} }
export -f build_locale_shortcuts
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Dynamically build locale functions based on SET_LOCALE configuration
load_conf lang
build_locale_shortcuts build_locale_shortcuts
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# EOF # EOF
+281 -77
View File
@@ -34,29 +34,81 @@
# * OF SUCH DAMAGE. # * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# shellcheck disable=SC2317,SC2329 # nested helper functions are called through their name (shellcheck cannot follow them)
load_conf "net"
# Common helpers, then the ones private to this module (helpers/net.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
_profile_load_helpers "${BASH_SOURCE[0]}"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Download a resource using curl, wget, or fetch. # Download a resource using curl, wget, or fetch.
# Usage: dwl <url> [output_file] # Usage: dwl [-t <seconds>] [-r|--resume] <url> [output_file]
dwl() dwl()
{ {
case "$1" in local timeout=""
--help|-h) local resume=0
echo "Usage: dwl <url> [output_file]"
echo "Downloads a resource using curl, wget, or fetch." local _res="${DWL_DEFAULT_RESUME:-}"
echo "" case "${_res,,}" in
echo "Arguments:" 1|true|yes|on)
echo " url The full URL to download (http/https/ftp)." resume=1
echo " output_file (Optional) Path to save the file. If omitted, prints to stdout."
return 0
;;
"")
echo "Error: URL argument is missing." >&2
echo "Try 'get_resource --help' for usage." >&2
return 1
;; ;;
esac esac
case "$1" in # Parse leading options before the URL.
while [[ $# -gt 0 ]]; do
case "$1" in
--help|-h)
echo "Usage: dwl [-t <seconds>|--timeout <seconds>] [-r|--resume] [--no-resume] <url> [output_file]"
echo "Downloads a resource using curl, wget, or fetch."
echo ""
echo "Arguments:"
echo " -t, --timeout Maximum time in seconds to wait for the transfer."
echo " -r, --resume Resume an interrupted download when possible (file output only)."
echo " --no-resume Disable resume mode even if enabled in config."
echo " url The full URL to download (http/https/ftp)."
echo " output_file (Optional) Path to save the file. If omitted, prints to stdout."
return 0
;;
-t|--timeout)
[[ -z "${2:-}" || ! "${2:-}" =~ ^[0-9]+$ ]] && {
echo "Error: --timeout requires a positive integer argument." >&2
return 1
}
timeout="$2"
shift 2
;;
-r|--resume)
resume=1
shift
;;
--no-resume)
resume=0
shift
;;
--)
shift
break
;;
-*)
echo "Error: Unknown option '$1'. Try 'dwl --help'." >&2
return 1
;;
*)
break
;;
esac
done
case "${1:-}" in
"")
echo "Error: URL argument is missing." >&2
echo "Try 'dwl --help' for usage." >&2
return 1
;;
http://*|https://*|ftp://*) ;; http://*|https://*|ftp://*) ;;
*) *)
echo "Error: '$1' does not look like a valid URL. Must start with http://, https://, or ftp://" >&2 echo "Error: '$1' does not look like a valid URL. Must start with http://, https://, or ftp://" >&2
@@ -65,63 +117,88 @@ dwl()
esac esac
local url="$1" local url="$1"
local output="$2" local output="${2:-}"
# Honour preferred tool from configuration; fall back to auto-detection. # Honour preferred tool from configuration; fall back to auto-detection.
local preferred="${DWL_PREFERRED_TOOL:-}" local preferred="${DWL_PREFERRED_TOOL:-}"
_try_curl() _try_curl()
{ {
# -f: an HTTP error (404, 500, ...) is a failure, not a saved error
# page; -S keeps error messages visible despite -s.
local args=(-fsSL)
[[ -n "$timeout" ]] && args+=(--max-time "$timeout" --connect-timeout "$timeout")
if [[ -z "$output" ]]; then if [[ -z "$output" ]]; then
curl -sL "$url" if (( resume == 1 )); then
echo "Warning: --resume requires an output file; ignoring resume mode for stdout." >&2
fi
curl "${args[@]}" "$url"
else else
curl -sL -o "$output" "$url" (( resume == 1 )) && args+=(-C -)
curl "${args[@]}" -o "$output" "$url"
fi fi
} }
_try_wget() _try_wget()
{ {
local args=(-q)
[[ -n "$timeout" ]] && args+=(--timeout="$timeout")
(( resume == 1 )) && args+=(-c)
if [[ -z "$output" ]]; then if [[ -z "$output" ]]; then
wget -qO- "$url" wget "${args[@]}" -O- "$url"
else else
wget -q -O "$output" "$url" wget "${args[@]}" -O "$output" "$url"
fi fi
} }
_try_fetch() _try_fetch()
{ {
local args=()
[[ -n "$timeout" ]] && args+=(-T "$timeout")
if (( resume == 1 )); then
echo "Warning: resume mode is not supported with fetch; continuing without resume." >&2
fi
if [[ -z "$output" ]]; then if [[ -z "$output" ]]; then
fetch -o - "$url" fetch "${args[@]}" -o - "$url"
else else
fetch -o "$output" "$url" fetch "${args[@]}" -o "$output" "$url"
fi fi
} }
# Pick the tool: the configured one, otherwise the first available.
# Helpers are removed on every exit path; the exit status of the transfer
# itself is returned (the final "unset -f" must not mask it).
local tool=""
if [[ -n "$preferred" ]]; then if [[ -n "$preferred" ]]; then
command -v "$preferred" >/dev/null 2>&1 || { if ! command -v "$preferred" >/dev/null 2>&1; then
echo "Error: preferred download tool '$preferred' is not installed." >&2 echo "Error: preferred download tool '$preferred' is not installed." >&2
unset -f _try_curl _try_wget _try_fetch
return 1 return 1
} fi
case "$preferred" in case "$preferred" in
curl) _try_curl ;; curl|wget|fetch) tool="$preferred" ;;
wget) _try_wget ;;
fetch) _try_fetch ;;
*) *)
echo "Error: DWL_PREFERRED_TOOL '$preferred' is not supported (use curl, wget or fetch)." >&2 echo "Error: DWL_PREFERRED_TOOL '$preferred' is not supported (use curl, wget or fetch)." >&2
unset -f _try_curl _try_wget _try_fetch
return 1 return 1
;; ;;
esac esac
elif command -v curl >/dev/null 2>&1; then elif command -v curl >/dev/null 2>&1; then
_try_curl tool=curl
elif command -v wget >/dev/null 2>&1; then elif command -v wget >/dev/null 2>&1; then
_try_wget tool=wget
elif command -v fetch >/dev/null 2>&1; then elif command -v fetch >/dev/null 2>&1; then
_try_fetch tool=fetch
else else
echo "Error: No download utility (curl, wget, or fetch) found." >&2 echo "Error: No download utility (curl, wget, or fetch) found." >&2
unset -f _try_curl _try_wget _try_fetch
return 1 return 1
fi fi
local rc=0
"_try_${tool}" || rc=$?
unset -f _try_curl _try_wget _try_fetch unset -f _try_curl _try_wget _try_fetch
return "$rc"
} }
export -f dwl export -f dwl
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@@ -138,30 +215,29 @@ isipv4()
# Start with a regex format test (four octets) # Start with a regex format test (four octets)
if [[ $ip =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then if [[ $ip =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
local old_ifs=$IFS local -a ip_arr=()
IFS='.' IFS='.' read -r -a ip_arr <<< "$ip"
read -r -a ip_arr <<< "$ip"
IFS=$old_ifs
# Ensure each octet is between 0 and 255 # Ensure each octet is between 0 and 255
local oct local oct
for oct in "${ip_arr[@]}"; do for oct in "${ip_arr[@]}"; do
# Reject leading plus/minus or empty entries # Reject leading plus/minus or empty entries
if [[ -z $oct || $oct =~ [^0-9] ]]; then if [[ -z $oct || $oct =~ [^0-9] ]]; then
[[ -t 1 ]] && disp "The given parameter is NOT a valid IPv4." [[ -t 1 ]] && disp E "The given parameter is NOT a valid IPv4."
return 1 return 1
fi fi
if (( oct > 255 )); then # 10# forces base-10 so leading-zero octets (08, 09) are not read as octal
[[ -t 1 ]] && disp "The given parameter is NOT a valid IPv4." if (( 10#$oct > 255 )); then
[[ -t 1 ]] && disp E "The given parameter is NOT a valid IPv4."
return 1 return 1
fi fi
done done
[[ -t 1 ]] && disp "The given IPv4 is valid." [[ -t 1 ]] && disp I "The given IPv4 is valid."
return 0 return 0
fi fi
[[ -t 1 ]] && disp "The given parameter is NOT a valid IPv4." [[ -t 1 ]] && disp E "The given parameter is NOT a valid IPv4."
return 1 return 1
} }
export -f isipv4 export -f isipv4
@@ -171,19 +247,94 @@ export -f isipv4
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Determine if parameter is a valid IPv6 address # Determine if parameter is a valid IPv6 address
# Usage: isipv6 <ip_address> # Usage: isipv6 <ip_address>
# Accepts full and "::"-compressed forms, an embedded IPv4 suffix
# (e.g. ::ffff:192.0.2.1) and an optional RFC 4007 zone id (e.g. fe80::1%eth0).
isipv6() isipv6()
{ {
local ip="$1" local ip="$1"
local regex='^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}$' local valid=0
if [[ $ip =~ $regex ]]; then
if [[ -t 1 ]]; then # A single-pass validation; any failing check breaks out with valid=0.
disp "The given IPv6 is valid." while true; do
[[ -z $ip ]] && break
# Strip an optional zone identifier (fe80::1%eth0); reject empty/multiple.
if [[ $ip == *%* ]]; then
[[ $ip == *%*%* ]] && break
[[ -z ${ip#*%} ]] && break
ip="${ip%%[%]*}"
fi fi
# Reject ':::' and more than one '::' compression.
[[ $ip == *:::* ]] && break
local compressed=0
if [[ $ip == *::* ]]; then
[[ ${ip#*::} == *::* ]] && break
compressed=1
fi
# A lone leading/trailing ':' that is not part of a '::' is invalid.
[[ $ip == :* && $ip != ::* ]] && break
[[ $ip == *: && $ip != *:: ]] && break
# An embedded IPv4 is allowed only as the trailing 32 bits. Validate it,
# then replace it with two zero hextets so the rest is a clean hextet list.
if [[ $ip == *.* ]]; then
local v4="${ip##*:}"
[[ $v4 == *.*.*.* ]] || break
local -a oct=()
IFS='.' read -ra oct <<< "$v4"
[[ ${#oct[@]} -eq 4 ]] || break
local o v4ok=1
for o in "${oct[@]}"; do
if [[ ! $o =~ ^[0-9]{1,3}$ ]] || (( 10#$o > 255 )); then
v4ok=0
break
fi
done
(( v4ok )) || break
ip="${ip%"$v4"}0:0"
fi
# Count the hextets on each side of an optional '::'.
local left right seg part total=0 ok=1
if (( compressed )); then
left="${ip%%::*}"
right="${ip#*::}"
else
left="$ip"
right=""
fi
for seg in "$left" "$right"; do
[[ -z $seg ]] && continue
local -a parts=()
IFS=':' read -ra parts <<< "$seg"
for part in "${parts[@]}"; do
[[ $part =~ ^[0-9a-fA-F]{1,4}$ ]] || { ok=0; break; }
total=$(( total + 1 ))
done
(( ok )) || break
done
(( ok )) || break
# Without '::' there must be exactly 8 hextets; with it, fewer than 8
# (the '::' expands to at least one zero hextet).
if (( compressed )); then
(( total <= 7 )) || break
else
(( total == 8 )) || break
fi
valid=1
break
done
if (( valid )); then
[[ -t 1 ]] && disp I "The given IPv6 is valid."
return 0 return 0
fi fi
if [[ -t 1 ]]; then [[ -t 1 ]] && disp E "The given parameter is not a valid IPv6."
disp "The given parameter is not a valid IPv6."
fi
return 1 return 1
} }
export -f isipv6 export -f isipv6
@@ -191,19 +342,24 @@ export -f isipv6
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Encode a string so it can be used as a URL parameter # Encode a string so it can be used in a URL path or query parameter: every
# byte outside the unreserved set becomes %XX (UTF-8 bytes included, space is
# %20, valid in both a path and a query).
# Usage: urlencode <string> # Usage: urlencode <string>
urlencode() urlencode()
{ {
local LANG=C # LC_ALL (not LANG) is what overrides all the locale variables: with the
# C locale each character is one byte, so multibyte characters are encoded
# byte by byte (é -> %C3%A9) whatever the user's locale is.
local LC_ALL=C
local str="$*" local str="$*"
local length="${#str}" local length="${#str}"
local i
for (( i = 0; i < length; i++ )); do for (( i = 0; i < length; i++ )); do
local c="${str:i:1}" local c="${str:i:1}"
case "$c" in case "$c" in
[a-zA-Z0-9.~_-]) printf '%s' "$c" ;; [a-zA-Z0-9.~_-]) printf '%s' "$c" ;;
' ') printf '+' ;; *) printf '%%%02X' "'$c" ;;
*) printf '%%%02X' "'$c" #| cut -d' ' -f2 ;;
esac esac
done done
} }
@@ -281,42 +437,90 @@ myextip()
shift shift
done done
# Fetch data. Allow overriding endpoint via MYEXTIP_DEFAULT_URL config key. # Fetch data. The endpoint can be changed with the MYEXTIP_DEFAULT_URL
local api_url="${MYEXTIP_DEFAULT_URL:-https://ip-api.com/json/}" # configuration key; the default is the HTTPS ipinfo.io JSON API (no key
# needed). ip-api.com is not usable: its free tier is HTTP only.
local api_url="${MYEXTIP_DEFAULT_URL:-https://ipinfo.io/json}"
if [[ $api_url != https://* ]]; then
disp W "The lookup service ($api_url) is not HTTPS: the answer is not protected."
fi
local response local response
if ! response=$(dwl "$api_url"); then if ! response=$(dwl -t 10 "$api_url"); then
disp E "Failed to fetch external IP information from $api_url" disp E "Failed to fetch external IP information from $api_url"
return 2 return 2
fi fi
# Parse with jq when available and when raw wasn't requested. The jq filter if [[ "$show_raw" == true ]]; then
# is tolerant to field-name differences between providers (ip-api / ipinfo). printf '%s\n' "$response"
if command -v jq >/dev/null 2>&1 && [[ "$show_raw" != true ]]; then return 0
echo "$response" | jq -r --argjson all "$all" --argjson ip "$show_ip" \
--argjson isp "$show_isp" --argjson loc "$show_loc" \
--argjson coord "$show_coord" --argjson as "$show_as" '
[
(if $all or $ip then "IP Address : \(.query // .ip)" else empty end),
(if $all or $isp then "ISP : \(.isp // .org)" else empty end),
(if $all or $loc then
("Location : " + ((.city // "") + (if .city then ", " else "" end) + (if .regionName then .regionName else .region end) + (if .country then ", " + .country else "" end)))
else empty end),
(if $all or $coord then (if (.lat and .lon) then "Coordinates: \(.lat), \(.lon)" elif .loc then "Coordinates: \(.loc)" else empty end) else empty end),
(if $all or $as then "AS : \(.as // .org)" else empty end)
] | .[]'
else
[[ "$show_raw" != true ]] && disp W "jq is not installed, displaying raw JSON response."
echo "$response"
fi fi
if ! command -v jq >/dev/null 2>&1; then
disp W "jq is not installed, displaying raw JSON response."
printf '%s\n' "$response"
return 0
fi
# The first jq stage turns the answer of any supported provider into one
# common object, so the display stage below does not care where the data
# comes from:
# ipinfo.io : ip, org "AS<n> <name>", city, region, country (code),
# loc "lat,lon", timezone (string), postal
# ipwho.is : ip, connection{asn,org,isp}, country (name), latitude,
# longitude, timezone{id}, postal (recognised by .connection)
# ip-api.com: query, isp, as, regionName, lat, lon, timezone, zip
# (recognised by .query)
# A missing field is simply left out of the output. An answer without any
# IP (error message, rate limit, ...) is reported as unexpected.
local out
out=$(printf '%s' "$response" | jq -r \
--argjson all "$all" --argjson ip "$show_ip" \
--argjson isp "$show_isp" --argjson loc "$show_loc" \
--argjson coord "$show_coord" --argjson as "$show_as" '
def clean: map(select(. != null and . != "")) ;
(if (.connection | type) == "object" then
{ ip: .ip,
isp: (.connection.isp // .connection.org),
as: (if .connection.asn then
(["AS\(.connection.asn)", .connection.org] | clean | join(" "))
else null end),
city: .city, region: .region, country: .country, zip: .postal,
lat: .latitude, lon: .longitude, tz: .timezone.id }
elif .query then
{ ip: .query, isp: .isp, as: .as, city: .city, region: .regionName,
country: .country, zip: .zip, lat: .lat, lon: .lon, tz: .timezone }
else
((.loc // "" | split(",")) as $l
| { ip: .ip,
isp: (.org // null | if . then sub("^AS[0-9]+ +"; "") else . end),
as: .org, city: .city, region: .region, country: .country,
zip: .postal,
lat: ($l[0] // null), lon: ($l[1] // null), tz: .timezone })
end) as $d
| if $d.ip == null then "unexpected response" | halt_error(3) else
[
(if ($all or $ip) and $d.ip then "IP Address : \($d.ip)" else empty end),
(if ($all or $isp) and $d.isp then "ISP : \($d.isp)" else empty end),
(if ($all or $loc) then
([$d.city, $d.region, $d.country] | clean | join(", ")
| select(. != "")
| "Location : \(.)" + (if $d.zip then " (\($d.zip))" else "" end))
else empty end),
(if ($all or $coord) and $d.lat and $d.lon then "Coordinates : \($d.lat), \($d.lon)" else empty end),
(if ($all or $as) and $d.as then "AS : \($d.as)" else empty end),
(if $all and $d.tz then "Timezone : \($d.tz)" else empty end)
] | .[]
end' 2>/dev/null)
local rc=$?
if (( rc != 0 )) || [[ -z $out ]]; then
disp E "Unexpected answer from $api_url (use --raw to see it)."
return 3
fi
printf '%s\n' "$out"
} }
export -f myextip export -f myextip
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# EOF
load_conf "net"
# EOF # EOF
+400 -20
View File
@@ -34,14 +34,28 @@
# * OF SUCH DAMAGE. # * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
load_conf "packages"
# Common helpers, then the ones private to this module (helpers/packages.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
_profile_load_helpers "${BASH_SOURCE[0]}"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Detect the active package manager of the current distribution. # Detect the active package manager of the current distribution.
# Detection is based on /etc/os-release (ID / ID_LIKE), then falls back to # Detection is based on /etc/os-release (ID / ID_LIKE), then falls back to
# checking available binaries in a fixed priority order. # checking available binaries in a fixed priority order.
# Echoes one of: apt dnf yum zypper pacman apk portage xbps nix # Echoes one of: apt dnf yum zypper pacman apk portage xbps nix
# Returns 1 if no known package manager could be identified. # Returns 1 if no known package manager could be identified.
_get_pkgmgr() # shellcheck disable=SC2120 # only --help is a possible argument
get_pkgmgr()
{ {
if [[ "${1:-}" == -h || "${1:-}" == --help ]]; then
printf "get_pkgmgr: print the package manager of the running distribution.\n"
printf "Usage: get_pkgmgr\n"
printf "Prints one of: apt dnf yum zypper pacman apk portage xbps nix (status 1 if unknown).\n"
return 0
fi
local distro_id="" distro_like="" local distro_id="" distro_like=""
if [[ -r /etc/os-release ]]; then if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091 # shellcheck disable=SC1091
@@ -56,30 +70,48 @@ _get_pkgmgr()
for id in $distro_id $distro_like; do for id in $distro_id $distro_like; do
case "${id,,}" in case "${id,,}" in
debian|ubuntu|linuxmint|raspbian|pop|kali|elementary|zorin|neon|parrot) debian|ubuntu|linuxmint|raspbian|pop|kali|elementary|zorin|neon|parrot)
echo "apt"; return 0 ;; echo "apt"
return 0
;;
fedora) fedora)
echo "dnf"; return 0 ;; echo "dnf"
return 0
;;
rhel|centos|rocky|almalinux|ol|scientific|amzn) rhel|centos|rocky|almalinux|ol|scientific|amzn)
command -v dnf >/dev/null 2>&1 && { echo "dnf"; return 0; } command -v dnf >/dev/null 2>&1 && { echo "dnf"; return 0; }
echo "yum"; return 0 ;; echo "yum"
opensuse*|sles|sled) return 0
echo "zypper"; return 0 ;; ;;
opensuse*|suse|sles|sled)
echo "zypper"
return 0
;;
arch|manjaro|endeavouros|garuda|artix|cachyos) arch|manjaro|endeavouros|garuda|artix|cachyos)
echo "pacman"; return 0 ;; echo "pacman"
return 0
;;
alpine) alpine)
echo "apk"; return 0 ;; echo "apk"
return 0
;;
gentoo) gentoo)
echo "portage"; return 0 ;; echo "portage"
return 0
;;
void) void)
echo "xbps"; return 0 ;; echo "xbps"
return 0
;;
nixos) nixos)
echo "nix"; return 0 ;; echo "nix"
return 0
;;
esac esac
done done
# Fallback: check for binaries in priority order. # Fallback: check for binaries in priority order.
local bin local bin
for bin in apt-get dnf yum zypper pacman apk emerge xbps-install nix-env; do for bin in apt apt-get dnf yum zypper pacman apk emerge xbps-install nix-env; do
command -v "$bin" >/dev/null 2>&1 && { command -v "$bin" >/dev/null 2>&1 && {
case "$bin" in case "$bin" in
apt-get) echo "apt" ;; apt-get) echo "apt" ;;
@@ -94,10 +126,7 @@ _get_pkgmgr()
return 1 return 1
} }
export -f _get_pkgmgr export -f get_pkgmgr
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Look for a package within installed one # Look for a package within installed one
# Usage: pkgs <string> # Usage: pkgs <string>
@@ -112,7 +141,9 @@ pkgs()
disp E "Invalid options, use \"pkgs --help\" to display usage." disp E "Invalid options, use \"pkgs --help\" to display usage."
return 1 return 1
fi fi
eval set -- "$PARSED" local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
@@ -150,7 +181,7 @@ pkgs()
(( ignore_case )) && grep_opt="-i" (( ignore_case )) && grep_opt="-i"
local pkgmgr local pkgmgr
pkgmgr=$(_get_pkgmgr) || { pkgmgr=$(get_pkgmgr) || {
disp E "No usable package manager could be detected on this system." disp E "No usable package manager could be detected on this system."
return 2 return 2
} }
@@ -170,12 +201,361 @@ pkgs()
;; ;;
esac esac
"${list_cmd[@]}" | grep ${grep_opt:+"$grep_opt"} "$pkg" # -e: a pattern starting with "-" must not be read as a grep option
"${list_cmd[@]}" | grep ${grep_opt:+"$grep_opt"} -e "$pkg"
} }
export -f pkgs export -f pkgs
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Find which installed package a file belongs to.
# Usage: pkgf <file>
pkgf()
{
local PARSED
PARSED=$(getopt -o h --long help -n 'pkgf' -- "$@")
# shellcheck disable=SC2181
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"pkgf --help\" to display usage."
return 1
fi
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "pkgf: Find which installed package owns a given file.\n\n"
printf "Usage: pkgf [options] <file>\n\n"
printf "Options:\n"
printf "\t-h, --help\tDisplay this help screen\n"
return 0
;;
--)
shift
break
;;
*)
disp E "Invalid option: $1"
return 1
;;
esac
done
load_conf "packages" local file="$1"
[[ -z "$file" ]] && {
disp E "Please specify a file path."
return 1
}
# A bare command name that is not a file here ("pkgf ls") is looked up in
# the PATH: the package managers expect a path, and would otherwise match
# the name as a pattern against every file of every package.
if [[ $file != */* && ! -e $file ]]; then
local _found
_found=$(command -v -- "$file" 2>/dev/null) && [[ $_found == /* ]] && file="$_found"
fi
# "./" keeps a name starting with "-" from being read as an option
[[ $file == -* ]] && file="./$file"
local pkgmgr
pkgmgr=$(get_pkgmgr) || {
disp E "No usable package manager could be detected on this system."
return 2
}
case "$pkgmgr" in
apt)
dpkg -S "$file"
;;
dnf|yum|zypper)
rpm -qf "$file"
;;
pacman)
pacman -Qo "$file"
;;
apk)
apk info --who-owns "$file"
;;
portage)
qfile "$file"
;;
xbps)
xbps-query -o "$file"
;;
nix)
nix-locate "$file"
;;
*)
disp E "Package manager '$pkgmgr' is not supported by pkgf."
return 2
;;
esac
}
export -f pkgf
# ------------------------------------------------------------------------------
# Install packages with the package manager of the distribution.
# Usage: pkgi [options] <package> [package...]
pkgi()
{
local assume_yes=0 dry_run=0
local PARSED
PARSED=$(getopt -o hyn --long help,yes,dry-run -n 'pkgi' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"pkgi --help\" to display usage."
return 1
fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "pkgi: Install packages (distro-aware via get_pkgmgr).\n\n"
printf "Usage: pkgi [options] <package> [package...]\n\n"
printf "Options:\n"
printf "\t-h, --help\tDisplay this help screen\n"
printf "\t-y, --yes\tDo not ask for confirmation (where the manager asks)\n"
printf "\t-n, --dry-run\tOnly display the command that would be run\n\n"
printf "Root privileges are obtained with sudo (or doas) when needed.\n"
return 0
;;
-y|--yes)
assume_yes=1
shift
;;
-n|--dry-run)
dry_run=1
shift
;;
--)
shift
break
;;
*)
disp E "Invalid option: $1"
return 1
;;
esac
done
if [[ $# -eq 0 ]]; then
disp E "Please specify at least one package name."
return 1
fi
local pkg
for pkg in "$@"; do
# Not passed after "--": not every manager understands it
if [[ $pkg == -* ]]; then
disp E "Invalid package name: '$pkg'."
return 1
fi
done
local pkgmgr
pkgmgr=$(get_pkgmgr) || {
disp E "No usable package manager could be detected on this system."
return 2
}
local -a cmd=()
local needs_root=1
case "$pkgmgr" in
apt)
cmd=(apt-get install)
(( assume_yes )) && cmd+=(-y)
;;
dnf|yum)
cmd=("$pkgmgr" install)
(( assume_yes )) && cmd+=(-y)
;;
zypper)
cmd=(zypper install)
(( assume_yes )) && cmd+=(-y)
;;
pacman)
cmd=(pacman -S)
(( assume_yes )) && cmd+=(--noconfirm)
;;
apk)
# apk add never asks for confirmation
cmd=(apk add)
;;
portage)
cmd=(emerge)
(( assume_yes )) || cmd+=(--ask)
;;
xbps)
cmd=(xbps-install -S)
(( assume_yes )) && cmd+=(-y)
;;
nix)
# nix-env works on the user's profile: no root needed
cmd=(nix-env -i)
needs_root=0
;;
*)
disp E "Package manager '$pkgmgr' is not supported by pkgi."
return 2
;;
esac
_pkg_run "$needs_root" "$dry_run" "${cmd[@]}" "$@"
}
export -f pkgi
# ------------------------------------------------------------------------------
# Uninstall packages with the package manager of the distribution.
# Usage: pkgr [options] <package> [package...]
# -p, --purge also delete the configuration files (apt purge, pacman -n,
# apk --purge); managers without such a mode just remove
# -d, --deps also remove the dependencies no longer needed. Without it,
# the managers that do it by default (dnf, yum) are told not to
# (apk always cleans up: it has no way to prevent it)
pkgr()
{
local purge=0 deps=0 assume_yes=0 dry_run=0
local PARSED
PARSED=$(getopt -o hpdyn --long help,purge,deps,yes,dry-run -n 'pkgr' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"pkgr --help\" to display usage."
return 1
fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "pkgr: Uninstall packages (distro-aware via get_pkgmgr).\n\n"
printf "Usage: pkgr [options] <package> [package...]\n\n"
printf "Options:\n"
printf "\t-h, --help\tDisplay this help screen\n"
printf "\t-p, --purge\tAlso delete configuration files (apt, pacman, apk)\n"
printf "\t-d, --deps\tAlso remove dependencies that are no longer needed\n"
printf "\t-y, --yes\tDo not ask for confirmation (where the manager asks)\n"
printf "\t-n, --dry-run\tOnly display the command that would be run\n\n"
printf "Root privileges are obtained with sudo (or doas) when needed.\n"
return 0
;;
-p|--purge)
purge=1
shift
;;
-d|--deps)
deps=1
shift
;;
-y|--yes)
assume_yes=1
shift
;;
-n|--dry-run)
dry_run=1
shift
;;
--)
shift
break
;;
*)
disp E "Invalid option: $1"
return 1
;;
esac
done
if [[ $# -eq 0 ]]; then
disp E "Please specify at least one package name."
return 1
fi
local pkg
for pkg in "$@"; do
if [[ $pkg == -* ]]; then
disp E "Invalid package name: '$pkg'."
return 1
fi
done
local pkgmgr
pkgmgr=$(get_pkgmgr) || {
disp E "No usable package manager could be detected on this system."
return 2
}
local -a cmd=() post=()
local needs_root=1 purge_done=0
case "$pkgmgr" in
apt)
# --auto-remove also drops the automatically installed packages
# nothing needs any more
if (( purge )); then cmd=(apt-get purge); purge_done=1; else cmd=(apt-get remove); fi
(( deps )) && cmd+=(--auto-remove)
(( assume_yes )) && cmd+=(-y)
;;
dnf|yum)
cmd=("$pkgmgr" remove)
# These managers remove the unneeded dependencies by default
(( deps )) || cmd+=(--setopt=clean_requirements_on_remove=False)
(( assume_yes )) && cmd+=(-y)
;;
zypper)
cmd=(zypper remove)
(( deps )) && cmd+=(--clean-deps)
(( assume_yes )) && cmd+=(-y)
;;
pacman)
# -n: no .pacsave backups of the configuration files;
# -s: also remove dependencies nothing else needs
local flags="-R"
(( purge )) && { flags+="n"; purge_done=1; }
(( deps )) && flags+="s"
cmd=(pacman "$flags")
(( assume_yes )) && cmd+=(--noconfirm)
;;
apk)
cmd=(apk del)
(( purge )) && { cmd+=(--purge); purge_done=1; }
;;
portage)
cmd=(emerge --unmerge)
(( assume_yes )) || cmd+=(--ask)
# depclean removes everything no longer needed, as autoremove does
(( deps )) && post=(emerge --depclean)
;;
xbps)
cmd=(xbps-remove)
(( deps )) && cmd+=(-R)
(( assume_yes )) && cmd+=(-y)
;;
nix)
cmd=(nix-env -e)
needs_root=0
# Dependencies live in the store: collect what is now unreachable
(( deps )) && post=(nix-collect-garbage)
;;
*)
disp E "Package manager '$pkgmgr' is not supported by pkgr."
return 2
;;
esac
if (( purge && ! purge_done )); then
disp W "$pkgmgr has no purge mode: the packages are removed normally."
fi
_pkg_run "$needs_root" "$dry_run" "${cmd[@]}" "$@" || return $?
if (( ${#post[@]} )); then
# Only reached when the removal itself succeeded
_pkg_run "$needs_root" "$dry_run" "${post[@]}"
fi
}
export -f pkgr
# ------------------------------------------------------------------------------
# EOF # EOF
+338 -81
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Copyright (c) 2013-2022 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org> # Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details. # Protected by the BSD3 license. Please read bellow for details.
# #
# * Redistribution and use in source and binary forms, # * Redistribution and use in source and binary forms,
@@ -34,18 +34,50 @@
# * OF SUCH DAMAGE. # * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
load_conf "processes"
# Common helpers, then the ones private to this module (helpers/processes.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
_profile_load_helpers "${BASH_SOURCE[0]}"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Search processes matching the given string # Search processes matching the given string
# Usage: ppg <string> # Usage: ppg <string>
ppg() ppg()
{ {
if [[ "$1" == "-h" || "$1" == "--help" ]]; then local PARSED
printf "ppg: Search processes matching the given string.\n\n"
printf "Usage: ppg <string>\n\n" PARSED=$(getopt -o h --long help -n 'ppg' -- "$@")
printf "Options:\n" # shellcheck disable=SC2181 # getopt return code is checked immediately after
printf "\t-h, --help\t\tDisplay this help screen\n" if [[ $? -ne 0 ]]; then
return 0 disp E "Invalid options, use \"ppg --help\" to display usage."
return 1
fi fi
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "ppg: Search processes matching the given string.\n\n"
printf "Usage: ppg <string>\n\n"
printf "Options:\n"
printf "\t-h, --help\t\tDisplay this help screen\n"
return 0
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"ppg --help\" to display usage."
return 1
;;
esac
done
if [[ -z "$1" ]]; then if [[ -z "$1" ]]; then
disp E "Usage: ppg <string>" disp E "Usage: ppg <string>"
return 1 return 1
@@ -53,12 +85,17 @@ ppg()
local pattern="$*" local pattern="$*"
# pgrep reads the pattern as an extended regular expression, the fallback
# below as a plain substring. Passed through ENVIRON, not awk -v: besides
# escape handling, a pattern on awk's own command line would match the awk
# process in the ps listing.
if command -v pgrep >/dev/null 2>&1; then if command -v pgrep >/dev/null 2>&1; then
pgrep -af -- "$pattern" pgrep -af -- "$pattern"
return $? return $?
fi fi
ps -ef | awk -v pattern="$pattern" ' ps -ef | PPG_PATTERN="$pattern" awk '
BEGIN { pattern = ENVIRON["PPG_PATTERN"] }
NR == 1 { NR == 1 {
print print
next next
@@ -73,21 +110,43 @@ ppg()
' '
} }
export -f ppg export -f ppg
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# List processes owned by a specific user # List processes owned by a specific user
# Usage: ppu <username> # Usage: ppu <username>
ppu() ppu()
{ {
if [[ "$1" == "-h" || "$1" == "--help" ]]; then local PARSED
printf "ppu: List processes owned by a specific user.\n\n"
printf "Usage: ppu <username>\n\n" PARSED=$(getopt -o h --long help -n 'ppu' -- "$@")
printf "Options:\n" # shellcheck disable=SC2181 # getopt return code is checked immediately after
printf "\t-h, --help\t\tDisplay this help screen\n" if [[ $? -ne 0 ]]; then
return 0 disp E "Invalid options, use \"ppu --help\" to display usage."
return 1
fi fi
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "ppu: List processes owned by a specific user.\n\n"
printf "Usage: ppu <username>\n\n"
printf "Options:\n"
printf "\t-h, --help\t\tDisplay this help screen\n"
return 0
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"ppu --help\" to display usage."
return 1
;;
esac
done
if [[ -z "$1" ]]; then if [[ -z "$1" ]]; then
disp E "Usage: ppu <username>" disp E "Usage: ppu <username>"
return 1 return 1
@@ -98,48 +157,101 @@ ppu()
ps -u "$1" -o "${PPU_DEFAULT_FORMAT:-pid,user,%cpu,%mem,start,time,command}" ps -u "$1" -o "${PPU_DEFAULT_FORMAT:-pid,user,%cpu,%mem,start,time,command}"
} }
export -f ppu export -f ppu
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# List processes by exact command name (no path/parameters) # List processes by exact command name (no path/parameters)
# Usage: ppn <command_name> # Usage: ppn <command_name>
ppn() ppn()
{ {
if [[ "$1" == "-h" || "$1" == "--help" ]]; then local PARSED
printf "ppn: List processes by exact command name (no path/parameters).\n\n"
printf "Usage: ppn <command_name>\n\n" PARSED=$(getopt -o h --long help -n 'ppn' -- "$@")
printf "Options:\n" # shellcheck disable=SC2181 # getopt return code is checked immediately after
printf "\t-h, --help\t\tDisplay this help screen\n" if [[ $? -ne 0 ]]; then
return 0 disp E "Invalid options, use \"ppn --help\" to display usage."
return 1
fi fi
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "ppn: List processes by exact command name (no path/parameters).\n\n"
printf "Usage: ppn <command_name>\n\n"
printf "Options:\n"
printf "\t-h, --help\t\tDisplay this help screen\n"
return 0
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"ppn --help\" to display usage."
return 1
;;
esac
done
if [[ -z "$1" ]]; then if [[ -z "$1" ]]; then
disp E "Usage: ppn <command_name>" disp E "Usage: ppn <command_name>"
return 1 return 1
fi fi
# -e: select all processes # ps prints the PID and the command name (comm, 15 characters at most, no
# -o: specify custom output columns (PID and Command name) # arguments); awk keeps the header and the lines whose whole name equals
# grep -w: ensures exact word matching so 'bash' doesn't match 'dbash' # the argument. grep -w was wrong: it also matched inside the PID column,
# shellcheck disable=SC2009 # pgrep do not offer the -w switch # and in names such as "kde-bash" ("-" is not a word character).
ps -eo pid,comm | grep -w "$1" ps -eo pid,comm | PPN_NAME="$1" awk '
BEGIN { name = ENVIRON["PPN_NAME"] }
NR == 1 { print; next }
{
line = $0
sub(/^ *[0-9]+ +/, "", line)
if (line == name) { print; found = 1 }
}
END { exit found ? 0 : 1 }
'
} }
export -f ppn export -f ppn
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Get PID list of the given process name # Get PID list of the given process name
# Usage: ppid <process_name [process_name2 ...]> # Usage: gpid <process_name [process_name2 ...]>
gpid() gpid()
{ {
if [[ "$1" == "-h" || "$1" == "--help" ]]; then local PARSED
printf "gpid: Get PID list of the given process name.\n\n"
printf "Usage: gpid <process_name [process_name2 ...]>\n\n" PARSED=$(getopt -o h --long help -n 'gpid' -- "$@")
printf "Options:\n" # shellcheck disable=SC2181 # getopt return code is checked immediately after
printf "\t-h, --help\t\tDisplay this help screen\n" if [[ $? -ne 0 ]]; then
return 0 disp E "Invalid options, use \"gpid --help\" to display usage."
return 1
fi fi
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "gpid: Get PID list of the given process name.\n\n"
printf "Usage: gpid <process_name [process_name2 ...]>\n\n"
printf "Options:\n"
printf "\t-h, --help\t\tDisplay this help screen\n"
return 0
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"gpid --help\" to display usage."
return 1
;;
esac
done
if [[ -z "$1" ]]; then if [[ -z "$1" ]]; then
disp E "Usage: gpid <process_name [process_name2 ...]>" disp E "Usage: gpid <process_name [process_name2 ...]>"
return 1 return 1
@@ -156,7 +268,8 @@ gpid()
if command -v pgrep >/dev/null 2>&1; then if command -v pgrep >/dev/null 2>&1; then
result=$(pgrep -d ' ' -x -- "$proc_name") result=$(pgrep -d ' ' -x -- "$proc_name")
else else
result=$(ps -eo pid=,comm= | awk -v proc="$proc_name" ' result=$(ps -eo pid=,comm= | GPID_NAME="$proc_name" awk '
BEGIN { proc = ENVIRON["GPID_NAME"] }
$2 == proc { $2 == proc {
if (out != "") { if (out != "") {
out = out " " out = out " "
@@ -182,75 +295,219 @@ gpid()
(( found )) || return 1 (( found )) || return 1
} }
export -f gpid export -f gpid
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Kill all processes owned by the given users (kill user) # Kill all processes owned by the given users (kill user)
# Usage: ku <username1 [username2 ...]> # Usage: ku [options] <username1 [username2 ...]>
# The users are validated first: nothing is killed if one of them is unknown.
# The processes of root and of the current user are never killed (it would take
# the session down), except in dry-run mode.
ku() ku()
{ {
if [[ "$1" == "-h" || "$1" == "--help" ]]; then local dry_run=0
printf "ku: Kill all processes owned by the given users.\n\n" local -a signal_opt=() _ku_sigs=() _ku_rest=()
printf "Usage: ku <username1 [username2 ...]>\n\n"
printf "Options:\n" _proc_split_signals _ku_sigs _ku_rest "$@"
printf "\t-h, --help\t\tDisplay this help screen\n"
return 0 local PARSED
fi PARSED=$(getopt -o hns: --long help,dry-run,signal: -n 'ku' -- "${_ku_rest[@]}")
if [[ -z "$1" ]]; then # shellcheck disable=SC2181 # getopt return code is checked immediately after
disp E "Usage: ku <username1 [username2 ...]>" if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"ku --help\" to display usage."
return 1 return 1
fi fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "ku: Kill all processes owned by the given users.\n\n"
printf "Usage: ku [options] <username1 [username2 ...]>\n\n"
printf "Options:\n"
printf "\t-h, --help\t\tDisplay this help screen\n"
printf "\t-n, --dry-run\t\tDisplay commands without executing them\n"
printf "\t-s, --signal SIG\tSignal to send (overrides KU_DEFAULT_SIGNAL)\n"
printf "\t --signal=SIG\tSame as above\n"
printf "\t -SIG / -NUM\t\tSignal format compatible with kill\n\n"
printf "Users are given by name or numeric ID. The processes of root and of the\n"
printf "current user are never killed (except with --dry-run).\n"
return 0
;;
-n|--dry-run)
dry_run=1
shift
;;
-s|--signal)
signal_opt=(-s "$2")
shift 2
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"ku --help\" to display usage."
return 1
;;
esac
done
# A kill-style signal (-9, -KILL) applies unless -s/--signal was given
if [[ ${#signal_opt[@]} -eq 0 && ${#_ku_sigs[@]} -gt 0 ]]; then
signal_opt=("${_ku_sigs[-1]}")
fi
if [[ $# -eq 0 ]]; then
disp E "Usage: ku [options] <username1 [username2 ...]>"
return 1
fi
# First pass: resolve every user to a name (killall only takes names, so
# numeric IDs are translated) and check it, before anything is killed.
local u uid uname
local -a unames=()
for u in "$@"; do for u in "$@"; do
if ! id "$u" >/dev/null 2>&1; then if ! uid=$(id -u -- "$u" 2>/dev/null); then
disp E "User '$u' does not exist." disp E "User '$u' does not exist."
return 1 return 1
fi
uname=$(id -un -- "$u")
if (( ! dry_run )) && [[ $uid -eq 0 || $uid -eq $EUID ]]; then
disp E "Safety abort: refusing to kill the processes of '$uname' (root or current user)."
return 1
fi
unames+=("$uname")
done
# Second pass: kill. The failure of one user does not skip the others.
local rc=0
local -a cmd
for uname in "${unames[@]}"; do
# killall (psmisc) preferred; fall back to pkill (procps-ng).
if command -v killall >/dev/null 2>&1; then
cmd=(killall)
if [[ ${#signal_opt[@]} -gt 0 ]]; then
cmd+=("${signal_opt[@]}")
elif [[ -n "${KU_DEFAULT_SIGNAL:-}" ]]; then
cmd+=("-${KU_DEFAULT_SIGNAL}")
fi
cmd+=(-u "$uname")
elif command -v pkill >/dev/null 2>&1; then
cmd=(pkill)
# Translate killall's -s SIGNAME form to pkill's -SIGNAME form.
if [[ ${#signal_opt[@]} -eq 2 && "${signal_opt[0]}" == "-s" ]]; then
cmd+=("-${signal_opt[1]}")
elif [[ ${#signal_opt[@]} -gt 0 ]]; then
cmd+=("${signal_opt[@]}")
elif [[ -n "${KU_DEFAULT_SIGNAL:-}" ]]; then
cmd+=("-${KU_DEFAULT_SIGNAL}")
fi
cmd+=(-u "$uname")
else else
killall ${KU_DEFAULT_SIGNAL:+-${KU_DEFAULT_SIGNAL}} -u "$u" disp E "ku: neither 'killall' (psmisc) nor 'pkill' (procps) is available."
return 1
fi
if (( dry_run )); then
printf "DRY-RUN: "
printf "%q " "${cmd[@]}"
printf "\n"
else
"${cmd[@]}" || rc=1
fi fi
done done
return "$rc"
} }
export -f ku export -f ku
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Kill all children of a process then the process (kill tree) # Kill all children of a process then the process (kill tree)
# Usage: kt <pid> [kill_options] # Usage: kt [options] <pid>
kt() kt()
{ {
if [[ "$1" == "-h" || "$1" == "--help" ]]; then local dry_run=0
printf "kt: Kill all children of a process then the process (kill tree).\n\n" local -a kill_args=() _kt_sigs=() _kt_rest=()
printf "Usage: kt <pid> [kill_options]\n\n"
printf "Options:\n" _proc_split_signals _kt_sigs _kt_rest "$@"
printf "\t-h, --help\t\tDisplay this help screen\n"
return 0 local PARSED
PARSED=$(getopt -o hns: --long help,dry-run,signal: -n 'kt' -- "${_kt_rest[@]}")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"kt --help\" to display usage."
return 1
fi fi
if [[ -z "$1" ]]; then # Restore the getopt-normalised (shell-quoted) argument list
disp E "Usage: kt <pid>" local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "kt: Kill all children of a process then the process (kill tree).\n\n"
printf "Usage: kt [options] <pid>\n\n"
printf "Options:\n"
printf "\t-h, --help\t\tDisplay this help screen\n"
printf "\t-n, --dry-run\t\tDisplay kill commands without executing them\n"
printf "\t-s, --signal SIG\tSignal to send to process tree\n"
printf "\t --signal=SIG\tSame as above\n"
printf "\t -SIG / -NUM\t\tSignal format compatible with kill\n"
return 0
;;
-n|--dry-run)
dry_run=1
shift
;;
-s|--signal)
kill_args=(-s "$2")
shift 2
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"kt --help\" to display usage."
return 1
;;
esac
done
# A kill-style signal (-9, -KILL) applies unless -s/--signal was given
if [[ ${#kill_args[@]} -eq 0 && ${#_kt_sigs[@]} -gt 0 ]]; then
kill_args=("${_kt_sigs[-1]}")
fi
if [[ $# -eq 0 ]]; then
disp E "Usage: kt [options] <pid>"
return 1
fi
if [[ $# -gt 1 ]]; then
disp E "Unexpected argument: $2. Usage: kt [options] <pid>"
return 1 return 1
fi fi
local parent_pid="$1" local parent_pid="$1"
shift if [[ ! $parent_pid =~ ^[0-9]+$ ]]; then
disp E "Invalid PID: '$parent_pid'."
return 1
fi
if [[ "$parent_pid" == "0" || "$parent_pid" == "1" ]]; then if [[ "$parent_pid" == "0" || "$parent_pid" == "1" ]]; then
disp E "Safety abort: Refusing to kill PID $parent_pid (system critical)." disp E "Safety abort: Refusing to kill PID $parent_pid (system critical)."
return 1 return 1
fi fi
if [[ "$parent_pid" == "$$" || "$parent_pid" == "$BASHPID" ]]; then
disp E "Safety abort: Refusing to kill the current shell (PID $parent_pid)."
return 1
fi
if ! command -v pgrep >/dev/null 2>&1; then
disp E "kt: 'pgrep' (procps) is required to find the children."
return 1
fi
local children_pids _kt_tree "$dry_run" "$parent_pid" "${kill_args[@]}"
children_pids=$(pgrep -P "$parent_pid")
for pid in $children_pids; do
kt "$pid" "$@" || break
done
kill "$@" "$parent_pid"
} }
export -f kt export -f kt
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
load_conf "processes"
# EOF # EOF
+219 -115
View File
@@ -34,6 +34,12 @@
# * OF SUCH DAMAGE. # * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
load_conf "prompt"
# Common helpers, then the ones private to this module (helpers/prompt.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
_profile_load_helpers "${BASH_SOURCE[0]}"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Parse a prompt theme file safely — it is NEVER sourced or executed. # Parse a prompt theme file safely — it is NEVER sourced or executed.
@@ -41,11 +47,8 @@
# PROMPT_COLOR_* — prompt slot colours (TIME_FG, BAR_BG, …) # PROMPT_COLOR_* — prompt slot colours (TIME_FG, BAR_BG, …)
# Standard colour variables from disp.sh (Blue, On_IBlack, …) — allows a # Standard colour variables from disp.sh (Blue, On_IBlack, …) — allows a
# theme to redefine the palette used everywhere in the shell session. # theme to redefine the palette used everywhere in the shell session.
# Allowed value forms: # Values follow the forms accepted by _prompt_resolve_color. Any other key or
# $ColorName or ${ColorName} — colour variable from disp.sh (resolved by # value is rejected with a warning.
# indirection via ${!varname})
# \e[...m or \033[...m — raw ANSI escape literal (single block)
# Any other key or value is rejected with a warning.
# Usage: load_theme <theme_name_or_path> [theme_dir] # Usage: load_theme <theme_name_or_path> [theme_dir]
# theme_name_or_path : bare name (e.g. "dark") or an explicit path. # theme_name_or_path : bare name (e.g. "dark") or an explicit path.
# theme_dir : directory to search for bare names; defaults to # theme_dir : directory to search for bare names; defaults to
@@ -54,23 +57,18 @@
load_theme() load_theme()
{ {
local theme_name="$1" local theme_name="$1"
local theme_dir="${2:-${PROMPT_THEME_DIR:-$MYPATH/profile.d/themes}}"
local theme_file="" local theme_file=""
[[ -z "$theme_name" ]] && return 0 [[ -z "$theme_name" ]] && return 0
if [[ "$theme_name" == /* || "$theme_name" == */* ]]; then theme_file=$(_prompt_theme_file "$theme_name" "${2:-}")
theme_file="$theme_name"
else
theme_file="$theme_dir/${theme_name}.theme"
fi
if [[ ! -f "$theme_file" || ! -r "$theme_file" ]]; then if [[ ! -f "$theme_file" || ! -r "$theme_file" ]]; then
printf "[ Warning ] load_theme: theme file not found: %s\n" "$theme_file" >&2 printf "[ Warning ] load_theme: theme file not found: %s\n" "$theme_file" >&2
return 1 return 1
fi fi
# ---- Key whitelist: prompt slots ---------------------------------------- # ---- Key whitelist: prompt slots and standard colour variables ----------
local -A _lth_allowed=( local -A _lth_allowed=(
[PROMPT_COLOR_TIME_FG]=1 [PROMPT_COLOR_TIME_BG]=1 [PROMPT_COLOR_TIME_FG]=1 [PROMPT_COLOR_TIME_BG]=1
[PROMPT_COLOR_BAR_BG]=1 [PROMPT_COLOR_BAR_BG]=1
@@ -78,49 +76,22 @@ load_theme()
[PROMPT_COLOR_ERR_BG]=1 [PROMPT_COLOR_ERR_FG]=1 [PROMPT_COLOR_ERR_MARK]=1 [PROMPT_COLOR_ERR_BG]=1 [PROMPT_COLOR_ERR_FG]=1 [PROMPT_COLOR_ERR_MARK]=1
[PROMPT_COLOR_ROOT_FG]=1 [PROMPT_COLOR_USER_FG]=1 [PROMPT_COLOR_ROOT_FG]=1 [PROMPT_COLOR_USER_FG]=1
[PROMPT_COLOR_DIR_FG]=1 [PROMPT_COLOR_DIR_FG]=1
[PROMPT_COLOR_CTX_FG]=1
) )
# ---- Colour variable names exported by disp.sh --------------------------
local _lth_color_re
_lth_color_re='Black|Red|Green|Yellow|Blue|Purple|Cyan|White'
_lth_color_re+='|BBlack|BRed|BGreen|BYellow|BBlue|BPurple|BCyan|BWhite'
_lth_color_re+='|UBlack|URed|UGreen|UYellow|UBlue|UPurple|UCyan|UWhite'
_lth_color_re+='|On_Black|On_Red|On_Green|On_Yellow|On_Blue|On_Purple|On_Cyan|On_White'
_lth_color_re+='|IBlack|IRed|IGreen|IYellow|IBlue|IPurple|ICyan|IWhite'
_lth_color_re+='|BIBlack|BIRed|BIGreen|BIYellow|BIBlue|BIPurple|BICyan|BIWhite'
_lth_color_re+='|On_IBlack|On_IRed|On_IGreen|On_IYellow|On_IBlue|On_IPurple|On_ICyan|On_IWhite'
_lth_color_re+='|DEFAULTFG|DEFAULTBG|DEFAULTCOL|RESETCOL'
# ---- Key whitelist: standard colour vars (same list as above) -----------
local _lth_cn local _lth_cn
for _lth_cn in \ for _lth_cn in "${_PROMPT_COLOR_NAMES[@]}"; do
Black Red Green Yellow Blue Purple Cyan White \
BBlack BRed BGreen BYellow BBlue BPurple BCyan BWhite \
UBlack URed UGreen UYellow UBlue UPurple UCyan UWhite \
On_Black On_Red On_Green On_Yellow On_Blue On_Purple On_Cyan On_White \
IBlack IRed IGreen IYellow IBlue IPurple ICyan IWhite \
BIBlack BIRed BIGreen BIYellow BIBlue BIPurple BICyan BIWhite \
On_IBlack On_IRed On_IGreen On_IYellow On_IBlue On_IPurple On_ICyan On_IWhite \
DEFAULTFG DEFAULTBG DEFAULTCOL RESETCOL; do
_lth_allowed[$_lth_cn]=1 _lth_allowed[$_lth_cn]=1
done done
unset _lth_cn
# ERE: safe colour reference $Name or ${Name}
local _lth_ref_re='^\$\{?('"$_lth_color_re"')\}?$'
# ERE: raw ANSI escape literal \e[...m or \033[...m
local _lth_ansi_re='^(\\e|\\033)\[[0-9;]*m$'
# ---- Line parser --------------------------------------------------------- # ---- Line parser ---------------------------------------------------------
local _lth_line _lth_key _lth_value _lth_varname _lth_lineno=0 local _lth_line _lth_key _lth_value _lth_lineno=0
while IFS= read -r _lth_line || [[ -n "$_lth_line" ]]; do while IFS= read -r _lth_line || [[ -n "$_lth_line" ]]; do
((_lth_lineno++)) _lth_lineno=$(( _lth_lineno + 1 ))
_lth_line="${_lth_line%$'\r'}" # strip CR _lth_line="${_lth_line%$'\r'}" # strip CR
_lth_line="${_lth_line#"${_lth_line%%[![:space:]]*}"}" # ltrim _lth_line="${_lth_line#"${_lth_line%%[![:space:]]*}"}" # ltrim
_lth_line="${_lth_line%"${_lth_line##*[![:space:]]}"}" # rtrim _lth_line="${_lth_line%"${_lth_line##*[![:space:]]}"}" # rtrim
[[ -z "$_lth_line" || "$_lth_line" == '#'* ]] && continue # blank/comment [[ -z "$_lth_line" || "$_lth_line" == '#'* ]] && continue # blank/comment
[[ "$_lth_line" == 'export '* ]] && _lth_line="${_lth_line#export }" # strip prefix [[ "$_lth_line" == 'export '* ]] && _lth_line="${_lth_line#export }" # strip prefix
if [[ "$_lth_line" != *=* ]]; then if [[ "$_lth_line" != *=* ]]; then
@@ -149,25 +120,16 @@ load_theme()
_lth_value="${_lth_value%%\'*}" _lth_value="${_lth_value%%\'*}"
fi fi
if [[ "$_lth_value" =~ $_lth_ref_re ]]; then if _prompt_resolve_color "$_lth_value"; then
# Safe colour variable reference — resolve via indirection export "$_lth_key"="$_PROMPT_RESOLVED"
_lth_varname="${_lth_value#\$}"
_lth_varname="${_lth_varname#\{}"
_lth_varname="${_lth_varname%\}}"
export "$_lth_key"="${!_lth_varname}"
elif [[ "$_lth_value" =~ $_lth_ansi_re ]]; then
# Raw ANSI escape literal — accept as-is
export "$_lth_key"="$_lth_value"
else else
printf "[ Warning ] load_theme: %s:%d: invalid value for '%s', ignoring.\n" \ printf "[ Warning ] load_theme: %s:%d: invalid value for '%s', ignoring.\n" \
"$theme_file" "$_lth_lineno" "$_lth_key" >&2 "$theme_file" "$_lth_lineno" "$_lth_key" >&2
fi fi
done < "$theme_file" done < "$theme_file"
} }
# Not exported, it remains private # Not exported, it remains private (like the other _prompt_* helpers: set_theme
# ------------------------------------------------------------------------------ # and set_prompt are meant to be used from the interactive shell itself).
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Dynamically switch the prompt theme for the current shell session. # Dynamically switch the prompt theme for the current shell session.
# Calls load_theme to apply the new colour values immediately, then updates # Calls load_theme to apply the new colour values immediately, then updates
@@ -178,27 +140,86 @@ load_theme()
set_theme() set_theme()
{ {
local theme_dir="${PROMPT_THEME_DIR:-${MYPATH}/profile.d/themes}" local theme_dir="${PROMPT_THEME_DIR:-${MYPATH}/profile.d/themes}"
local preview=0
local save=0
local list_only=0
local theme_name=""
# -- help mode ----------------------------------------------------------- while [[ $# -gt 0 ]]; do
if [[ "$1" == "-h" || "$1" == "--help" ]]; then case "$1" in
printf "set_theme: Switch the prompt colour theme for the current shell session.\n\n" -h|--help)
printf "Usage: set_theme [options] [theme]\n\n" printf "set_theme: Switch the prompt colour theme for the current shell session.\n\n"
printf "Options:\n" printf "Usage: set_theme [options] [theme]\n\n"
printf " -h, --help Display this help screen\n" printf "Options:\n"
printf " -l, --list List available themes (default when no argument is given)\n\n" printf "\t-h, --help\tDisplay this help screen\n"
printf "Arguments:\n" printf "\t-l, --list\tList available themes (default when no argument is given)\n"
printf " theme Bare theme name (e.g. 'dark') or an explicit path to a .theme file.\n" printf "\t-p, --preview\tPreview a theme without applying it\n"
printf " Themes are searched in: %s\n" "$theme_dir" printf "\t-S, --save\tSave theme to configuration\n\n"
printf " Override with PROMPT_THEME_DIR in profile.conf [prompt].\n\n" printf "Arguments:\n"
printf "Examples:\n" printf "\ttheme \tBare theme name (e.g. 'dark') or an explicit path to a .theme file.\n"
printf " set_theme — list available themes\n" printf "\t \tThemes are searched in: %s\n" "$theme_dir"
printf " set_theme dark — apply the dark theme\n" printf "\t \tOverride with PROMPT_THEME_DIR in profile.conf [prompt].\n\n"
printf " set_theme ~/my.theme — apply a theme by path\n" printf "Examples:\n"
return 0 printf "\tset_theme \t— list available themes\n"
printf "\tset_theme dark \t— apply the dark theme\n"
printf "\tset_theme -p dark \t— preview the dark theme\n"
printf "\tset_theme -S \t— save the current theme to the configuration\n"
printf "\tset_theme -S dark \t— apply and save the dark theme\n"
printf "\tset_theme ~/my.theme\t— apply a theme by path\n"
return 0
;;
-l|--list)
list_only=1
shift
;;
-p|--preview)
preview=1
shift
;;
-S|--save)
save=1
shift
;;
--)
shift
break
;;
-*)
disp E "Unknown option: $1, use \"set_theme --help\" to display usage."
return 1
;;
*)
if [[ -n "$theme_name" ]]; then
disp E "Too many arguments. Usage: set_theme [options] [theme]"
return 1
fi
theme_name="$1"
shift
;;
esac
done
if [[ $# -gt 0 ]]; then
if [[ -n "$theme_name" ]]; then
disp E "Too many arguments. Usage: set_theme [options] [theme]"
return 1
fi
theme_name="$1"
shift
fi fi
# -- list mode ----------------------------------------------------------- # -- list mode -----------------------------------------------------------
if [[ $# -eq 0 || "$1" == "-l" || "$1" == "--list" ]]; then if (( list_only )) || [[ -z "$theme_name" && $preview -eq 0 ]]; then
if (( save )); then
if [[ -n "${PROMPT_THEME:-}" ]]; then
conf_save "prompt" "PROMPT_THEME" "$PROMPT_THEME" || return 1
disp I "Saved current prompt theme '$PROMPT_THEME' to configuration."
return 0
fi
disp E "No active theme to save. Apply a theme first or pass one with -S."
return 1
fi
printf "Available themes in %s:\n" "$theme_dir" printf "Available themes in %s:\n" "$theme_dir"
local f name local f name
for f in "$theme_dir"/*.theme; do for f in "$theme_dir"/*.theme; do
@@ -214,45 +235,101 @@ set_theme()
return 0 return 0
fi fi
# -- apply mode ---------------------------------------------------------- if (( preview )) && [[ -z "$theme_name" ]]; then
local theme_name="$1" disp E "--preview requires a theme argument."
return 1
fi
# Reset colours to defaults before loading the new theme if (( preview && save )); then
set_colors disp E "--preview and --save cannot be used together."
load_theme "$theme_name" || return 1 return 1
fi
# -- preview mode --------------------------------------------------------
if (( preview )); then
# Snapshot the palette and the prompt colours by value, to put back
# exactly what was there (including the PROMPT_COLOR_* that did not
# exist, which a previewed theme would otherwise leave behind).
local -A _snap=()
local _v
for _v in "${_PROMPT_COLOR_NAMES[@]}" ${!PROMPT_COLOR_@}; do
[[ -n "${!_v+x}" ]] && _snap[$_v]="${!_v}"
done
local _rc=0
_prompt_load_theme_clean "$theme_name" "$theme_dir" || _rc=1
if (( _rc == 0 )); then
printf "Preview for theme '%s':\n" "$theme_name"
printf "%b [ 13:37:00 ] %b%b [ 0 %b• (5s) %b] %b user@host%b [ git:main +1/-0 ] %b\n" \
"${PROMPT_COLOR_TIME_FG:-}${PROMPT_COLOR_TIME_BG:-}" "${DEFAULTCOL:-}" \
"${PROMPT_COLOR_OK_FG:-}${PROMPT_COLOR_BAR_BG:-}" \
"${PROMPT_COLOR_OK_MARK:-}${PROMPT_COLOR_BAR_BG:-}" \
"${PROMPT_COLOR_OK_FG:-}${PROMPT_COLOR_BAR_BG:-}" \
"${PROMPT_COLOR_USER_FG:-}${PROMPT_COLOR_BAR_BG:-}" \
"${PROMPT_COLOR_CTX_FG:-}${PROMPT_COLOR_BAR_BG:-}" "${DEFAULTCOL:-}"
printf "%b [ 13:37:00 ] %b%b [ 0 %b• (5s) %b] %b user@host%b [ git:main +1/-0 ] %b\n" \
"${PROMPT_COLOR_TIME_FG:-}${PROMPT_COLOR_TIME_BG:-}" "${DEFAULTCOL:-}" \
"${PROMPT_COLOR_OK_FG:-}${PROMPT_COLOR_ERR_BG:-}" \
"${PROMPT_COLOR_ERR_MARK:-}${PROMPT_COLOR_ERR_BG:-}" \
"${PROMPT_COLOR_OK_FG:-}${PROMPT_COLOR_ERR_BG:-}" \
"${PROMPT_COLOR_USER_FG:-}${PROMPT_COLOR_BAR_BG:-}" \
"${PROMPT_COLOR_CTX_FG:-}${PROMPT_COLOR_BAR_BG:-}" "${DEFAULTCOL:-}"
printf "%b/path/to/dir \$ %b\n" \
"${PROMPT_COLOR_DIR_FG:-}" "${DEFAULTCOL:-}"
fi
# Restore: every colour variable is either put back or removed
for _v in "${_PROMPT_COLOR_NAMES[@]}" ${!PROMPT_COLOR_@}; do
if [[ -n "${_snap[$_v]+x}" ]]; then
export "$_v=${_snap[$_v]}"
else
unset "$_v"
fi
done
return "$_rc"
fi
# -- apply mode ----------------------------------------------------------
_prompt_load_theme_clean "$theme_name" "$theme_dir" || return 1
export PROMPT_THEME="$theme_name" export PROMPT_THEME="$theme_name"
if (( save )); then
conf_save "prompt" "PROMPT_THEME" "$theme_name" || return 1
disp I "Prompt theme set to $theme_name and saved to configuration."
return 0
fi
disp I "Prompt theme set to $theme_name." disp I "Prompt theme set to $theme_name."
} }
export -f set_theme export -f set_theme
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# timer_* functions : internal timing functions for the prompt.
# The start is recorded by the DEBUG trap (profile.sh) before each command and
# ------------------------------------------------------------------------------ # the elapsed time is computed by set_prompt, through PROMPT_COMMAND.
# timer_* functions : internal timing function for prompt
# Usage: timer_now
# This function returns the current time in nanoseconds since the epoch. It
# first tries to use the %N format specifier for nanoseconds, but if that is
# not supported (e.g., on older systems), it falls back to seconds.
function timer_now
{
date +%s%N 2>/dev/null || date +%s
}
# Usage: timer_start # Usage: timer_start
# This function initializes the timer_start variable with the current time in # Record the start time of the command about to run, unless already recorded
# nanoseconds. It is used to measure the elapsed time for the prompt. # (the DEBUG trap fires for every simple command of a pipeline or function: only
function timer_start # the first one counts).
timer_start()
{ {
timer_start=${timer_start:-$(timer_now)} if [[ -z "${_timer_start_us:-}" ]]; then
_timer_now
# shellcheck disable=SC2154 # set by _timer_now (helpers/prompt.sh)
_timer_start_us="$_timer_us"
fi
} }
# Usage: timer_stop # Usage: timer_stop
# This function calculates the elapsed time since timer_start and formats it # Compute the time elapsed since timer_start and format it into timer_show with
# into a human-readable string with appropriate units (us, ms, s, m, h # an appropriate unit (us, ms, s, m, h).
function timer_stop timer_stop()
{ {
local delta_us=$((($(timer_now) - timer_start) / 1000)) _timer_now
local delta_us=$(( _timer_us - ${_timer_start_us:-$_timer_us} ))
(( delta_us < 0 )) && delta_us=0
local us=$((delta_us % 1000)) local us=$((delta_us % 1000))
local ms=$(((delta_us / 1000) % 1000)) local ms=$(((delta_us / 1000) % 1000))
local s=$(((delta_us / 1000000) % 60)) local s=$(((delta_us / 1000000) % 60))
@@ -266,7 +343,7 @@ function timer_stop
elif ((s >= 10)); then elif ((s >= 10)); then
timer_show=${s}.$((ms / 100))s timer_show=${s}.$((ms / 100))s
elif ((s > 0)); then elif ((s > 0)); then
timer_show=${s}.$(printf %03d $ms)s printf -v timer_show '%d.%03ds' "$s" "$ms"
elif ((ms >= 100)); then elif ((ms >= 100)); then
timer_show=${ms}ms timer_show=${ms}ms
elif ((ms > 0)); then elif ((ms > 0)); then
@@ -274,9 +351,16 @@ function timer_stop
else else
timer_show=${us}us timer_show=${us}us
fi fi
unset timer_start unset _timer_start_us
} }
# ------------------------------------------------------------------------------
# Prompt segments. Each one puts its text in _PROMPT_SEG (empty when there is
# nothing to show), without a command substitution (no subshell per segment).
# They are defined once, at load time, and not inside set_prompt: a function
# definition resets $?, and set_prompt must read the exit status of the user's
# last command before anything else runs.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Function triggered internally by bash : defining prompt # Function triggered internally by bash : defining prompt
# Usage: set_prompt # Usage: set_prompt
@@ -285,10 +369,23 @@ function timer_stop
# command, the elapsed time of the last command, and the current user and host. # command, the elapsed time of the last command, and the current user and host.
set_prompt() set_prompt()
{ {
local Last_Command=$? # Must come first! local Last_Command=$? # Must come first: nothing may run before this line
# When other commands precede set_prompt in PROMPT_COMMAND (profile.sh adds
# _profile_save_status first for that case), $? is theirs: use the saved one.
if [[ -n "${_profile_status+x}" ]]; then
Last_Command="$_profile_status"
unset _profile_status
fi
local FancyX='\342\234\227' local FancyX='\342\234\227'
local Checkmark='\342\234\223' local Checkmark='\342\234\223'
# The context segment is not copied into PS1. Bash expands PS1 when
# displaying it, including $(...) and `...`: a git branch (or conda
# environment) named 'x$(command)' would be executed. PS1 only contains the
# reference ${_PROMPT_CTX}, whose value is inserted as plain text, never
# re-scanned. This needs promptvars (the default).
shopt -q promptvars || shopt -s promptvars
# Resolve theme/config colours with hardcoded fallbacks # Resolve theme/config colours with hardcoded fallbacks
local _time_fg="${PROMPT_COLOR_TIME_FG:-$Blue}" local _time_fg="${PROMPT_COLOR_TIME_FG:-$Blue}"
local _time_bg="${PROMPT_COLOR_TIME_BG:-$On_White}" local _time_bg="${PROMPT_COLOR_TIME_BG:-$On_White}"
@@ -301,6 +398,7 @@ set_prompt()
local _root_fg="${PROMPT_COLOR_ROOT_FG:-$Red}" local _root_fg="${PROMPT_COLOR_ROOT_FG:-$Red}"
local _user_fg="${PROMPT_COLOR_USER_FG:-$BGreen}" local _user_fg="${PROMPT_COLOR_USER_FG:-$BGreen}"
local _dir_fg="${PROMPT_COLOR_DIR_FG:-$ICyan}" local _dir_fg="${PROMPT_COLOR_DIR_FG:-$ICyan}"
local _ctx_fg="${PROMPT_COLOR_CTX_FG:-$BIYellow}"
# Begin with time (cursor-save is non-printing; all ANSI sequences wrapped # Begin with time (cursor-save is non-printing; all ANSI sequences wrapped
# in \[...\] so bash does not count them toward the visible line width). # in \[...\] so bash does not count them toward the visible line width).
@@ -310,33 +408,40 @@ set_prompt()
# Add exit status of the last command. # Add exit status of the last command.
# If it was successful, print a green check mark. Otherwise, print a red X. # If it was successful, print a green check mark. Otherwise, print a red X.
timer_stop
if [[ $Last_Command == 0 ]]; then if [[ $Last_Command == 0 ]]; then
PS1+="\[${_ok_fg}${_bar_bg}\] [ $Last_Command " PS1+="\[${_ok_fg}${_bar_bg}\] [ $Last_Command "
PS1+="\[${_ok_mark}${_bar_bg}\]${Checkmark} " PS1+="\[${_ok_mark}${_bar_bg}\]${Checkmark} "
# Add the elapsed time, then close the status section and return to bar bg. # Add the elapsed time, then close the status section and return to bar bg.
timer_stop
PS1+="($timer_show)\[${_ok_fg}${_bar_bg}\] ] " PS1+="($timer_show)\[${_ok_fg}${_bar_bg}\] ] "
else else
PS1+="\[${_err_fg}${_err_bg}\] [ $Last_Command " PS1+="\[${_err_fg}${_err_bg}\] [ $Last_Command "
PS1+="\[${_err_mark}${_err_bg}\]${FancyX} " PS1+="\[${_err_mark}${_err_bg}\]${FancyX} "
timer_stop
PS1+="($timer_show)\[${_err_fg}${_err_bg}\] ] " PS1+="($timer_show)\[${_err_fg}${_err_bg}\] ] "
fi fi
# If root, print the host in root colour. Otherwise use user colour. # If root, print the host in root colour. Otherwise use user colour.
if [[ $EUID -eq 0 ]]; then if [[ $EUID -eq 0 ]]; then
PS1+="\[${_root_fg}${_bar_bg}\] \\u\[${_user_fg}${_bar_bg}\]@\\h" PS1+="\[${_root_fg}${_bar_bg}\] \\u\[${_user_fg}${_bar_bg}\]@\\h"
else else
PS1+="\[${_user_fg}${_bar_bg}\] \\u@\\h" PS1+="\[${_user_fg}${_bar_bg}\] \\u@\\h"
fi fi
# Optional context segment appended at the end of the top bar.
_PROMPT_CTX=""
_prompt_seg_git; _prompt_ctx_add
_prompt_seg_conda; _prompt_ctx_add
_prompt_seg_venv; _prompt_ctx_add
_prompt_seg_session; _prompt_ctx_add
if [[ -n "$_PROMPT_CTX" ]]; then
# Single quotes: the reference must reach PS1 literally (see above)
PS1+="\[${_ctx_fg}${_bar_bg}\] [ "'${_PROMPT_CTX}'" ]"
fi
PS1+="\[\e[K\e[u\]\[$RESETCOL\]\n" PS1+="\[\e[K\e[u\]\[$RESETCOL\]\n"
# Print the working directory and prompt marker, then reset colour. # Print the working directory and prompt marker, then reset colour.
PS1+="\[${_dir_fg}\]\\w \\\$\[$RESETCOL\] " PS1+="\[${_dir_fg}\]\\w \\\$\[$RESETCOL\] "
} }
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Theme and configuration loading. # Theme and configuration loading.
# Precedence (lowest → highest): # Precedence (lowest → highest):
@@ -344,18 +449,17 @@ set_prompt()
# 2. Theme file (PROMPT_THEME key from [prompt] section) # 2. Theme file (PROMPT_THEME key from [prompt] section)
# 3. Individual PROMPT_COLOR_* overrides in [prompt] section # 3. Individual PROMPT_COLOR_* overrides in [prompt] section
# #
# load_conf "prompt" (top of this file) has exported every key of the section,
# the theme file is loaded over them, and _prompt_apply_overrides then puts the
# PROMPT_COLOR_* keys of the section back on top (resolving $ColorName values).
# CONF_prompt is already populated by parse_conf (run in profile.sh before # CONF_prompt is already populated by parse_conf (run in profile.sh before
# modules are sourced). We extract PROMPT_THEME and PROMPT_THEME_DIR from the # modules are sourced), which is where PROMPT_THEME and PROMPT_THEME_DIR are
# raw associative array now so load_theme can run before load_conf "prompt" # read from.
# exports remaining keys. That way any PROMPT_COLOR_* value set explicitly in
# [prompt] wins over the same variable set by the theme file.
_pt_theme="${CONF_prompt[PROMPT_THEME]:-}" _pt_theme="${CONF_prompt[PROMPT_THEME]:-}"
_pt_dir="${CONF_prompt[PROMPT_THEME_DIR]:-}" _pt_dir="${CONF_prompt[PROMPT_THEME_DIR]:-}"
[[ -n "$_pt_theme" ]] && load_theme "$_pt_theme" ${_pt_dir:+"$_pt_dir"} [[ -n "$_pt_theme" ]] && load_theme "$_pt_theme" ${_pt_dir:+"$_pt_dir"}
unset _pt_theme _pt_dir unset _pt_theme _pt_dir
_prompt_apply_overrides
load_conf "prompt"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# EOF # EOF
+45 -18
View File
@@ -34,6 +34,13 @@
# * OF SUCH DAMAGE. # * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
load_conf "pwd"
# Common helpers, then the ones private to this module (helpers/pwd.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
_profile_load_helpers "${BASH_SOURCE[0]}"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# genpwd : generate a password with different criteria # genpwd : generate a password with different criteria
# Usage: genpwd [options] [--extracars=<cars>] [--length=<n>] [nb_passwd] # Usage: genpwd [options] [--extracars=<cars>] [--length=<n>] [nb_passwd]
@@ -66,8 +73,14 @@ genpwd()
help,nosymbols,nonumbers,noup,nolow,extracars:,length:,occurences:,occurrences: \ help,nosymbols,nonumbers,noup,nolow,extracars:,length:,occurences:,occurrences: \
-n 'genpwd' -- "$@") -n 'genpwd' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after # shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then return 1; fi if [[ $? -ne 0 ]]; then
eval set -- "$PARSED" disp E "Invalid options, use \"genpwd --help\" to display usage."
return 1
fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
@@ -146,6 +159,21 @@ genpwd()
fi fi
local carset="" local carset=""
# The defaults may come from the configuration file: bash evaluates a
# non-numeric string inside (( )) as an expression, so check them first.
local _v
for _v in "$symb" "$numb" "$maj" "$min"; do
if [[ $_v != 0 && $_v != 1 ]]; then
disp E "Invalid GENPWD_DEFAULT_* value '$_v': the character class switches must be 0 or 1."
return 1
fi
done
for _v in "$length" "$occurs" "$nbpwd"; do
if ! [[ $_v =~ ^[1-9][0-9]*$ ]]; then
disp E "Invalid GENPWD_DEFAULT_* value '$_v': length, occurrences and count must be positive integers."
return 1
fi
done
local unique_carset="" local unique_carset=""
local ch="" local ch=""
local i=0 local i=0
@@ -206,7 +234,9 @@ genpwd()
return 1 return 1
fi fi
disp I "Generating $nbpwd password(s), please wait..." # On stderr: stdout carries the passwords only, so that "pw=$(genpwd)" or
# "genpwd | xclip" do not capture this message.
disp I "Generating $nbpwd password(s), please wait..." >&2
for (( n=1; n<=nbpwd; n++ )); do for (( n=1; n<=nbpwd; n++ )); do
local -a password_chars=() local -a password_chars=()
local -A char_count=() local -A char_count=()
@@ -220,7 +250,8 @@ genpwd()
return 1 return 1
fi fi
idx=$(( RANDOM % ${#set} )) _pwd_rand "${#set}" || return 1
idx=$_PWD_RAND
char=${set:idx:1} char=${set:idx:1}
count=${char_count["$char"]:-0} count=${char_count["$char"]:-0}
@@ -230,7 +261,7 @@ genpwd()
break break
fi fi
((attempts++)) attempts=$(( attempts + 1 ))
done done
done done
@@ -242,7 +273,8 @@ genpwd()
return 1 return 1
fi fi
idx=$(( RANDOM % ${#carset} )) _pwd_rand "${#carset}" || return 1
idx=$_PWD_RAND
char=${carset:idx:1} char=${carset:idx:1}
count=${char_count["$char"]:-0} count=${char_count["$char"]:-0}
@@ -252,12 +284,13 @@ genpwd()
break break
fi fi
((attempts++)) attempts=$(( attempts + 1 ))
done done
done done
for (( i=${#password_chars[@]} - 1; i>0; i-- )); do for (( i=${#password_chars[@]} - 1; i>0; i-- )); do
idx=$(( RANDOM % (i + 1) )) _pwd_rand "$(( i + 1 ))" || return 1
idx=$_PWD_RAND
char=${password_chars[i]} char=${password_chars[i]}
password_chars[i]=${password_chars[idx]} password_chars[i]=${password_chars[idx]}
password_chars[idx]=$char password_chars[idx]=$char
@@ -268,9 +301,6 @@ genpwd()
done done
} }
export -f genpwd export -f genpwd
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# pwdscore : score a password quality from 1 to 100 # pwdscore : score a password quality from 1 to 100
# Usage: pwdscore [options] <password> # Usage: pwdscore [options] <password>
@@ -308,7 +338,7 @@ pwdscore()
break break
;; ;;
-*) -*)
disp E "Invalid option '$1'. Use \"pwdscore --help\" to display usage." disp E "Invalid option '$1'. Use \"pwdscore --help\" to display usage (a password starting with '-' must follow '--')."
return 1 return 1
;; ;;
*) *)
@@ -369,17 +399,17 @@ pwdscore()
score=$(( length * 2 )) score=$(( length * 2 ))
fi fi
if [[ $password =~ [a-z] ]]; then if [[ $password =~ [[:lower:]] ]]; then
has_lower=1 has_lower=1
pool_size=$(( pool_size + 26 )) pool_size=$(( pool_size + 26 ))
score=$(( score + 12 )) score=$(( score + 12 ))
fi fi
if [[ $password =~ [A-Z] ]]; then if [[ $password =~ [[:upper:]] ]]; then
has_upper=1 has_upper=1
pool_size=$(( pool_size + 26 )) pool_size=$(( pool_size + 26 ))
score=$(( score + 12 )) score=$(( score + 12 ))
fi fi
if [[ $password =~ [0-9] ]]; then if [[ $password =~ [[:digit:]] ]]; then
has_digit=1 has_digit=1
pool_size=$(( pool_size + 10 )) pool_size=$(( pool_size + 10 ))
score=$(( score + 12 )) score=$(( score + 12 ))
@@ -477,7 +507,4 @@ pwdscore()
export -f pwdscore export -f pwdscore
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
load_conf "pwd"
# EOF # EOF
+188 -251
View File
@@ -34,261 +34,37 @@
# * OF SUCH DAMAGE. # * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------ load_conf "rain"
# Generic rain-like engine and presets
_rain_build_colors() # Common helpers, then the ones private to this module (helpers/rain.sh)
{ # shellcheck source=../helpers/common.sh
local base_color="$1" declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
RAIN_ENGINE_COLORS=() _profile_load_helpers "${BASH_SOURCE[0]}"
case $base_color in
green)
for i in {22..28} {34..40} {46..48}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;;
blue)
for i in {17..21} {27..33} {39..45}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;;
red)
for i in {52..52} {88..88} {124..124} {160..160} {196..201}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;;
yellow)
for i in {58..58} {100..100} {142..142} {184..184} {226..229}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;;
cyan)
for i in {30..31} {37..38} {44..45} {50..51}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;;
*)
RAIN_ENGINE_COLORS=("\e[37m" "\e[37;1m")
for i in {244..255}; do RAIN_ENGINE_COLORS+=("\e[38;5;${i}m"); done ;;
esac
}
_rain_build_chars()
{
local mode="$1"
local charset="$2"
RAIN_ENGINE_CHARS=()
case "$mode" in
matrix)
case "$charset" in
""|binary)
RAIN_ENGINE_CHARS=("0" "1")
;;
kana|kanji)
# Half-width katakana set, generally rendered as single-cell glyphs.
RAIN_ENGINE_CHARS=("ア" "イ" "ウ" "エ" "オ" "カ" "キ" "ク" "ケ" "コ" "サ" "シ" "ス" "セ" "ソ" "タ" "チ" "ツ" "テ" "ト" "ナ" "ニ" "ヌ" "ネ" "ノ" "ハ" "ヒ" "フ" "ヘ" "ホ" "マ" "ミ" "ム" "メ" "モ" "ヤ" "ユ" "ヨ" "ラ" "リ" "ル" "レ" "ロ" "ワ" "ン")
;;
ascii)
RAIN_ENGINE_CHARS=("0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "A" "B" "C" "D" "E" "F")
;;
*)
disp E "Unknown charset: ${charset} (supported: binary, kana, ascii)."
return 1
;;
esac
;;
*)
RAIN_ENGINE_CHARS=("|" "│" "┃" "┆" "┇" "┊" "┋" "╽" "╿")
;;
esac
return 0
}
_rain_normalize_speed()
{
local raw_speed="$1"
# Accept integer/floating values. UI scale is centiseconds by default:
# 5 -> 0.05s, 2.5 -> 0.025s. Values < 1 are treated as direct seconds
# for backward compatibility (e.g. 0.03).
if [[ ! "$raw_speed" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
return 1
fi
if awk -v s="$raw_speed" 'BEGIN { exit !(s < 1) }'; then
printf "%s" "$raw_speed"
else
awk -v s="$raw_speed" 'BEGIN { printf "%.3f", s / 100 }'
fi
}
_rain_engine()
{
local step_duration="$1"
local base_color="$2"
local mode="$3"
local charset="$4"
command -v tput >/dev/null 2>&1 || {
disp E "The program 'tput' is required but not installed."
return 1
}
_rain_build_colors "$base_color"
_rain_build_chars "$mode" "$charset" || return 1
local rain_colors=("${RAIN_ENGINE_COLORS[@]}")
local rain_chars=("${RAIN_ENGINE_CHARS[@]}")
local rain_color_tab=${#rain_colors[@]}
local rain_tab=${#rain_chars[@]}
local matrix_head_color=$'\e[1;97m'
local exit_st=0
local num_rain_metadata=5
local term_height=0 term_width=0
local X=0 Y=0 drop_length=0 rain_drop=0
local max_rain_width=0 max_rain_height=0
local new_rain_odd=0 falling_odd=0
local term_area=0
local frame_sleep="$step_duration"
sigwinch()
{
term_width=$(tput cols)
term_height=$(tput lines)
((term_area = term_width * term_height))
case "$mode" in
matrix)
((max_rain_width = term_area / 3))
((max_rain_height = term_height < 8 ? 1 : term_height / 6))
((new_rain_odd = term_height > 50 ? 100 : term_height * 2))
((new_rain_odd = new_rain_odd * 85 / 100))
((falling_odd = 100))
# Adapt cadence and density to terminal size for smoother rendering.
if ((term_area < 1200)); then
((max_rain_width = term_area / 4))
frame_sleep=$(awk -v s="$step_duration" 'BEGIN { printf "%.3f", s * 1.15 }')
elif ((term_area > 5000)); then
((max_rain_width = term_area / 2))
frame_sleep=$(awk -v s="$step_duration" 'BEGIN { printf "%.3f", s * 0.85 }')
else
frame_sleep="$step_duration"
fi
;;
*)
((max_rain_width = term_area / 4))
((max_rain_height = term_height < 10 ? 1 : term_height / 10))
((new_rain_odd = term_height > 50 ? 100 : term_height * 2))
((new_rain_odd = new_rain_odd * 75 / 100))
((falling_odd = term_height > 25 ? 100 : term_height * 4))
((falling_odd = falling_odd * 90 / 100))
frame_sleep="$step_duration"
;;
esac
}
do_exit()
{
exit_st=1
}
do_render()
{
local idx=0 y=0 drop_color="" current_char="" render_color=""
for ((idx = 0; idx < num_rains * num_rain_metadata; idx += num_rain_metadata)); do
X=${rains[idx]}
Y=${rains[idx + 1]}
drop_length=${rains[idx + 4]}
for ((y = Y; y < Y + drop_length; y++)); do
((y < 1 || y > term_height)) && continue
printf "\e[%d;%dH " "$y" "$X"
done
done
for ((idx = 0; idx < num_rains * num_rain_metadata; idx += num_rain_metadata)); do
if ((100 * RANDOM / 32768 < falling_odd)); then
if ((++rains[idx + 1] > term_height)); then
rains=("${rains[@]:0:idx}" "${rains[@]:idx+num_rain_metadata:num_rains*num_rain_metadata}")
((num_rains--))
continue
fi
fi
X=${rains[idx]}
Y=${rains[idx + 1]}
rain_drop=${rains[idx + 2]}
drop_color=${rains[idx + 3]}
drop_length=${rains[idx + 4]}
for ((y = Y; y < Y + drop_length; y++)); do
((y < 1 || y > term_height)) && continue
if [[ "$mode" == "matrix" ]]; then
current_char="${rain_chars[rain_tab * RANDOM / 32768]}"
if ((y == Y + drop_length - 1)); then
render_color="$matrix_head_color"
else
render_color="$drop_color"
fi
else
current_char="$rain_drop"
render_color="$drop_color"
fi
printf "\e[%d;%dH%b%s" "$y" "$X" "$render_color" "$current_char"
done
done
}
trap do_exit TERM INT
trap sigwinch WINCH
stty -echo
printf "\e[?25l"
printf "\e[2J"
local rains=()
local num_rains=0
local ch=""
sigwinch
while ((exit_st <= 0)); do
read -r -n 1 -t "$frame_sleep" ch
case "$ch" in
q|Q)
do_exit
;;
esac
if ((num_rains < max_rain_width)) && ((100 * RANDOM / 32768 < new_rain_odd)); then
rain_drop="${rain_chars[rain_tab * RANDOM / 32768]}"
drop_color="${rain_colors[rain_color_tab * RANDOM / 32768]}"
drop_length=$((max_rain_height * RANDOM / 32768 + 1))
X=$((term_width * RANDOM / 32768 + 1))
Y=$((1 - drop_length))
rains=("${rains[@]}" "$X" "$Y" "$rain_drop" "$drop_color" "$drop_length")
((num_rains++))
fi
do_render
done
printf "\e[%d;1H\e[0K" "$term_height"
printf "\e[?25h"
stty echo
trap - TERM INT
trap - WINCH
}
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Let the rain fall (current style) # Let the rain fall (current style)
# Usage: rain [OPTIONS] # Usage: rain [OPTIONS]
rain() rain()
{ {
local _rain_show_usage
_rain_show_usage() _rain_show_usage()
{ {
printf "Usage: rain [OPTIONS]\n" printf "Usage: rain [OPTIONS]\n"
printf "Options:\n" printf "Options:\n"
printf "\t-s, --speed NUM Set speed value (default: 5 => 0.050s).\n" printf "\t-s, --speed NUM\tSet speed value (default: 5 => 0.050s).\n"
printf "\t Values >=1 use a /100 scale (5 => 0.05s).\n" printf "\t\t\t\tValues >=1 use a /100 scale (5 => 0.05s).\n"
printf "\t Values <1 are interpreted as raw seconds.\n" printf "\t\t\t\tValues <1 are interpreted as raw seconds.\n"
printf "\t-c, --color COLOR Set the color theme (default: white).\n" printf "\t-d, --density NUM\tMaximum number of simultaneous falling elements.\n"
printf "\t-h, --help Display this help message and exit.\n\n" printf "\t-c, --color COLOR\tSet the color theme (default: white).\n"
printf "Available Colors:\n" printf "\t-h, --help\t\tDisplay this help message and exit.\n\n"
printf "Available Colors (case-insensitive):\n"
printf "\t\e[32mgreen\e[0m\t: Matrix-like green shades\n" printf "\t\e[32mgreen\e[0m\t: Matrix-like green shades\n"
printf "\t\e[34mblue\e[0m\t: Deep ocean blue gradients\n" printf "\t\e[34mblue\e[0m\t: Deep ocean blue gradients\n"
printf "\t\e[31mred\e[0m\t: Crimson/Blood rain\n" printf "\t\e[31mred\e[0m\t: Crimson/Blood rain\n"
printf "\t\e[33myellow\e[0m\t: Amber and gold tones\n" printf "\t\e[33myellow\e[0m\t: Amber and gold tones\n"
printf "\t\e[36mcyan\e[0m\t: Electric cyan/turquoise\n" printf "\t\e[36mcyan\e[0m\t: Electric cyan/turquoise\n"
printf "\twhite\t: Greyscale and white (original style)\n\n" printf "\twhite\t: Greyscale and white (original style)\n\n"
printf "Press q (or Ctrl-C) to quit.\n"
printf "Example: rain --color green --speed 3\n" printf "Example: rain --color green --speed 3\n"
} }
@@ -296,19 +72,24 @@ rain()
local step_duration local step_duration
step_duration=$(_rain_normalize_speed "$_raw_speed") || step_duration=0.050 step_duration=$(_rain_normalize_speed "$_raw_speed") || step_duration=0.050
local base_color="${RAIN_DEFAULT_COLOR:-white}" local base_color="${RAIN_DEFAULT_COLOR:-white}"
local density_override="${RAIN_DEFAULT_DENSITY:-}"
if [[ -n "$density_override" ]]; then
density_override=$(_rain_normalize_density "$density_override") || density_override=""
fi
while [[ "$#" -gt 0 ]]; do while [[ "$#" -gt 0 ]]; do
case $1 in case $1 in
-s|--speed) -s|--speed)
if [[ -n "$2" && ! "$2" =~ ^- ]]; then if [[ -n "$2" && ! "$2" =~ ^- ]]; then
step_duration=$(_rain_normalize_speed "$2") || { step_duration=$(_rain_normalize_speed "$2") || {
disp E "--speed requires a numeric value." disp E "--speed requires a numeric value greater than 0."
_rain_show_usage _rain_show_usage
return 1 return 1
} }
shift shift
else else
disp E "--speed requires a numeric value." disp E "--speed requires a numeric value greater than 0."
_rain_show_usage _rain_show_usage
return 1 return 1
fi fi
@@ -323,6 +104,20 @@ rain()
return 1 return 1
fi fi
;; ;;
-d|--density)
if [[ -n "$2" && ! "$2" =~ ^- ]]; then
density_override=$(_rain_normalize_density "$2") || {
disp E "--density requires a positive integer value."
_rain_show_usage
return 1
}
shift
else
disp E "--density requires a positive integer value."
_rain_show_usage
return 1
fi
;;
-h|--help) -h|--help)
_rain_show_usage _rain_show_usage
return 0 return 0
@@ -340,7 +135,7 @@ rain()
shift shift
done done
_rain_engine "$step_duration" "$base_color" "rain" "" _rain_engine "$step_duration" "$base_color" "rain" "" "$density_override"
} }
export -f rain export -f rain
@@ -349,16 +144,19 @@ export -f rain
# Usage: matrix [OPTIONS] # Usage: matrix [OPTIONS]
matrix() matrix()
{ {
local _matrix_show_usage
_matrix_show_usage() _matrix_show_usage()
{ {
printf "Usage: matrix [OPTIONS]\n" printf "Usage: matrix [OPTIONS]\n"
printf "Options:\n" printf "Options:\n"
printf "\t-s, --speed NUM Set speed value (default: 3.5 => 0.035s).\n" printf "\t-s, --speed NUM\tSet speed value (default: 3.5 => 0.035s).\n"
printf "\t Values >=1 use a /100 scale (3.5 => 0.035s).\n" printf "\t\t\t\tValues >=1 use a /100 scale (3.5 => 0.035s).\n"
printf "\t Values <1 are interpreted as raw seconds.\n" printf "\t\t\t\tValues <1 are interpreted as raw seconds.\n"
printf "\t-c, --color COLOR Set color theme (default: green).\n" printf "\t-d, --density NUM\tMaximum number of simultaneous falling elements.\n"
printf "\t-C, --charset SET Character set: binary, kana, ascii (default: binary).\n" printf "\t-c, --color COLOR\tSet color theme (default: green).\n"
printf "\t-h, --help Display this help message and exit.\n\n" printf "\t-C, --charset SET\tCharacter set: binary, kana, ascii (default: binary).\n"
printf "\t-h, --help\t\tDisplay this help message and exit.\n\n"
printf "Press q (or Ctrl-C) to quit.\n"
printf "Example: matrix -C kana -c green --speed 2\n" printf "Example: matrix -C kana -c green --speed 2\n"
} }
@@ -366,20 +164,27 @@ matrix()
local step_duration local step_duration
step_duration=$(_rain_normalize_speed "$_raw_speed") || step_duration=0.035 step_duration=$(_rain_normalize_speed "$_raw_speed") || step_duration=0.035
local base_color="${MATRIX_DEFAULT_COLOR:-green}" local base_color="${MATRIX_DEFAULT_COLOR:-green}"
# Lower-cased, like the value of -C: the configuration may say "Kana"
local charset="${MATRIX_DEFAULT_CHARSET:-binary}" local charset="${MATRIX_DEFAULT_CHARSET:-binary}"
charset="${charset,,}"
local density_override="${MATRIX_DEFAULT_DENSITY:-}"
if [[ -n "$density_override" ]]; then
density_override=$(_rain_normalize_density "$density_override") || density_override=""
fi
while [[ "$#" -gt 0 ]]; do while [[ "$#" -gt 0 ]]; do
case $1 in case $1 in
-s|--speed) -s|--speed)
if [[ -n "$2" && ! "$2" =~ ^- ]]; then if [[ -n "$2" && ! "$2" =~ ^- ]]; then
step_duration=$(_rain_normalize_speed "$2") || { step_duration=$(_rain_normalize_speed "$2") || {
disp E "--speed requires a numeric value." disp E "--speed requires a numeric value greater than 0."
_matrix_show_usage _matrix_show_usage
return 1 return 1
} }
shift shift
else else
disp E "--speed requires a numeric value." disp E "--speed requires a numeric value greater than 0."
_matrix_show_usage _matrix_show_usage
return 1 return 1
fi fi
@@ -412,6 +217,20 @@ matrix()
return 1 return 1
fi fi
;; ;;
-d|--density)
if [[ -n "$2" && ! "$2" =~ ^- ]]; then
density_override=$(_rain_normalize_density "$2") || {
disp E "--density requires a positive integer value."
_matrix_show_usage
return 1
}
shift
else
disp E "--density requires a positive integer value."
_matrix_show_usage
return 1
fi
;;
-h|--help) -h|--help)
_matrix_show_usage _matrix_show_usage
return 0 return 0
@@ -429,12 +248,130 @@ matrix()
shift shift
done done
_rain_engine "$step_duration" "$base_color" "matrix" "$charset" _rain_engine "$step_duration" "$base_color" "matrix" "$charset" "$density_override"
} }
export -f matrix export -f matrix
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Full-screen rainbow screensaver
# Usage: rainbow [OPTIONS]
rainbow()
{
local PARSED
load_conf "rain" PARSED=$(getopt -o hs: --long help,speed: -n 'rainbow' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"rainbow --help\" to display usage."
return 1
fi
local _raw_speed="${RAINBOW_DEFAULT_SPEED:-4}"
local frame_sleep
frame_sleep=$(_rain_normalize_speed "$_raw_speed") || frame_sleep=0.040
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "Usage: rainbow [OPTIONS]\n"
printf "Options:\n"
printf "\t-s, --speed NUM\tSet horizontal color shift speed (default: 4 => 0.040s).\n"
printf "\t\t\t\tValues >=1 use a /100 scale (4 => 0.04s).\n"
printf "\t\t\t\tValues <1 are interpreted as raw seconds.\n"
printf "\t-h, --help\t\tDisplay this help message and exit.\n\n"
printf "The rainbow fills the whole terminal with background colors only.\n"
printf "It uses truecolor when supported, otherwise ANSI bright backgrounds.\n"
printf "Press q (or Ctrl-C) to quit.\n"
return 0
;;
-s|--speed)
frame_sleep=$(_rain_normalize_speed "$2") || {
disp E "--speed requires a numeric value greater than 0."
return 1
}
shift 2
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"rainbow --help\" to display usage."
return 1
;;
esac
done
_rain_need_tty rainbow || return 1
local exit_st=0
local term_height=0 term_width=0
local use_truecolor=0
local offset=0 row=0 col=0 palette_width=0 idx=0
local row_line="" screen="" ch=""
# shellcheck disable=SC2034 # used by _rain_enter/_rain_leave (dynamic scoping)
local saved_stty="" saved_traps=""
# WINCH handler (and initial sizing): rebuild the palette for the new width
_rainbow_sigwinch()
{
term_width=$(tput cols)
term_height=$(tput lines)
((term_width < 1)) && term_width=1
((term_height < 1)) && term_height=1
if [[ "$TERM" == *direct* || "${COLORTERM:-}" == "truecolor" || "${COLORTERM:-}" == "24bit" ]]; then
use_truecolor=1
else
use_truecolor=0
fi
palette_width=$term_width
offset=0
_rainbow_build_palette "$palette_width" "$use_truecolor"
}
_rain_enter _rainbow_sigwinch
_rainbow_sigwinch
while ((exit_st <= 0)); do
ch=""
# A timeout makes read fail (status > 128): expected, not an error
read -r -n 1 -t "$frame_sleep" ch || true
case "$ch" in
q|Q)
_rain_stop
;;
esac
# One row of colours, shifted by offset (col - offset is at least
# 1 - palette_width, so adding palette_width makes it positive)
row_line=""
for ((col = 0; col < term_width; col++)); do
idx=$(( (col - offset + palette_width) % palette_width ))
row_line+="${RAINBOW_BG_PALETTE[idx]} "
done
row_line+=$'\e[0m'
# Every row is the same: the whole screen goes out in one write
screen=""
for ((row = 1; row <= term_height; row++)); do
screen+="\e[${row};1H${row_line}"
done
printf '%b' "$screen"
offset=$(( (offset + 1) % palette_width ))
done
_rain_leave
unset -f _rainbow_sigwinch
unset RAINBOW_BG_PALETTE
return 0
}
export -f rainbow
# ------------------------------------------------------------------------------
# EOF # EOF
+111 -31
View File
@@ -34,6 +34,13 @@
# * OF SUCH DAMAGE. # * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
load_conf "ssh"
# Common helpers, then the ones private to this module (helpers/ssh.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
_profile_load_helpers "${BASH_SOURCE[0]}"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Remove host entries (name and IP) from ~/.ssh/known_hosts for the active user # Remove host entries (name and IP) from ~/.ssh/known_hosts for the active user
# Usage: rmhost <hostname|ip> [hostname2|ip2 [...]] # Usage: rmhost <hostname|ip> [hostname2|ip2 [...]]
@@ -45,17 +52,27 @@ rmhost()
PARSED=$(getopt -o ha --long help,all-users -n 'rmhost' -- "$@") PARSED=$(getopt -o ha --long help,all-users -n 'rmhost' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after # shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then return 1; fi if [[ $? -ne 0 ]]; then
eval set -- "$PARSED" disp E "Invalid options, use \"rmhost --help\" to display usage."
return 1
fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
-h|--help) -h|--help)
printf "rmhost: Remove host/IP from known_hosts files.\n\n" printf "rmhost: Remove host/IP from known_hosts files.\n\n"
printf "Usage: rmhost [--all-users] <hostname|ip> [hostname2|ip2 ...]\n\n" printf "Usage: rmhost [--all-users] <pattern|ip> [pattern2|ip2 ...]\n\n"
printf "Options:\n" printf "Options:\n"
printf " -a, --all-users Remove entries from all local users when run as root\n" printf " -a, --all-users Remove entries from all local users when run as root\n"
printf " -h, --help Display this help screen\n" printf " -h, --help Display this help screen\n\n"
printf "A host name is removed together with all the IP addresses it resolves to.\n"
printf "Wildcards:\n"
printf " Glob patterns (*, ?, [...]) are expanded against unhashed known_hosts entries.\n"
printf " Hashed entries (prefixed with |1|) are never matched by wildcards.\n"
return 0 return 0
;; ;;
-a|--all-users) -a|--all-users)
@@ -83,14 +100,27 @@ rmhost()
return 127 return 127
} }
# A name starting with "-" would be read as an option by the tools below
local target
for target in "$@"; do
if [[ "$target" == -* ]]; then
disp E "Invalid host '$target'."
return 1
fi
done
if (( all_users )); then if (( all_users )); then
[[ ${EUID:-$(id -u)} -eq 0 ]] || { [[ ${EUID:-$(id -u)} -eq 0 ]] || {
disp E "Option --all-users is only available when run as root." disp E "Option --all-users is only available when run as root."
return 1 return 1
} }
# Users may share a home directory: each file is listed once
local -A _seen_homes=()
local home
while IFS=: read -r _ _ _ _ _ home _; do while IFS=: read -r _ _ _ _ _ home _; do
[[ -n $home && -f $home/.ssh/known_hosts ]] || continue [[ -n $home && -f $home/.ssh/known_hosts && -z ${_seen_homes[$home]+x} ]] || continue
_seen_homes[$home]=1
known_hosts_files+=("$home/.ssh/known_hosts") known_hosts_files+=("$home/.ssh/known_hosts")
done < /etc/passwd done < /etc/passwd
@@ -102,34 +132,81 @@ rmhost()
return 0 return 0
} }
else else
if [[ ! -f "${HOME}/.ssh/known_hosts" ]]; then
disp W "No known_hosts file found (${HOME}/.ssh/known_hosts)."
return 0
fi
known_hosts_files=("${HOME}/.ssh/known_hosts") known_hosts_files=("${HOME}/.ssh/known_hosts")
fi fi
for target in "$@"; do for target in "$@"; do
local hst="$target" # Wildcard: expand the glob pattern against the unhashed entries of
local ip="" # each file, and remove the matches from that file only. A "[host]:port"
local v4=1 # entry is not a pattern: its brackets are known_hosts notation.
local v6=1 if [[ "$target" == *['*?']* ]] || \
[[ "$target" == *'['* && ! "$target" =~ ^\[[^][]+\]:[0-9]+$ ]]; then
local -a _matched=() _wentries=()
local _wf _wl _wfield _whost _wmatch _key _found=0
for _wf in "${known_hosts_files[@]}"; do
_matched=()
while IFS= read -r _wl; do
# Comments, hashed entries and @cert-authority/@revoked
# marker lines are left alone
[[ -z "$_wl" || "$_wl" == '#'* || "$_wl" == '|'* || "$_wl" == '@'* ]] && continue
_wfield="${_wl%% *}"
IFS=',' read -ra _wentries <<< "$_wfield"
for _whost in "${_wentries[@]}"; do
# Strip [host]:port notation to get the bare name for matching
if [[ "$_whost" == '['*']:'* ]]; then
_wmatch="${_whost#[}"
_wmatch="${_wmatch%%]:*}"
else
_wmatch="$_whost"
fi
# shellcheck disable=SC2053 # unquoted on purpose: glob match
[[ "$_wmatch" == $target ]] && _matched+=("$_whost")
done
done < "$_wf"
isipv4 "$hst" >/dev/null 2>&1; v4=$? [[ ${#_matched[@]} -gt 0 ]] || continue
isipv6 "$hst" >/dev/null 2>&1; v6=$? _found=1
while IFS= read -r _key; do
disp I "Removing '$_key' from $_wf..."
if ! _rmhost_remove "$_key" "$_wf"; then
disp W "No known_hosts entry found for '$_key' in '$_wf'."
fi
done < <(printf '%s\n' "${_matched[@]}" | sort -u)
done
if [[ $v4 -eq 0 || $v6 -eq 0 ]]; then (( _found )) || disp W "No known_hosts entries match pattern '$target'."
ip="$hst" continue
hst=""
fi fi
if [[ -z ${ip:-} && -n ${hst:-} ]]; then local hst="$target"
local ip
local -a ips=()
if isipv4 "$hst" >/dev/null 2>&1 || isipv6 "$hst" >/dev/null 2>&1; then
ips=("$hst")
hst=""
elif [[ "$hst" == '['*']:'* ]]; then
# Explicit [host]:port entry: removed exactly as given
:
else
# All the addresses of the name: ssh records the one it connected
# to, which may be any of them (IPv4 and IPv6, several A records)
if command -v host >/dev/null 2>&1; then if command -v host >/dev/null 2>&1; then
ip=$(host "$hst" 2>/dev/null | mapfile -t ips < <(host "$hst" 2>/dev/null |
awk '/has address|has IPv6 address/ {print $NF; exit}') awk '/has address|has IPv6 address/ {print $NF}' | sort -u)
elif command -v getent >/dev/null 2>&1; then elif command -v getent >/dev/null 2>&1; then
ip=$(getent ahosts "$hst" 2>/dev/null | awk 'NR == 1 {print $1; exit}') # ahosts lists every address once per socket type
mapfile -t ips < <(getent ahosts "$hst" 2>/dev/null |
awk '{print $1}' | sort -u)
else else
disp W "No resolver tool found; removing hostname only for '$hst'." disp W "No resolver tool found; removing hostname only for '$hst'."
fi fi
[[ -z ${ip:-} ]] && \ [[ ${#ips[@]} -eq 0 ]] && \
disp W "Could not resolve IP for '$hst'; removing hostname only." disp W "Could not resolve IP for '$hst'; removing hostname only."
fi fi
@@ -137,23 +214,20 @@ rmhost()
for known_hosts_file in "${known_hosts_files[@]}"; do for known_hosts_file in "${known_hosts_files[@]}"; do
if [[ -n ${hst:-} ]]; then if [[ -n ${hst:-} ]]; then
disp I "Removing host $hst from $known_hosts_file..." disp I "Removing host $hst from $known_hosts_file..."
if ! ssh-keygen -R "$hst" -f "$known_hosts_file" >/dev/null 2>&1; then if ! _rmhost_remove "$hst" "$known_hosts_file"; then
disp W "No known_hosts entry found for '$hst' in '$known_hosts_file'." disp W "No known_hosts entry found for '$hst' in '$known_hosts_file'."
fi fi
fi fi
if [[ -n ${ip:-} ]]; then for ip in "${ips[@]}"; do
disp I "Removing IP $ip from $known_hosts_file..." disp I "Removing IP $ip from $known_hosts_file..."
if ! ssh-keygen -R "$ip" -f "$known_hosts_file" >/dev/null 2>&1; then if ! _rmhost_remove "$ip" "$known_hosts_file"; then
disp W "No known_hosts entry found for '$ip' in '$known_hosts_file'." disp W "No known_hosts entry found for '$ip' in '$known_hosts_file'."
fi fi
fi done
done done
done done
} }
export -f rmhost export -f rmhost
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Login root via SSH on the given machine # Login root via SSH on the given machine
# Usage: ssr <server [ssh options]> # Usage: ssr <server [ssh options]>
@@ -187,9 +261,18 @@ ssr()
local srv=$1 local srv=$1
shift shift
# Build default options array from config, falling back to -Y (X11 forwarding) # The first argument is the server: an option here ("ssr -p 22 host")
# would end up in the root@... destination
if [[ $srv == -* ]]; then
disp E "'$srv' is not a server name: the server comes first, then the ssh options."
return 1
fi
# Build the default options array from the configuration. An unset
# SSH_DEFAULT_OPT means -Y (X11 forwarding); an empty one means no default
# option at all, as the example configuration documents.
local -a ssh_default_opts=() local -a ssh_default_opts=()
if [[ -n "${SSH_DEFAULT_OPT:-}" ]]; then if [[ -n "${SSH_DEFAULT_OPT+x}" ]]; then
read -ra ssh_default_opts <<< "${SSH_DEFAULT_OPT}" read -ra ssh_default_opts <<< "${SSH_DEFAULT_OPT}"
else else
ssh_default_opts=(-Y) ssh_default_opts=(-Y)
@@ -201,7 +284,4 @@ ssr()
export -f ssr export -f ssr
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
load_conf "ssh"
# EOF # EOF
+1
View File
@@ -40,3 +40,4 @@ PROMPT_COLOR_ERR_MARK="$IYellow" # golden X
PROMPT_COLOR_ROOT_FG="$IRed" # red for root PROMPT_COLOR_ROOT_FG="$IRed" # red for root
PROMPT_COLOR_USER_FG="$IBlue" # electric blue for user PROMPT_COLOR_USER_FG="$IBlue" # electric blue for user
PROMPT_COLOR_DIR_FG="$ICyan" # teal path PROMPT_COLOR_DIR_FG="$ICyan" # teal path
PROMPT_COLOR_CTX_FG="$IYellow" # context segment (git/conda)
+1
View File
@@ -40,3 +40,4 @@ PROMPT_COLOR_ERR_MARK="$Yellow" # yellow X (warning intent)
PROMPT_COLOR_ROOT_FG="$Red" # Adwaita red for root PROMPT_COLOR_ROOT_FG="$Red" # Adwaita red for root
PROMPT_COLOR_USER_FG="$BBlue" # darker bold blue — readable on blue bar PROMPT_COLOR_USER_FG="$BBlue" # darker bold blue — readable on blue bar
PROMPT_COLOR_DIR_FG="$IGreen" # Adwaita green for path PROMPT_COLOR_DIR_FG="$IGreen" # Adwaita green for path
PROMPT_COLOR_CTX_FG="$BIWhite" # context segment (git/conda)
+1
View File
@@ -30,3 +30,4 @@ PROMPT_COLOR_ERR_MARK="$BIYellow" # X mark colour on failure
PROMPT_COLOR_ROOT_FG="$BIRed" # Username colour when root PROMPT_COLOR_ROOT_FG="$BIRed" # Username colour when root
PROMPT_COLOR_USER_FG="$ICyan" # Username@host colour for normal users PROMPT_COLOR_USER_FG="$ICyan" # Username@host colour for normal users
PROMPT_COLOR_DIR_FG="$IPurple" # Working directory colour PROMPT_COLOR_DIR_FG="$IPurple" # Working directory colour
PROMPT_COLOR_CTX_FG="$BIYellow" # Context segment (git/conda)
+1
View File
@@ -30,3 +30,4 @@ PROMPT_COLOR_ERR_MARK="$BYellow" # X mark colour on failure
PROMPT_COLOR_ROOT_FG="$Red" # Username colour when root PROMPT_COLOR_ROOT_FG="$Red" # Username colour when root
PROMPT_COLOR_USER_FG="$Green" # Username@host colour for normal users PROMPT_COLOR_USER_FG="$Green" # Username@host colour for normal users
PROMPT_COLOR_DIR_FG="$ICyan" # Working directory colour PROMPT_COLOR_DIR_FG="$ICyan" # Working directory colour
PROMPT_COLOR_CTX_FG="$BIYellow" # Context segment (git/conda)
+1
View File
@@ -33,3 +33,4 @@ PROMPT_COLOR_ERR_MARK="$BYellow" # X mark on failure (BIYellow → BYellow, l
PROMPT_COLOR_ROOT_FG="$Red" # Username when root (BIRed → Red) PROMPT_COLOR_ROOT_FG="$Red" # Username when root (BIRed → Red)
PROMPT_COLOR_USER_FG="$Blue" # Username@host normal user (ICyan → Blue) PROMPT_COLOR_USER_FG="$Blue" # Username@host normal user (ICyan → Blue)
PROMPT_COLOR_DIR_FG="$Purple" # Working directory (IPurple → Purple) PROMPT_COLOR_DIR_FG="$Purple" # Working directory (IPurple → Purple)
PROMPT_COLOR_CTX_FG="$BBlack" # Context segment (git/conda)
+1
View File
@@ -62,3 +62,4 @@ PROMPT_COLOR_ERR_MARK="$BBlack" # bold black X
PROMPT_COLOR_ROOT_FG="$BIWhite" # bold bright white for root warning PROMPT_COLOR_ROOT_FG="$BIWhite" # bold bright white for root warning
PROMPT_COLOR_USER_FG="$IWhite" # bright white for normal user PROMPT_COLOR_USER_FG="$IWhite" # bright white for normal user
PROMPT_COLOR_DIR_FG="$White" # standard white for path PROMPT_COLOR_DIR_FG="$White" # standard white for path
PROMPT_COLOR_CTX_FG="$BIWhite" # context segment (git/conda)
+1
View File
@@ -43,3 +43,4 @@ PROMPT_COLOR_ERR_MARK="$IRed" # hot pink X
PROMPT_COLOR_ROOT_FG="$IRed" # hot pink for root PROMPT_COLOR_ROOT_FG="$IRed" # hot pink for root
PROMPT_COLOR_USER_FG="$IYellow" # orange-yellow for user PROMPT_COLOR_USER_FG="$IYellow" # orange-yellow for user
PROMPT_COLOR_DIR_FG="$ICyan" # electric cyan for path PROMPT_COLOR_DIR_FG="$ICyan" # electric cyan for path
PROMPT_COLOR_CTX_FG="$IYellow" # context segment (git/conda)
+1
View File
@@ -40,3 +40,4 @@ PROMPT_COLOR_ERR_MARK="$IYellow" # yellow X
PROMPT_COLOR_ROOT_FG="$IRed" # red for root PROMPT_COLOR_ROOT_FG="$IRed" # red for root
PROMPT_COLOR_USER_FG="$BIPurple" # bold vivid purple for user PROMPT_COLOR_USER_FG="$BIPurple" # bold vivid purple for user
PROMPT_COLOR_DIR_FG="$ICyan" # electric cyan path PROMPT_COLOR_DIR_FG="$ICyan" # electric cyan path
PROMPT_COLOR_CTX_FG="$IYellow" # context segment (git/conda)
+1
View File
@@ -125,3 +125,4 @@ PROMPT_COLOR_ERR_MARK="\e[38;2;253;246;227m" # Base3 — X mark (bright on red
PROMPT_COLOR_ROOT_FG="\e[38;2;220;50;47m" # Red — root warning PROMPT_COLOR_ROOT_FG="\e[38;2;220;50;47m" # Red — root warning
PROMPT_COLOR_USER_FG="\e[38;2;42;161;152m" # Cyan — normal user PROMPT_COLOR_USER_FG="\e[38;2;42;161;152m" # Cyan — normal user
PROMPT_COLOR_DIR_FG="\e[38;2;38;139;210m" # Blue — working directory PROMPT_COLOR_DIR_FG="\e[38;2;38;139;210m" # Blue — working directory
PROMPT_COLOR_CTX_FG="\e[38;2;181;137;0m" # Yellow — context segment (git/conda)
+1
View File
@@ -120,3 +120,4 @@ PROMPT_COLOR_ERR_MARK="\e[1;38;2;253;246;227m" # Base3 bold — bright warm mark
PROMPT_COLOR_ROOT_FG="\e[38;2;220;50;47m" # Red — root warning PROMPT_COLOR_ROOT_FG="\e[38;2;220;50;47m" # Red — root warning
PROMPT_COLOR_USER_FG="\e[38;2;42;161;152m" # Cyan — normal user PROMPT_COLOR_USER_FG="\e[38;2;42;161;152m" # Cyan — normal user
PROMPT_COLOR_DIR_FG="\e[38;2;38;139;210m" # Blue — working directory PROMPT_COLOR_DIR_FG="\e[38;2;38;139;210m" # Blue — working directory
PROMPT_COLOR_CTX_FG="\e[38;2;181;137;0m" # Yellow — context segment (git/conda)
+133 -60
View File
@@ -40,6 +40,18 @@ export BASE_URL="https://git.geoffray-levasseur.org/fatalerrors/profile"
# UPDT_DEFAULT_BRANCH (if set in profile.conf) is already available. # UPDT_DEFAULT_BRANCH (if set in profile.conf) is already available.
load_conf "updates" load_conf "updates"
# Common helpers, then the ones private to this module (helpers/updates.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
_profile_load_helpers "${BASH_SOURCE[0]}"
# ------------------------------------------------------------------------------
if ! _updt_valid_branch "${UPDT_DEFAULT_BRANCH:-master}"; then
disp W "Invalid UPDT_DEFAULT_BRANCH '${UPDT_DEFAULT_BRANCH}', using master."
UPDT_DEFAULT_BRANCH=master
fi
export UPDT_DEFAULT_BRANCH="${UPDT_DEFAULT_BRANCH:-master}" export UPDT_DEFAULT_BRANCH="${UPDT_DEFAULT_BRANCH:-master}"
export UPDT_URL="$BASE_URL/raw/branch/$UPDT_DEFAULT_BRANCH" export UPDT_URL="$BASE_URL/raw/branch/$UPDT_DEFAULT_BRANCH"
export ARCH_URL="$BASE_URL/archive/$UPDT_DEFAULT_BRANCH.tar.gz" export ARCH_URL="$BASE_URL/archive/$UPDT_DEFAULT_BRANCH.tar.gz"
@@ -47,7 +59,10 @@ export ARCH_URL="$BASE_URL/archive/$UPDT_DEFAULT_BRANCH.tar.gz"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Check whether a newer profile version is available # Check whether a newer profile version is available
# Usage: check_updates [-q] # Usage: check_updates [-q]
# If -q is specified, the function will operate in quiet mode (internal use only) # If -q is specified, the function will operate in quiet mode (internal use
# only): only the "update available" notice is displayed, errors are silent.
# Return status: 0 up to date, 1 update available, 2 invalid option,
# 4 temporary file error, 5 version unavailable or invalid.
check_updates() check_updates()
{ {
local quiet=0 result=5 PARSED local quiet=0 result=5 PARSED
@@ -59,7 +74,10 @@ check_updates()
disp E "Invalid options, use \"check_updates --help\" to display usage." disp E "Invalid options, use \"check_updates --help\" to display usage."
return 2 return 2
fi fi
eval set -- "$PARSED" # Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
@@ -85,42 +103,57 @@ check_updates()
(( quiet != 1 )) && disp I "Checking for updates..." (( quiet != 1 )) && disp I "Checking for updates..."
vfile=$(mktemp /tmp/profile_version.XXXXXX) || { vfile=$(mktemp "${TMPDIR:-/tmp}/profile_version.XXXXXX") || {
disp E "Failed to create a temporary file." (( quiet != 1 )) && disp E "Failed to create a temporary file."
return 4 return 4
} }
dwl "$UPDT_URL/version" "$vfile" >/dev/null 2>&1 || { # In quiet mode (startup), use a short timeout so a missing or slow network
# never blocks the interactive prompt.
local dwl_opts=()
(( quiet == 1 )) && dwl_opts+=(-t 3)
dwl "${dwl_opts[@]}" "$UPDT_URL/version" "$vfile" >/dev/null 2>&1 || {
rm -f "$vfile" rm -f "$vfile"
disp E "Cannot download version file; unable to continue." (( quiet != 1 )) && disp E "Cannot download version file; unable to continue."
return 5 return 5
} }
if [[ -s $vfile ]]; then # First line only, and only a plausible version: the text comes from the
lastver=$(<"$vfile") # network and is displayed by disp, which interprets backslash escapes (a
if [[ "$lastver" != "$PROFVERSION" ]]; then # crafted file, or a captive portal page, could otherwise inject terminal
disp I "Installed: $PROFVERSION. Available: $lastver." # control sequences at every shell startup).
(( quiet != 1 )) && disp I "You should upgrade when possible." IFS= read -r lastver < "$vfile" || true
result=1 rm -f "$vfile"
else lastver="${lastver%$'\r'}"
(( quiet != 1 )) && disp I "Your version is up-to-date." lastver="${lastver#"${lastver%%[![:space:]]*}"}"
result=0 lastver="${lastver%"${lastver##*[![:space:]]}"}"
fi if [[ ! "$lastver" =~ ^[0-9]+(\.[0-9]+)*([-_.+][0-9A-Za-z._+-]+)?$ ]]; then
rm -f "$vfile" (( quiet != 1 )) && disp E "The downloaded version file is empty or invalid; unable to continue."
return 5
fi
if [[ "$lastver" == "${PROFVERSION:-}" ]]; then
(( quiet != 1 )) && disp I "Your version is up-to-date."
result=0
elif _updt_is_newer "${PROFVERSION:-}" "$lastver"; then
disp I "Installed: ${PROFVERSION:-unknown}. Available: $lastver."
(( quiet != 1 )) && disp I "You should upgrade when possible."
result=1
else else
rm -f "$vfile" (( quiet != 1 )) && disp I "Installed version (${PROFVERSION:-}) is more recent than the published one ($lastver)."
disp E "Temporary file is unreadable; unable to continue." result=0
fi fi
return $result return $result
} }
export -f check_updates export -f check_updates
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Apply the available profile upgrade # Apply the available profile upgrade
# Usage: profile_upgrade [options] # Usage: profile_upgrade [options]
# Return status: 0 done (or nothing to do), 1 refused/bad context, 2 git or
# option error, 3 git/install error, 4 local archive or force error, 5
# temporary directory, 6 download, 7 extraction, 8 archive content, 9 copy.
profile_upgrade() profile_upgrade()
{ {
local PARSED local PARSED
@@ -134,7 +167,10 @@ profile_upgrade()
disp E "Invalid options, use \"profile_upgrade --help\" to display usage." disp E "Invalid options, use \"profile_upgrade --help\" to display usage."
return 2 return 2
fi fi
eval set -- "$PARSED" # Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do while true; do
case "$1" in case "$1" in
-h|--help) -h|--help)
@@ -148,7 +184,7 @@ profile_upgrade()
printf "\t-g, --switch-to-git\tReplace current install with a fresh Git clone\n" printf "\t-g, --switch-to-git\tReplace current install with a fresh Git clone\n"
printf "\t-n, --dry-run\t\tDisplay what would be done without changing anything\n" printf "\t-n, --dry-run\t\tDisplay what would be done without changing anything\n"
printf "\t-F, --force\t\tDiscard local changes before upgrading\n\n" printf "\t-F, --force\t\tDiscard local changes before upgrading\n\n"
printf "If the profile is installed from Git, the upgrade uses 'git pull'.\n" printf "If the profile is installed from Git, the upgrade uses 'git pull --ff-only'.\n"
printf "Otherwise, it downloads or applies an archive and refreshes the files.\n" printf "Otherwise, it downloads or applies an archive and refreshes the files.\n"
return 0 return 0
;; ;;
@@ -188,6 +224,13 @@ profile_upgrade()
esac esac
done done
# Target branch: explicit -b flag > config default > master
local target_branch="${branch:-${UPDT_DEFAULT_BRANCH:-master}}"
if ! _updt_valid_branch "$target_branch"; then
disp E "Invalid branch name '$target_branch'."
return 1
fi
if (( ! use_archive && ! switch_to_git )); then if (( ! use_archive && ! switch_to_git )); then
check_updates -q check_updates -q
check_rc=$? check_rc=$?
@@ -215,17 +258,18 @@ profile_upgrade()
disp E "Git is required to switch this install to a Git clone." disp E "Git is required to switch this install to a Git clone."
return 3 return 3
} }
local backup_dir="${MYPATH}.pre-git.$$.bak"
if (( dry_run )); then if (( dry_run )); then
disp I "[dry-run] rm -rf \"$MYPATH\"/.git" disp I "[dry-run] mv \"$MYPATH\" \"$backup_dir\""
disp I "[dry-run] git clone $BASE_URL \"$MYPATH\"" disp I "[dry-run] git clone $BASE_URL \"$MYPATH\""
[[ -n "$branch" ]] && disp I "[dry-run] git -C \"$MYPATH\" checkout $branch" [[ -n "$branch" ]] && disp I "[dry-run] git -C \"$MYPATH\" checkout $branch"
disp I "[dry-run] copy profile.conf and custom themes from \"$backup_dir\""
return 0 return 0
fi fi
if [[ -d $MYPATH/.git ]]; then if [[ -d $MYPATH/.git ]]; then
disp W "Git repository already present; no switch is needed." disp W "Git repository already present; no switch is needed."
else else
local backup_dir="${MYPATH}.pre-git.$$.bak"
mv "$MYPATH" "$backup_dir" || { mv "$MYPATH" "$backup_dir" || {
disp E "Failed to move current install out of the way." disp E "Failed to move current install out of the way."
return 3 return 3
@@ -235,11 +279,21 @@ profile_upgrade()
mv "$backup_dir" "$MYPATH" 2>/dev/null || true mv "$backup_dir" "$MYPATH" 2>/dev/null || true
return 3 return 3
} }
[[ -n "$branch" ]] && ( if [[ -n "$branch" ]] && ! ( cd "$MYPATH" && git checkout "$branch" ); then
cd "$MYPATH" && git checkout "$branch" disp W "Could not switch to branch '$branch'; staying on the default branch."
) || true fi
# The personal files are not in the repository (profile.conf is
# git-ignored): without this the new install would start without
# the user's configuration, which would stay in the backup.
# Existing files are never overwritten.
[[ -f "$backup_dir/profile.conf" ]] && \
cp -an "$backup_dir/profile.conf" "$MYPATH/profile.conf"
if [[ -d "$backup_dir/profile.d/themes" && -d "$MYPATH/profile.d/themes" ]]; then
cp -an "$backup_dir/profile.d/themes/." "$MYPATH/profile.d/themes/"
fi
disp I "Switched installation to Git source." disp I "Switched installation to Git source."
disp I "Previous install kept in $backup_dir." disp I "Previous install kept in $backup_dir (profile.conf and custom themes were copied)."
return 0 return 0
fi fi
fi fi
@@ -250,23 +304,18 @@ profile_upgrade()
disp E "Git is required for this upgrade but is not available." disp E "Git is required for this upgrade but is not available."
return 3 return 3
} }
pushd "$MYPATH" >/dev/null || { # git -C instead of cd: no directory change to undo on each exit path
disp E "Failed to change directory to $MYPATH." local -a G=(git -C "$MYPATH")
return 3 "${G[@]}" rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
}
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
disp E "Install directory is not a valid Git working tree." disp E "Install directory is not a valid Git working tree."
popd >/dev/null || return 1
return 3 return 3
} }
# Resolve the target branch: explicit -b flag > config default > master
local target_branch="${branch:-${UPDT_DEFAULT_BRANCH:-master}}"
branch="$target_branch" branch="$target_branch"
# Detect current local branch and warn+switch when it differs # Detect current local branch and warn+switch when it differs
local current_branch local current_branch
current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) current_branch=$("${G[@]}" rev-parse --abbrev-ref HEAD 2>/dev/null)
if [[ -n "$current_branch" && "$current_branch" != "$branch" ]]; then if [[ -n "$current_branch" && "$current_branch" != "$branch" ]]; then
disp W "Branch mismatch: currently on '$current_branch', configured branch is '$branch'." disp W "Branch mismatch: currently on '$current_branch', configured branch is '$branch'."
if (( dry_run )); then if (( dry_run )); then
@@ -274,21 +323,20 @@ profile_upgrade()
disp I "[dry-run] git checkout $branch" disp I "[dry-run] git checkout $branch"
disp W "[dry-run] Branch would be changed from '$current_branch' to '$branch'." disp W "[dry-run] Branch would be changed from '$current_branch' to '$branch'."
else else
git fetch origin "$branch" || { "${G[@]}" fetch origin "$branch" || {
disp E "Git fetch failed for branch $branch." disp E "Git fetch failed for branch $branch."
popd >/dev/null || return 1
return 2 return 2
} }
git checkout "$branch" || { "${G[@]}" checkout "$branch" || {
disp E "Git checkout failed for branch $branch." disp E "Git checkout failed for branch $branch."
popd >/dev/null || return 1
return 2 return 2
} }
disp W "Branch has been changed from '$current_branch' to '$branch'." disp W "Branch has been changed from '$current_branch' to '$branch'."
fi fi
fi fi
if ! git diff --quiet || ! git diff --cached --quiet || [[ -n $(git ls-files --others --exclude-standard) ]]; then # status --porcelain: staged, unstaged and untracked (not ignored) files
if [[ -n $("${G[@]}" status --porcelain 2>/dev/null) ]]; then
if (( force_git )); then if (( force_git )); then
disp W "Force mode: local Git changes and untracked files will be lost." disp W "Force mode: local Git changes and untracked files will be lost."
if (( dry_run )); then if (( dry_run )); then
@@ -296,19 +344,16 @@ profile_upgrade()
disp I "[dry-run] git reset --hard HEAD" disp I "[dry-run] git reset --hard HEAD"
disp I "[dry-run] git clean -fd" disp I "[dry-run] git clean -fd"
else else
git fetch --all --prune || { "${G[@]}" fetch --all --prune || {
disp E "Git fetch failed, upgrade not applied." disp E "Git fetch failed, upgrade not applied."
popd >/dev/null || return 1
return 4 return 4
} }
git reset --hard HEAD || { "${G[@]}" reset --hard HEAD || {
disp E "Git reset failed, upgrade not applied." disp E "Git reset failed, upgrade not applied."
popd >/dev/null || return 1
return 4 return 4
} }
git clean -fd || { "${G[@]}" clean -fd || {
disp E "Git clean failed, upgrade not applied." disp E "Git clean failed, upgrade not applied."
popd >/dev/null || return 1
return 4 return 4
} }
fi fi
@@ -319,16 +364,16 @@ profile_upgrade()
fi fi
fi fi
if (( dry_run )); then if (( dry_run )); then
disp I "[dry-run] git pull origin $branch" disp I "[dry-run] git pull --ff-only origin $branch"
else else
git pull origin "$branch" || { # --ff-only: never create a merge commit nor leave conflict markers
disp E "Git pull failed, upgrade not applied." # in the installed scripts; local commits that diverge are reported
popd >/dev/null || return 1 "${G[@]}" pull --ff-only origin "$branch" || {
disp E "Git pull failed, upgrade not applied (see the git message above: local changes in the way, or local commits that diverge from origin/$branch and must be rebased or dropped by hand)."
return 2 return 2
} }
disp I "Successfully upgraded using git." disp I "Successfully upgraded using git. Please log out and log in again."
fi fi
popd >/dev/null || return 1
else else
if (( use_archive )); then if (( use_archive )); then
[[ -r "$archive_file" ]] || { [[ -r "$archive_file" ]] || {
@@ -375,7 +420,7 @@ profile_upgrade()
if (( dry_run )); then if (( dry_run )); then
disp I "[dry-run] dwl \"$ARCH_URL\" \"$archive\"" disp I "[dry-run] dwl \"$ARCH_URL\" \"$archive\""
else else
dwl "$ARCH_URL" "$archive" || { dwl -t 300 "$ARCH_URL" "$archive" || {
disp E "Failed to download archive." disp E "Failed to download archive."
rm -rf "$tmpdir" rm -rf "$tmpdir"
return 6 return 6
@@ -384,10 +429,25 @@ profile_upgrade()
fi fi
if (( dry_run )); then if (( dry_run )); then
disp I "[dry-run] tar -xzf \"$archive\" -C \"$tmpdir\"" disp I "[dry-run] tar -xf \"$archive\" -C \"$tmpdir\""
disp I "[dry-run] cp -a <extracted_profile>/. \"$MYPATH\"/" disp I "[dry-run] cp -a <extracted_profile>/. \"$MYPATH\"/"
else else
tar -xzf "$archive" -C "$tmpdir" || { # An archive is refused if one of its members is an absolute path or
# climbs out of the extraction directory with "..".
if ! tar -tf "$archive" >"$tmpdir/list" 2>/dev/null; then
disp E "Archive extraction failed (unreadable archive)."
rm -rf "$tmpdir"
return 7
fi
if grep -qE '(^/|(^|/)\.\.(/|$))' "$tmpdir/list"; then
disp E "Unsafe archive (absolute or '..' path); upgrade refused."
rm -rf "$tmpdir"
return 7
fi
rm -f "$tmpdir/list"
# --no-same-owner: the files belong to the user running the upgrade
tar --no-same-owner -xf "$archive" -C "$tmpdir" || {
disp E "Archive extraction failed." disp E "Archive extraction failed."
rm -rf "$tmpdir" rm -rf "$tmpdir"
return 7 return 7
@@ -400,6 +460,16 @@ profile_upgrade()
return 8 return 8
fi fi
# cp only adds and replaces: a module removed from the new version
# stays in profile.d and keeps being loaded. They are listed after
# the copy (never deleted: they may be the user's own modules).
local f
local -a stale=()
for f in "$MYPATH"/profile.d/*.sh "$MYPATH"/profile.d/bash-completion/*.sh; do
[[ -e "$f" ]] || continue
[[ -e "$extracted_root/${f#"$MYPATH"/}" ]] || stale+=("${f#"$MYPATH"/}")
done
disp I "Installing new version..." disp I "Installing new version..."
cp -a "$extracted_root"/. "$MYPATH"/ || { cp -a "$extracted_root"/. "$MYPATH"/ || {
disp E "Failed to copy new files into $MYPATH." disp E "Failed to copy new files into $MYPATH."
@@ -407,13 +477,16 @@ profile_upgrade()
return 9 return 9
} }
if (( ${#stale[@]} )); then
disp W "These files are not part of the new version and are still loaded; remove them if they are not yours: ${stale[*]}"
fi
disp I "Upgrade complete. Please log out and log in again." disp I "Upgrade complete. Please log out and log in again."
rm -rf "$tmpdir" rm -rf "$tmpdir"
fi fi
fi fi
return 0
} }
export -f profile_upgrade export -f profile_upgrade
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# EOF # EOF
+319 -42
View File
@@ -35,17 +35,180 @@
# * OF SUCH DAMAGE. # * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
if [[ ! $SHELL =~ bash ]]; then # This first test is POSIX on purpose: it runs before anything bash-specific, so
echo "That environment script is designed to be used with bash being the shell." # that sourcing the file from dash, zsh, ... ends with a message instead of
echo "Please consider using bash to enjoy our features!" # cascading errors. It checks the running shell: $SHELL is the user's login
# shell, which says nothing about it (and is unset under cron or systemd).
if [ -z "${BASH_VERSION:-}" ]; then
echo "That environment script is designed to be used with bash." >&2
echo "Please consider using bash to enjoy our features!" >&2
# shellcheck disable=SC2317 # exit is the fallback when not sourced
return 1 2>/dev/null || exit 1
fi
_profile_is_sourced()
{
[[ "${BASH_SOURCE[0]}" != "$0" ]]
}
# Installation helper: write $2 (the source line) in the file $1, unless that
# file already sources $3 (the profile) in some form.
_profile_install_in_file()
{
local rc_file="$1"
local source_line="$2"
local target="$3"
[[ -f "$rc_file" ]] || touch "$rc_file" || {
printf "[ Error ] Cannot create %s\n" "$rc_file" >&2
return 1
}
# The exact line, or any other sourcing statement naming the same file
# (". ~/p/profile.sh", with other quotes, ...): adding another one would
# load the profile twice.
if grep -Fqx "$source_line" "$rc_file" || _profile_file_sources "$rc_file" "$target"; then
printf "[ Info ] Already configured in %s\n" "$rc_file"
return 0
fi
printf "\n%s\n" "$source_line" >> "$rc_file" || {
printf "[ Error ] Cannot write to %s\n" "$rc_file" >&2
return 1
}
printf "[ Info ] Added profile source line to %s\n" "$rc_file"
return 0
}
# Detect whether $rc_file sources the file whose basename is $target_name.
# Returns 0 (true) if a sourcing statement referencing $target_name is found,
# ignoring commented-out lines.
_profile_file_sources()
{
local rc_file="$1"
local target_name="$2"
local line
[[ -f "$rc_file" ]] || return 1
while IFS= read -r line || [[ -n "$line" ]]; do
# Strip comments so commented-out source lines are not matched.
line="${line%%#*}"
# Keep only lines whose first word is a source command (`source` or `.`).
[[ "$line" =~ ^[[:space:]]*(source|\.)[[:space:]] ]] || continue
# Match if the sourcing statement references the target file.
[[ "$line" == *"$target_name"* ]] && return 0
done < "$rc_file"
return 1 return 1
}
_profile_install()
{
local install_bashrc=0
local install_profile=0
local target_selected=0
local script_dir source_line rc=0
while [[ $# -gt 0 ]]; do
case "$1" in
--bashrc)
install_bashrc=1
target_selected=1
;;
--profile)
install_profile=1
target_selected=1
;;
-h|--help)
printf "Usage: %s --install [--bashrc] [--profile]\n" "${BASH_SOURCE[0]}"
printf "If no target is specified, both ~/.bashrc and ~/.profile are configured.\n"
return 0
;;
*)
printf "[ Error ] Unknown install option: %s\n" "$1" >&2
return 2
;;
esac
shift
done
if (( target_selected == 0 )); then
install_bashrc=1
install_profile=1
fi
# Avoid double loading when both targets are selected and one already
# sources the other: only configure the called file so profile.sh is
# loaded exactly once.
if (( install_bashrc == 1 && install_profile == 1 )); then
if _profile_file_sources "$HOME/.bashrc" ".profile"; then
printf "[ Info ] ~/.bashrc sources ~/.profile; configuring only ~/.profile to avoid double loading.\n"
install_bashrc=0
elif _profile_file_sources "$HOME/.profile" ".bashrc"; then
printf "[ Info ] ~/.profile sources ~/.bashrc; configuring only ~/.bashrc to avoid double loading.\n"
install_profile=0
fi
fi
script_dir=$(dirname "$(realpath -s "${BASH_SOURCE[0]}")")
# The path goes between double quotes in the rc file: escape what stays
# special there (a plain path is left unchanged, so existing lines keep
# matching)
local escaped_dir="$script_dir"
escaped_dir="${escaped_dir//\\/\\\\}"
escaped_dir="${escaped_dir//\"/\\\"}"
escaped_dir="${escaped_dir//\$/\\\$}"
escaped_dir="${escaped_dir//\`/\\\`}"
source_line="source \"$escaped_dir/profile.sh\""
if (( install_bashrc == 1 )); then
_profile_install_in_file "$HOME/.bashrc" "$source_line" "$script_dir/profile.sh" || rc=$?
fi
if (( install_profile == 1 )); then
_profile_install_in_file "$HOME/.profile" "$source_line" "$script_dir/profile.sh" || rc=$?
fi
return "$rc"
}
# Options are only handled when the file is executed: a sourced file receives
# the positional parameters of whoever sources it ("./script --install" sourcing
# the profile must not install anything, nor fail on an option that is not for
# us).
if ! _profile_is_sourced; then
case "${1:-}" in
--install)
shift
_profile_install "$@"
exit $?
;;
-h|--help)
printf "Usage: source %s\n" "${BASH_SOURCE[0]}"
printf " %s --install [--bashrc] [--profile]\n" "${BASH_SOURCE[0]}"
exit 0
;;
"")
;;
*)
printf "[ Error ] Unknown option: %s\n" "$1" >&2
exit 2
;;
esac
printf "[ Warning ] profile.sh is designed to be sourced, not executed directly.\n" >&2
printf "Use: source \"%s\"\n" "$(realpath -s "${BASH_SOURCE[0]}")" >&2
printf "Or run: %s --install [--bashrc] [--profile]\n" "${BASH_SOURCE[0]}" >&2
exit 1
fi fi
# Required for associative arrays (4.0+) and namerefs (4.3+) # Required for associative arrays (4.0+) and namerefs (4.3+)
if ((BASH_VERSINFO[0] < 4)) || [[ ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 3 ]]; then if ((BASH_VERSINFO[0] < 4)) || [[ ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 3 ]]; then
echo "[ Error ] This profile requires Bash 4.3 or higher." echo "[ Error ] This profile requires Bash 4.3 or higher." >&2
echo "Current version: $BASH_VERSION" echo "Current version: $BASH_VERSION" >&2
(return 0 2>/dev/null) && return 1 || exit 1 return 1
fi fi
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@@ -78,6 +241,10 @@ pathremove()
# export "$pathvar=$1${!pathvar:+:${!pathvar}}" # export "$pathvar=$1${!pathvar:+:${!pathvar}}"
#} #}
# Append one directory to a path variable (PATH by default), unless it is
# already in it: an entry that exists keeps its place. Moving it to the end
# would, for instance, demote ~/.local/bin that Debian's ~/.profile puts first,
# so that system programs shadow the user's own ones.
pathappend() pathappend()
{ {
[[ -z "$1" ]] && return 0 [[ -z "$1" ]] && return 0
@@ -86,7 +253,9 @@ pathappend()
printf "pathappend: unsafe variable name '%s'\n" "$pathvar" >&2 printf "pathappend: unsafe variable name '%s'\n" "$pathvar" >&2
return 1 return 1
} }
pathremove "$1" "$pathvar" case ":${!pathvar}:" in
*":$1:"*) return 0 ;;
esac
export "$pathvar=${!pathvar:+${!pathvar}:}$1" export "$pathvar=${!pathvar:+${!pathvar}:}$1"
} }
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@@ -111,13 +280,21 @@ parse_conf()
# Skip comments and empty lines # Skip comments and empty lines
[[ -z "$key" || "$key" =~ ^[#\;] ]] && continue [[ -z "$key" || "$key" =~ ^[#\;] ]] && continue
# Section Detection: [section_name] # Section Detection: [section_name], optionally followed by a comment
if [[ "$key" =~ ^\[([a-zA-Z0-9_]+)\]$ ]]; then if [[ "$key" =~ ^\[([a-zA-Z0-9_]+)\][[:space:]]*([#\;].*)?$ ]]; then
current_section="${BASH_REMATCH[1]}" current_section="${BASH_REMATCH[1]}"
declare -g -A "CONF_$current_section" declare -g -A "CONF_$current_section"
continue continue
fi fi
# A key is a variable name: it ends up in "export" (load_conf) and as an
# array subscript. Anything else (a stray line, a typo) is reported.
if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
printf "[ Warning ] %s: invalid configuration line ignored: '%s'\n" \
"$config_file" "$key" >&2
continue
fi
# Secure Assignment (if inside a section) # Secure Assignment (if inside a section)
if [[ -n "$current_section" ]]; then if [[ -n "$current_section" ]]; then
# Clean the value # Clean the value
@@ -125,19 +302,44 @@ parse_conf()
value="${value#"${value%%[![:space:]]*}"}" value="${value#"${value%%[![:space:]]*}"}"
value="${value%$'\r'}" value="${value%$'\r'}"
# A quoted value is what is between the quotes (a trailing comment
# is dropped); an unquoted one ends at an inline comment, which is
# a "#" preceded by a blank ("dark # my theme").
if [[ "$value" == \"* ]]; then
value="${value#\"}"
value="${value%%\"*}"
elif [[ "$value" == \'* ]]; then
value="${value#\'}"
value="${value%%\'*}"
else
value="${value%%[[:space:]]#*}"
value="${value%"${value##*[![:space:]]}"}"
fi
# Protect against command injection by disallowing certain characters in keys # Protect against command injection by disallowing certain characters in keys
value="${value//\`/}" value="${value//\`/}"
value="${value//\$\(/}" value="${value//\$\(/}"
# Correctly interpretet internal variables (e.g. $HOME) # Correctly interpretet internal variables (e.g. $HOME). In the
# [prompt] section only the variables that exist are substituted:
# envsubst would otherwise replace an unknown one by nothing,
# wiping out the $Blue-style colour references that are resolved
# later, when the colour variables exist (see prompt.sh).
if [[ "$value" == *\$* ]]; then if [[ "$value" == *\$* ]]; then
value=$(envsubst <<< "$value") if command -v envsubst >/dev/null 2>&1; then
if [[ "$current_section" == "prompt" ]]; then
local _envnames="" _envname
for _envname in $(compgen -e); do
[[ "$_envname" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] && _envnames+="\${${_envname}}"
done
value=$(envsubst "$_envnames" <<< "$value")
else
value=$(envsubst <<< "$value")
fi
fi
# If envsubst is unavailable, $VAR references are left as-is.
fi fi
# Strip quotes (handling both " and ')
value="${value%\"}"; value="${value#\"}"
value="${value%\'}"; value="${value#\'}"
# Use a nameref for safe, eval-free assignment # Use a nameref for safe, eval-free assignment
local -n current_array="CONF_$current_section" local -n current_array="CONF_$current_section"
# shellcheck disable=SC2034 # Dynamic var creation # shellcheck disable=SC2034 # Dynamic var creation
@@ -159,6 +361,7 @@ load_alias()
# Create a nameref to the section array # Create a nameref to the section array
local -n current_aliases="$section_name" local -n current_aliases="$section_name"
local key
# Iterate safely over the keys of the associative array # Iterate safely over the keys of the associative array
for key in "${!current_aliases[@]}"; do for key in "${!current_aliases[@]}"; do
@@ -187,6 +390,7 @@ load_conf()
[[ "$(declare -p "$section_name" 2>/dev/null)" != "declare -A"* ]] && return 0 [[ "$(declare -p "$section_name" 2>/dev/null)" != "declare -A"* ]] && return 0
local -n current_vars="$section_name" local -n current_vars="$section_name"
local key
for key in "${!current_vars[@]}"; do for key in "${!current_vars[@]}"; do
# Export the key/value pair as a standard shell variable # Export the key/value pair as a standard shell variable
@@ -203,32 +407,41 @@ load_conf()
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Store script's path (realpath -s resolve symlinks if profile.sh is a symlink) # Store the script's path. Because we're more likely to be sourced, we use
# Because we're more likely to be sourced, we use BASH_SOURCE to get the path # BASH_SOURCE to get the path of the sourced file instead of $0. If profile.sh
# of the sourced file instead of $0 # itself is a symbolic link, it is followed to find the real installation
if [[ -z "$PROFILE_PATH" ]]; then # (profile.d, version, ...); a symlinked directory is kept as the user sees it
MYPATH=$(dirname "$(realpath -s "${BASH_SOURCE[0]}")") # (realpath -s).
if [[ -z "${PROFILE_PATH:-}" ]]; then
_profile_src="${BASH_SOURCE[0]}"
[[ -L "$_profile_src" ]] && _profile_src=$(readlink -f -- "$_profile_src")
MYPATH=$(dirname "$(realpath -s -- "$_profile_src")")
unset _profile_src
else else
MYPATH="$PROFILE_PATH" MYPATH="$PROFILE_PATH"
fi fi
export MYPATH export MYPATH
if [[ ! -e "$MYPATH/profile.sh" ]]; then if [[ ! -e "$MYPATH/profile.sh" ]]; then
echo "[ Warning ] Path detection failed, trying to use pwd..." echo "[ Warning ] Path detection failed, trying to use pwd..." >&2
MYPATH=$(pwd) MYPATH=$(pwd)
if [[ ! -e "$MYPATH/profile.sh" ]]; then if [[ ! -e "$MYPATH/profile.sh" ]]; then
echo "[ Error ] Unable to determine installation path, pretty much nothing will work." echo "[ Error ] Unable to determine installation path, pretty much nothing will work." >&2
fi fi
fi fi
if [[ ! -s "$MYPATH/version" ]]; then PROFVERSION=""
echo "[ Warning ] Unable to determine running profile version; your installation might be broken." if [[ -s "$MYPATH/version" ]]; then
IFS= read -r PROFVERSION < "$MYPATH/version"
PROFVERSION="${PROFVERSION%$'\r'}"
else
echo "[ Warning ] Unable to determine running profile version; your installation might be broken." >&2
fi fi
PROFVERSION=$(cat "$MYPATH"/version)
export PROFVERSION export PROFVERSION
# Build PATH environment variable # Build PATH environment variable
if [[ $EUID -eq 0 ]]; then if [[ $EUID -eq 0 ]]; then
pathappend /sbin:/usr/sbin pathappend /sbin
pathappend /usr/sbin
fi fi
[[ -d ~/bin ]] && pathappend ~/bin [[ -d ~/bin ]] && pathappend ~/bin
[[ -d ~/.local/bin ]] && pathappend ~/.local/bin [[ -d ~/.local/bin ]] && pathappend ~/.local/bin
@@ -236,32 +449,93 @@ fi
# Parse and load general configuration # Parse and load general configuration
export PROFILE_CONF="$MYPATH/profile.conf" export PROFILE_CONF="$MYPATH/profile.conf"
parse_conf "$PROFILE_CONF" parse_conf "$PROFILE_CONF"
# Overload with user configuration if it exists
if [[ -f "$HOME/.profile.conf" ]]; then
parse_conf "$HOME/.profile.conf"
fi
load_conf system # Load Bash system behavior configuration (history, pager, etc.) load_conf system # Load Bash system behavior configuration (history, pager, etc.)
load_conf general # General purpose configuration (compilation flags, etc.) load_conf general # General purpose configuration (compilation flags, etc.)
# Load module scripts # Helpers shared by several modules (each module also loads the common helpers
# itself when it is sourced alone, see _profile_load_helpers).
if [[ -r "$MYPATH/helpers/common.sh" ]]; then
# shellcheck source=helpers/common.sh
. "$MYPATH/helpers/common.sh"
else
printf "[ Error ] Common helpers not found: %s\n" "$MYPATH/helpers/common.sh" >&2
# shellcheck disable=SC2317 # exit is the fallback when not sourced
return 1 2>/dev/null || exit 1
fi
# Load module scripts. nullglob is only needed for the expansion: the caller's
# setting is put back afterwards instead of being forced off.
_profile_ng=0
shopt -q nullglob && _profile_ng=1
shopt -s nullglob shopt -s nullglob
for script in "$MYPATH/profile.d/"*.sh; do for _profile_script in "$MYPATH/profile.d/"*.sh; do
if [[ -f "$script" && -r "$script" ]]; then if [[ -f "$_profile_script" && -r "$_profile_script" ]]; then
# shellcheck source=/dev/null # shellcheck source=/dev/null
. "$script" || printf "[ Warning ] Failed to source module: %s\n" "$script" >&2 . "$_profile_script" || printf "[ Warning ] Failed to source module: %s\n" "$_profile_script" >&2
fi fi
done done
shopt -u nullglob unset _profile_script
(( _profile_ng )) || shopt -u nullglob
# Interactive shell detection, two methods available each one of those might have different result # Interactive shell detection. INTERACTIVE is exported for the modules, so it
# depending on distribution # is reset when the shell is not interactive: a non-interactive child of an
#shopt -q login_shell && INTERACTIVE=1 # interactive shell would otherwise inherit INTERACTIVE=1 and run the whole
[[ $- == *i* ]] && export INTERACTIVE=1 # interactive part (banner, prompt, update check) inside scripts.
if [[ $- == *i* ]]; then
export INTERACTIVE=1
else
unset INTERACTIVE
fi
if [[ $INTERACTIVE ]]; then if [[ -n "${INTERACTIVE:-}" ]]; then
# For compiling (as we often compile with LFS/0linux...) # Load custom bash completions
#Aliases shopt -s nullglob
load_alias aliases for _compl in "$MYPATH/profile.d/bash-completion/"*.sh; do
# shellcheck disable=SC1090 # Dynamic sourcing of completion scripts
[[ -f "$_compl" && -r "$_compl" ]] && . "$_compl"
done
unset _compl
(( _profile_ng )) || shopt -u nullglob
# Define PS1 # Aliases
load_alias aliases
# Define PS1. The timer hook is a DEBUG trap: it replaces any DEBUG trap set
# before (it cannot even be read from a sourced file, bash hides it there),
# so load this profile before tools such as bash-preexec.
trap 'timer_start' DEBUG trap 'timer_start' DEBUG
PROMPT_COMMAND='set_prompt'
# set_prompt is added to PROMPT_COMMAND instead of replacing it, so that
# what exists (VTE's directory reporting, direnv, "history -a", ...) keeps
# working. It has to read the exit status of the user's last command, which
# any command running before it resets: _profile_save_status stores it
# first, set_prompt (which uses it) runs last. It must also run last for the
# timer: the DEBUG trap fires for every command of PROMPT_COMMAND, and the
# elapsed time is measured from the first one of the user's command.
# Nothing changes when PROMPT_COMMAND is empty or already has set_prompt.
case "$(declare -p PROMPT_COMMAND 2>/dev/null)" in
"declare -a"*)
# Array form (bash 5.1+)
if [[ " ${PROMPT_COMMAND[*]} " != *" set_prompt "* ]]; then
if (( ${#PROMPT_COMMAND[@]} )); then
PROMPT_COMMAND=(_profile_save_status "${PROMPT_COMMAND[@]}" set_prompt)
else
PROMPT_COMMAND=(set_prompt)
fi
fi
;;
*)
if [[ -z "${PROMPT_COMMAND:-}" ]]; then
PROMPT_COMMAND='set_prompt'
elif [[ "$PROMPT_COMMAND" != *set_prompt* ]]; then
PROMPT_COMMAND="_profile_save_status;${PROMPT_COMMAND};set_prompt"
fi
;;
esac
# Set default language from DEFAULT_LANG config key (set in [general]). # Set default language from DEFAULT_LANG config key (set in [general]).
# The value must match one of the alias names defined in SET_LOCALE so that # The value must match one of the alias names defined in SET_LOCALE so that
@@ -279,9 +553,12 @@ if [[ $INTERACTIVE ]]; then
check_updates -q check_updates -q
disp I "Profile version $PROFVERSION loaded..." disp I "Profile version $PROFVERSION loaded..."
fi fi
unset _profile_ng
# Cleanup # Cleanup
unset pathremove pathprepend pathappend unset -f _profile_is_sourced _profile_install_in_file _profile_file_sources _profile_install
unset -f parse_conf load_alias load_conf
unset -f pathremove pathprepend pathappend
#return 0 #return 0
+1 -1
View File
@@ -1 +1 @@
4.0.0 4.2.0