10 ms·
Techniques I use to create a great user experience for shell scripts
- bfung 2y agoOnly one that’s shell specific is 4. The rest can be applied any code written. Good work!
- kemitche 2y agoEven 4 can be generalized to "be deliberate about what you do with a failed function call (etc) - does it exit the command? Log/print an error and continue? Get silently ignored? Handled?"
- mkmk 2y agoI don’t remember where I got it, but I have a simple implementation of a command-line spinner that I use keyboard shortcuts to add to most scripts. Has been a huge quality of life improvement but I wish I could just as seamlessly drop in a progress bar (of course, knowing how far along you are is more complex than knowing you’re still chugging along).
- hipjiveguy 2y agocan you share it?
- millzlane 2y agoNot OP but I have used this one with success. https://stackoverflow.com/questions/12498304/using-bash-to-display-a-progress-indicator-spinner https://stackoverflow.com/questions/12498304/using-bash-to-d...
- denvaar 2y agoI'd add that if you're going to use color, then you should do the appropriate checks for determining if STDOUT isatty
- zdw 2y agoOr $NO_COLOR, per https://no-color.org https://no-color.org
- Rzor 2y agoNicely done. I love everything about this.
- fragmede 2y agoDefinitely don't check that a variable is non-empty before running rm -rf ${VAR}/* That's typically a great experience for shell scripts!
- teroshan 2y agoIt happens to the best of us https://github.com/ValveSoftware/steam-for-linux/issues/3671 https://github.com/ValveSoftware/steam-for-linux/issues/3671
- ndsipa_pomu 2y agoAlso, you'd want to put in a double dash to signify the end of arguments as otherwise someone could set VAR="--no-preserve-root " and truly trash the system. Also, ${VAR} needs to be in double quotes for something as dangerous as a "rm" command: rm -rf -- "${VAR}"/*
- jiggawatts 2y agoEvery time I see a “good” bash script it reminds me of how incredibly primitive every shell is other than PowerShell. Validating parameters - a built in declarative feature! E.g.: ValidateNotNullOrEmpty. Showing progress — also built in, and doesn’t pollute the output stream so you can process returned text AND see progress at the same time. (Write-Progress) Error handling — Try { } Catch { } Finally { } works just like with proper programming languages. Platform specific — PowerShell doesn’t rely on a huge collection of non-standard CLI tools for essential functionality. It has built-in portable commands for sorting, filtering, format conversions, and many more. Works the same on Linux and Windows. Etc… PS: Another super power that bash users aren’t even aware they’re missing out on is that PowerShell can be embedded into a process as a library (not an external process!!) and used to build an entire GUI that just wraps the CLI commands. This works because the inputs and outputs are strongly typed objects so you can bind UI controls to them trivially. It can also define custom virtual file systems with arbitrary capabilities so you can bind tree navigation controls to your services or whatever. You can “cd” into IIS, Exchange, and SQL and navigate them like they’re a drive. Try that with bash!
- richbell 2y agoI am Microsoft hater. I cannot stand Windows and only use Linux. PowerShell blows bash out of the water. I love it.
- sweeter 2y agoexcept for the fact that it is slower than hell and the syntax is nuts. I don't really understand the comparison, bash is basically just command glue for composing pipelines and pwsh is definitely more of a full-fledged language... but to me, I use bash because its quick and dirty and it fits well with the Unix system. If I wanted the features that pwsh brings I would much rather just pick a language like Golang or Python where the experience is better and those things will work on any system imaginable. Whereas pwsh is really good on windows for specifically administrative tasks.
- hollerith 2y ago
- watmough 2y agoGood stuff. One rule I like, is to ensure that, as well as validation, all validated information is dumped in a convenient format prior to running the rest of the script. This is super helpful, assuming that some downstream process will need pathnames, or some other detail of the process just executed.
- markus_zhang 2y agoI was so frustrated by having to enter a lot of information for every new git project (I use a new VM for each project) so I wrote a shell script that automates everything for me. I'll probably also combine a few git commands for every commit and push.
- sureglymop 2y agoSounds like a cool setup! Did you write it up somewhere publicly? I also use VMs (qemu microvms) based on docker images for development.
- markus_zhang 2y agoSorry it's on my other machine so I don't have it at hand. But it's an extremely simple setup that configs the email, the user, removes the need of --set-upstream when pushing, automate pushing with token. I asked ChatGPT to write it and double checked btw.
- stephenr 2y agoMost of those things can just be set in your global git config file, and surely you're using some kind of repeatable/automated setup for VMs.. I don't see why you'd ever need to be doing something other than "copy default git config file" in your Vagrantfile/etc
- markus_zhang 2y agoThat's a good idea. I use VirtualBox but I'm sure there is something similar I can do.
- stephenr 2y agoThe benefit of vagrant is it works with a wide variety of Hypervisors (including vbox) and strongly encourages a reproducible setup through defined provisioning steps.
- haileys 2y agoDon't output ANSI colour codes directly - your output could redirect to a file, or perhaps the user simply prefers no colour. Use tput instead, and add a little snippet like this to the top of your script: command -v tput &>/dev/null && [ -t 1 ] && [ -z "${NO_COLOR:-}" ] || tput() { true; } This checks that the tput command exists (using the bash 'command' builtin rather than which(1) - surprisingly, which can't always be relied upon to be installed even on modern GNU/Linux systems), that stdout is a tty, and that the NO_COLOR env var is not set. If any of these conditions are false, a no-op tput function is defined. This little snippet of setup lets you sprinkle tput invocations through your script knowing that it's going to do the right thing in any situation.
- deleted 2y ago[deleted]
- lilyball 2y agoIf you use tput a lot it's also worth caching the output, because invoking it for every single color change and reset can really add up. If you know you're going to use a bunch of colors up front you can just stuff them into vars RED=$(tput setaf 1) GREEN=$(tput setaf 2) RESET=$(tput sgr0)
- hinkley 2y agoThere should just be a command for this. Like echo with a color flag that does something if you’re in a tty.
- CGamesPlay 2y agoBut since there isn’t, even if you make one, people won’t want to rely on it as a dependency.
- Aeolun 2y agoCan add it to bash?
- 2y ago
- sgarland 2y agoNowhere in this list did I see “use shellcheck.” On the scale of care, “the script can blow up in surprising ways” severely outweighs “error messages are in red.” Also, as someone else pointed out, what if I’m redirecting to a file?
- xyzzy_plugh 2y agoI find shellcheck to be a bit of a nuisance. For simple one-shot scripts, like cron jobs or wrappers, it's fine. But for more complicated scripts or command line tools, it can have a pretty poor signal-to-noise ratio. Not universally, but often enough that I don't really reach for it anymore. In truth when I find myself writing a large "program" in Bash such that shellcheck is cumbersome it's a good indication that it should instead be written in a compiled language.
- lilyball 2y agoWhat sort of noise do you see? I find it's pretty rare to run into something that needs to be suppressed.
- xyzzy_plugh 2y agoIt's been so long since I used it seriously I couldn't tell you. There's over 1000 open issues on the GitHub repo, and over 100 contain "false positive". I recognize several of these at first glance. https://github.com/koalaman/shellcheck/issues?q=is%3Aissue+is%3Aopen+%22false+positive%22 https://github.com/koalaman/shellcheck/issues?q=is%3Aissue+i...
- plorkyeran 2y agoI’ve definitely hit places where shellcheck is just plain wrong, but I’ve started to just think of it as a different language that’s a subset of shell. It’s less of a linter and more like using gradual type checking, where there’s no guarantee that all valid programs will be accepted; only that the programs which are accepted are free of certain categories of bugs.
- 2y ago
- xyzzy4747 2y agoNot trying to offend anyone here but I think shell scripts are the wrong solution for anything over ~50 lines of code. Use a better programming language. Go, Typescript, Rust, Python, and even Perl come to mind.
- sgarland 2y agoSome of us enjoy the masochism, thank you very much.
- nativeit 2y agoHear, hear! Bash me datty.
- httbs 2y agoI draw the line at around 300 lines.
- heresie-dabord 2y ago> shell scripts are the wrong solution for anything over ~50 lines of code. I don't think LOC is the correct criterion. I do solve many problems with bash and I enjoy the simplicity of shell coding. I even have long bash scripts. But I do agree that shell scripting is the right solution only if = you can solve the problem quickly = you don't need data structures = you don't need math = you don't need concurrency
- rbonvall 2y agoIn my opinion, shell scripting is the right tool when you need to do a lot of calling programs, piping, and redirecting. Such programs end up being cumbersome in "proper" languages.
- sulandor 2y ago"you can do anything not matter how horrible you feel" but yea, shell is foremost a composition language/environment
- koolba 2y agoif [ "$(uname -s)" == "Linux” ]; then stuff-goes-here else # Assume MacOS While probably true for most folks, that’s hardly what I’d call great for everybody not on Linux or a Mac.
- klysm 2y agoGotta draw a line somewhere
- edflsafoiewq 2y agoYeah, but you could at least elif is mac then ... else unsupported end.
- dspillett 2y agoIf you look at the next couple of lines of the code, it emits a warning if neither command is found, but carries on. Running without in this case works but it's not optimal, as described in the warning message.
- sgarland 2y agoEh. It’s true for most, and if not, it’s probably still a *BSD, so there’s a good chance that anything written for a Mac will still work. That said, I’ve never used any of the BSDs, so I may be way off here.
- sulandor 2y agoeven a certified unix https://www.opengroup.org/openbrand/register/brand3700.htm https://www.opengroup.org/openbrand/register/brand3700.htm
- dspillett 2y agoThe following check for gtimeout means that other OSs that don't have the expected behaviour in either command won't break the script, you'll just get a warning message that isn't terribly relevant to them (but more helpful than simply failing or silently running without timeout/gtimeout. Perhaps improving that message would be the better option. Though for that snippet I would argue for testing for the command rather than the OS (unless Macs or some other common arrangement has something incompatible in the standard path with the same command name?).
- worik 2y agoI liked the commenting style
- deleted 2y ago[deleted]
- dvrp 2y agoThese are all about passive experiences (which are great don't get me wrong!), but I think you can do better. It's the same phenomenon DHH talked about in the Rails doctrine when he said to "Optimize for programmer happiness". The python excerpt is my favorite example: ``` $ irb irb(main):001:0> exit $ irb irb(main):001:0> quit $ python >>> exit Use exit() or Ctrl-D (i.e. EOF) to exit ``` <quote> Ruby accepts both exit and quit to accommodate the programmer’s obvious desire to quit its interactive console. Python, on the other hand, pedantically instructs the programmer how to properly do what’s requested, even though it obviously knows what is meant (since it’s displaying the error message). That’s a pretty clear-cut, albeit small, example of [Principle of Least Surprise]. </quote>
- AlienRobot 2y agoYes. I'd be surprised if exit without parentheses quit the interactive shell when it doesn't quit a normal python script.
- wodenokoto 2y agoIpython quits without parenthesis.
- nerdponx 2y agoIPython includes a whole lot of extra magic of various kinds, compared to the built-in Python console.
- pmarreck 2y agothis actually completely turned me off from python when I first encountered it. I was like... "the program KNEW WHAT I WAS TRYING TO DO, and instead of just DOING that it ADMONISHED me, fuck Python" LOL The proliferation of Python has only made my feelings worse. Try running a 6 month old Python project that you haven't touched and see if it still runs. /eyeroll
- tpmoney 2y ago
- TeeMassive 2y agoI'd add, in each my Bash scripts I add this line to get the script's current directory: SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) This is based on this SA's answer: https://stackoverflow.com/questions/59895/how-do-i-get-the-directory-where-a-bash-script-is-located-from-within-the-script https://stackoverflow.com/questions/59895/how-do-i-get-the-d... I never got why Bash doesn't have a reliable "this file's path" feature and why people always take the current working directory for granted!
- Yasuraka 2y agoI've been using script_dir="$(dirname "$(realpath "$0")")" Hasn't failed me so far and it's easy enough to remember
- TeeMassive 2y agoRead the answers and comments from the SO threads. It won't always work for other people in other context.
- thangalin 2y agoI like: readonly SCRIPT_SRC="$(dirname "${BASH_SOURCE[${#BASH_SOURCE[@]} - 1]}")" readonly SCRIPT_DIR="$(cd "${SCRIPT_SRC}" >/dev/null 2>&1 && pwd)" readonly SCRIPT_NAME=$(basename "$0")
- pmarreck 2y agoRegarding point 1, you should `exit 2` on bad usage, not 1, because it is widely considered that error code 2 is a USAGE error.
- pmarreck 2y agohttps://github.com/charmbracelet/glow https://github.com/charmbracelet/glow is pretty nice for stylized TUI output
- bpshaver 2y agoGlow is awesome.
- JoosToopit 2y agoNo. Glow connects to internet servers, screw that.
- bpshaver 2y agoOops, I actually meant Gum, not Glow. Different project from the same folks. That said, I use Glow to render markdown sometimes. When and how does it connect to internet servers?
- pmarreck 2y agoI think the person you're responding to is FOS but anyone can audit the source code to find such a thing: https://github.com/charmbracelet/glow https://github.com/charmbracelet/glow
- pmarreck 2y agoPlease point me to the exact place in the source code where it does that: https://github.com/charmbracelet/glow https://github.com/charmbracelet/glow
- JoosToopit 2y agohttps://github.com/charmbracelet/glow/issues/172 https://github.com/charmbracelet/glow/issues/172 https://github.com/charmbracelet/glow/issues/615 https://github.com/charmbracelet/glow/issues/615 But it looks like they finally removed that code a few months ago: https://github.com/charmbracelet/glow/pull/619 https://github.com/charmbracelet/glow/pull/619
- jojo_ 2y agoFew months ago, I wrote a bash script for an open-source project. I created a small awk util that I used throughout the script to style the output. I found it very convenient. I wonder if something similar already exists. Some screenshots in the PR: https://github.com/ricomariani/CG-SQL-author/pull/18 https://github.com/ricomariani/CG-SQL-author/pull/18 Let me know guys if you like it. Any comments appreciated. function theme() { ! $IS_TTY && cat || awk ' /^([[:space:]]*)SUCCESS:/ { sub("SUCCESS:", " \033[1;32m&"); print; printf "\033[0m"; next } /^([[:space:]]*)ERROR:/ { sub("ERROR:", " \033[1;31m&"); print; printf "\033[0m"; next } /^ / { print; next } /^ / { print "\033[1m" $0 "\033[0m"; next } /^./ { print "\033[4m" $0 "\033[0m"; next } { print } END { printf "\033[0;0m" }' } Go to source: https://github.com/ricomariani/CG-SQL-author/blob/main/playground/play.sh#L560-L583 https://github.com/ricomariani/CG-SQL-author/blob/main/playg... Example usage: exit_with_help_message() { local exit_code=$1 cat <<EOF | theme CQL Playground Sub-commands: help Show this help message hello Onboarding checklist — Get ready to use the playground build-cql-compiler Rebuild the CQL compiler Go to source: https://github.com/ricomariani/CG-SQL-author/blob/main/playground/play.sh#L26 https://github.com/ricomariani/CG-SQL-author/blob/main/playg... cat <<EOF | theme CQL Playground — Onboarding checklist Required Dependencies The CQL compiler $($cql_compiler_ready && \ echo "SUCCESS: The CQL compiler is ready ($CQL)" || \ echo "ERROR: The CQL compiler was not found. Build it with: $CLI_NAME build-cql-compiler" ) Go to source: https://github.com/ricomariani/CG-SQL-author/blob/main/playground/play.sh#L114 https://github.com/ricomariani/CG-SQL-author/blob/main/playg...
- Myrmornis 2y agoIn the first example, the error messages should be going to stderr.
- gorgoiler 2y agoIt is impossible to write a safe shell script that does automatic error checking while using the features the language claims are available to you. Here’s a script that uses real language things like a function and error checking, but which also prints “oh no”: set -e f() { false echo oh } if f then echo no fi set -e is off when your function is called as a predicate. That’s such a letdown from expected- to actual-behavior that I threw it in the bin as a programming language. The only remedy is for each function to be its own script. Great! In terms of sh enlightenment, one of the steps before getting to the above is realizing that every time you use “;” you are using a technique to jam a multi-line expression onto a single line. It starts to feel incongruous to mix single line and multi line syntax: # weird if foo; then bar fi # ahah if foo then bar fi Writing long scripts without semicolons felt refreshing, like I was using the syntax in the way that nature intended. Shell scripting has its place. Command invocation with sh along with C functions is the de-facto API in Linux. Shell scripts need to fail fast and hard though and leave it up to the caller (either a different language, or another shell script) to figure out how to handle errors.
- Yasuraka 2y agoHere's a script that left an impression on me the first time I saw it: https://github.com/containerd/nerdctl/blob/main/extras/rootless/containerd-rootless-setuptool.sh https://github.com/containerd/nerdctl/blob/main/extras/rootl... I have since copied this pattern for many scripts: logging functions, grouping all global vars and constants at the top and creating subcommands using shift.
- oneshtein 2y agoYou may like bash-modules then: https://github.com/vlisivka/bash-modules/tree/master/bash-modules/examples https://github.com/vlisivka/bash-modules/tree/master/bash-mo...
- teo_zero 2y agoIn the 4th section, is there a reason why set +e is inside the loop while set -e is outside, or is it just an error?
- gjvc 2y agoliterally nothing here of interest
- teo_zero 2y agoif [ -x "$(command -v gtimeout)" ]; then Interesting way to check if a command is installed. How is it better than the simpler and more common "if command...; then"?
- mbivert 2y agoThe form you propose runs `command`, which may have undesired side-effects. I always thought `which` to be standard, but TIL `command` (sh builtin) is[0]. [0]: https://hynek.me/til/which-not-posix/ https://hynek.me/til/which-not-posix/
- stephenr 2y agoTo be clear: both alternatives shown below will invoke the same thing (`command -v gtimeout`). if [ -x "$(command -v gtimeout)" ]; then and if command -v gtimeout >/dev/null; then The first invokes it in a sub shell (and captures the output), the second invokes it directly and discards the output, using the return status of `command` as the input to `if`. The superficial reason the second is "preferred" is that it's slightly better performance wise. Not a huge difference, but it is a difference. However the hidden, and probably more impactful reason it's preferred, is that the first can give a false negative. If the thing you want to test before calling is implemented as a shell builtin, it will fail, because the `-x` mode of `test` (and thus `[`) is a file test, whereas the return value of `command -v` is whether or not the command can be invoked.
- mbivert 2y agoAh! I misread the parent, if thought he meant `if command` to look for a random command (e.g. `if grep`)
- thangalin 2y agoThe first four parts of my Typesetting Markdown blog describes improving the user-friendliness of bash scripts. In particular, you can use bash to define a reusable script that allows isolating software dependencies, command-line arguments, and parsing. https://dave.autonoma.ca/blog/2019/05/22/typesetting-markdown-part-1/ https://dave.autonoma.ca/blog/2019/05/22/typesetting-markdow... In effect, create a list of dependencies and arguments: #!/usr/bin/env bash source $HOME/bin/build-template DEPENDENCIES=( "gradle,https://gradle.org" "warp-packer,https://github.com/Reisz/warp/releases" "linux-x64.warp-packer,https://github.com/dgiagio/warp/releases" "osslsigncode,https://www.winehq.org" ) ARGUMENTS+=( "a,arch,Target operating system architecture (amd64)" "o,os,Target operating system (linux, windows, macos)" "u,update,Java update version number (${ARG_JAVA_UPDATE})" "v,version,Full Java version (${ARG_JAVA_VERSION})" ) The build-template can then be reused to enhance other shell scripts. Note how by defining the command-line arguments as data you can provide a general solution to printing usage information: https://gitlab.com/DaveJarvis/KeenWrite/-/blob/main/scripts/build-template?#L186 https://gitlab.com/DaveJarvis/KeenWrite/-/blob/main/scripts/... Further, the same command-line arguments list can be used to parse the options: https://gitlab.com/DaveJarvis/KeenWrite/-/blob/main/scripts/build-template?#L186 https://gitlab.com/DaveJarvis/KeenWrite/-/blob/main/scripts/... If you want further generalization, it's possible to have the template parse the command-line arguments automatically for any particular script. Tweak the arguments list slightly by prefixing the name of the variable to assign to the option value provided on the CLI: ARGUMENTS+=( "ARG_JAVA_ARCH,a,arch,Target operating system architecture (amd64)" "ARG_JAVA_OS,o,os,Target operating system (linux, windows, macos)" "ARG_JAVA_UPDATE,u,update,Java update version number (${ARG_JAVA_UPDATE})" "ARG_JAVA_VERSION,v,version,Full Java version (${ARG_JAVA_VERSION})" ) If the command-line options require running different code, it is possible to accommodate that as well, in a reusable solution.
- emmelaich 2y agoTiny nitpick - usage errors are conventionally 'exit 2' not 'exit 1'
- anthk 2y agoA tip: sh -x $SCRIPT shows a debugging trace on the script in a verbose way, it's unvaluable on errors. You can use it as a shebang too: #!/bin/sh -x
- olejorgenb 2y agoThanks! I've always edited the script adding a `set -x` at the top. Never occurred to me that I the shell of course had a similar startup flag.
- _def 2y ago> This matches the output format of Bash's builtin set -x tracing, but gives the script author more granular control of what is printed. I get and love the idea but I'd consider this implementation an anti-pattern. If the output mimics set -x but isn't doing what that is doing, it can mislead users of the script.
- delusional 2y agoEven worse, it mimics it poorly, hardcoding the PS4 to the default. The author could also consider trapping debug to maybe be selective while also making it a little more automatic.
- ndsipa_pomu 2y agoI can highly recommend using bash3boilerplate (https://github.com/kvz/bash3boilerplate https://github.com/kvz/bash3boilerplate) if you're writing BASH scripts and don't care about them running on systems that don't use BASH. It provides logging facilities with colour usage for the terminal (not for redirecting out to a file) and also decent command line parsing. It uses a great idea to specify the calling parameters in the help/usage information, so it's quick and easy to use and ensures that you have meaningful information about what parameters the script accepts. Also, please don't write shell scripts without running them through ShellCheck. The shell has so many footguns that can be avoided by correctly following its recommendations.
- rednafi 2y agoI ask LLMs to modify the shell script to strictly follow Google’s Bash scripting guidelines[^1]. It adds niceties like `set -euo pipefail`, uses `[[…]]` instead of `[…]` in conditionals, and fences all but numeric variables with curly braces. Works great. [^1]: https://google.github.io/styleguide/shellguide.html https://google.github.io/styleguide/shellguide.html
- zelphirkalt 2y agoWhy would you change a shell (sh?) script into a Bash script? And why would you change [[ into [ expressions, which are not Posix, as far as I remember? And why make the distinction for numeric variablesand not simply make the usage the same, consistent for everything? Does it also leave away the double quotes there? That even sounds dangerous, since numeric variables can contain filenames with spaces. Somehow whenever people dance to the Google code conventions tune, I find they adhere to questionable practices. I think people need to realize, that big tech conventions are simply their common debominator, and not especially great rules, that everyone should adopt for themselves.
- ykonstant 2y ago>That even sounds dangerous, since numeric variables can contain filenames with spaces. Or filenames that contain the number zero :D #!/bin/sh # # Usage : popc_unchecked BINARY_STRING # # Count number of 1s in BINARY_STRING. Made to demonstrate a use of IFS that # can bite you if you do not quote all the variables you don't want to split. len="${#1}" count() { printf '%s\n' "$((len + 1 - $#))"; } saved="${IFS}" IFS=0 count 1${1}1 IFS="${saved}" # PS: we do not run the code in a subshell because popcount needs to be highly # performant (≖ ᴗ ≖ )
- Arch-TK 2y agoThis reads like what I've named as "consultantware" which is a type of software developed by security consultants who are eager to write helpful utilities but have no idea about the standards for how command line software behaves on Linux. It ticks so many boxes: * Printing non-output information to stdout (usage information is not normal program output, use stderr instead) * Using copious amounts of colours everywhere to draw attention to error messages. * ... Because you've flooded my screen with even larger amount of irrelevant noise which I don't care about (what is being ran). * Coming up with a completely custom and never before seen way of describing the necessary options and arguments for a program. * Trying to auto-detect the operating system instead of just documenting the non-standard dependencies and providing a way to override them (inevitably extremely fragile and makes the end-user experience worse). If you are going to implement automatic fallbacks, at least provide a warning to the end user. * ... All because you've tried to implement a "helpful" (but unnecessary) feature of a timeout which the person using your script could have handled themselves instead. * pipefail when nothing is being piped (pipefail is not a "fix" it is an option, whether it is appropriate is dependant on the pipeline, it's not something you should be blanket applying to your codebase) * Spamming output in the current directory without me specifying where you should put it or expecting it to even happen. * Using set -e without understanding how it works (and where it doesn't work).
- Arch-TK 2y agoAddendum after reading the script: * #!/bin/bash instead of #!/usr/bin/env bash * [ instead of [[ * -z instead of actually checking how many arguments you got passed and trusting the end user if they do something weird like pass an empty string to your program * echo instead of printf * `print_and_execute sdk install java $DEFAULT_JAVA_VERSION` who asked you to install things? * `grep -h "^sdk use" "./prepare_$fork.sh" | cut -d' ' -f4 | while read -r version; do` You're seriously grepping shell scripts to determine what things you should install? * Unquoted variables all over the place. * Not using mktemp to hold all the temporary files and an exit trap to make sure they're cleaned up in most cases.
- nicbou 2y agoAs a bash casual, these suggestions are a reminder of why I avoid using bash when I can. That's a whole armory of footguns right there.
- latexr 2y ago> if [ -z "$1" ] I also recommend you catch if the argument is `-h` or `--help`. A careful user won’t just run a script with no arguments in the hopes it does nothing but print the help.¹ if [[ "${1}" =~ ^(-h|--help)$ ]] Strictly speaking, your first command should indeed `exit 1`, but that request for help should `exit 0`. ¹ For that reason, I never make a script which runs without an argument. Except if it only prints information without doing anything destructive or that the user might want to undo. Everything else must be called with an argument, even if a dummy one, to ensure intentionality.
- archargelod 2y agoOne of my favorite techniques for shell scripts, not mentioned in the article: For rarely run scripts, consider checking if required flags are missing and query for user input, for example: [[ -z "$filename" ]] && printf "Enter filename to edit: " && read filename Power users already know to always do `-h / --help` first, but this way even people that are less familiar with command line can use your tool. if that's a script that's run very rarely or once, entering the fields sequentially could also save time, compared to common `try to remember flags -> error -> check help -> success` flow.
- artursapek 2y agoThis post and comment section are a perfect encapsulation of why I'll just write a Rust or Go program, not bash, if I want to create a CLI tool that I actually care about.
- baby 2y agoLet's normalize using python instead of bash
- electromech 2y agoUsing what version of python? How will you distribute the expected version to target machines? python has its place, but it's not without its own portability challenges and sneaky gotchas. I have many times written and tested a python script with (for example) 3.12 only to have a runtime error on a coworker's machine because they have an older python version that doesn't support a language feature that I used. For small, portable scripts I try to stick to POSIX standards (shellcheck helps with this) instead of bash or python. For bigger scripts, typically I'll reach for python or Typescript. However, that requires paying the cost of documenting and automating the setup, version detection, etc. and the cost to users for dealing with that extra setup and inevitable issues with it. Compiled languages are the next level, but obviously have their own challenges.
- baby 2y ago> Using what version of python? How will you distribute the expected version to target machines? Let's focus on solving this then. Because the number of times that I've had to do surgery on horrible bash files because they were written for some platform and didn't run on mine...
- ndsipa_pomu 2y agoDepends on how long you want the script/program to be usable. Try running a twenty year old BASH script versus a python programme on a new ARM or RISC-V chip. Or, try running BASH/python on some ancient AIX hardware.
- baby 2y agoI can still run old python fine, as much as old bash scripts. I often have to edit bash scripts written by others to make it run on my mac.
- 0xbadcafebee 2y agoIf you want a great script user experience, I highly recommend avoiding the use of pipefail. It causes your script to die unexpectedly with no output. You can add traps and error handlers and try to dig out of PIPESTATUS the offending failed intermediate pipe just to tell the user why the program is exiting unexpectedly, but you can't resume code execution from where the exception happened. You're also now writing a complicated ass program that should probably be in a more complete language. Instead, just check $? and whether a pipe's output has returned anything at all ([ -z "$FOO" ]) or if it looks similar to what you expect. This is good enough for 99% of scripts and allows you to fail gracefully or even just keep going despite the error (which is good enough for 99.99% of cases). You can also still check intermediate pipe return status from PIPESTATUS and handle those errors gracefully too.
- electromech 2y ago> "It causes your script to die unexpectedly with no output." Oh? I don't observe this behavior in my testing. Could you share an example? AFAIK, if you don't capture stderr, that should be passed to the user. > "Instead, just check $? and..." I agree that careful error handling is ideal. However, IMO it's good defensive practice to start scripts with "-e" and pipefail. For many/most scripts, it's preferable to fail with inadequate output than to "succeed" but not perform the actions expected by the caller.
- 0xbadcafebee 2y ago$ date +%w 0 $ cat foo.sh #!/usr/bin/env sh set -x set -eu -o pipefail echo "start of script" echo "start of pipe" | cat | false | cat | cat if [ "$(date +%w)" = "0" ] ; then echo "It's sunday! Here we do something important!" fi $ sh foo.sh + set -eu -o pipefail + echo 'start of script' start of script + echo 'start of pipe' + cat + false + cat + cat $ Notice how the script exits, and prints the last pipe it ran? It should have printed out the 'if ..' line next. It didn't, because the script exited with an error. But it didn't tell you that. If you later find out the script has been failing, and find this output, you can guess the pipe failed (it doesn't actually say it failed), but you don't know what part of the pipe failed or why. And you only know this much because tracing was enabled. If tracing is disabled (the default for most people), you would have only seen 'start of script' and then the program returning. Would have looked totally normal, and you'd be none the wiser unless whatever was running this script was also checking its return status and blaring a warning if it exited non-zero, and then you have an investigation to begin with no details. > IMO it's good defensive practice to start scripts with "-e" and pipefail. If by "defensive" you mean "creating unexpected failures and you won't know where in your script the failure happened or why", then I don't like defensive practice. I cannot remember a single instance in 20 years where pipefail helped me. But plenty of times where I spent hours trying to figure out where a script was crashing and why, long after it had been crashing for weeks/months, unbeknownst to me. To be sure, there were reasons why the pipe failed, but in almost all cases it didn't matter, because either I got the output I needed or didn't. > it's preferable to fail with inadequate output than to "succeed" but not perform the actions expected by the caller. I can't disagree more. You can "succeed" and still detect problems and handle them or exit gracefully. Failing with no explanation just wastes everybody's time. Furthermore, this is the kind of practice in backend and web development that keeps causing web apps to stop working, but the user gets no notification whatsoever, and so can't report an error, much less even know an error is happening. I've had this happen to me a half dozen times in the past month, from a bank's website, from a consumer goods company's website, even from a government website. Luckily I am a software engineer and know how to trace backend network calls, so I could discover what was going on; no normal user can do that.
- calmbonsai 2y agoMaybe in the late ‘90s it may have been appropriate to use shell for this (I used Perl for this back then) sort of TUI, but now it’s wrong-headed to use shell for anything aside from bootstrapping into an appropriately dedicated set of TUI libraries such as Python, Ruby, or hell just…anything with proper functions, deps checks, and error-handling.
- account42 2y ago> Strategic Error Handling with "set -e" and "set +e" I think appending an explicit || true for commands that are ok to fail makes more sense. Having state you need to keep track of just makes things less readable.