Most projects have a folder of shell scripts that nobody reviews carefully: deploy helpers, CI steps, release tooling. They break in quiet ways, usually because of an unquoted variable. ESLint now officially lints shell scripts. The new @eslint/shell plugin, announced on the ESLint blog, parses Bash, POSIX sh and mksh and ships rules inspired by ShellCheck. This tutorial sets it up, walks through the rules with before-and-after examples, and adds it to CI.
Why lint shell with ESLint?
ShellCheck is excellent, and nothing here says you should drop it. The advantage of @eslint/shell is that your scripts use the same tool, config file, editor integration and CI step as your JavaScript, JSON, Markdown and CSS, which ESLint already supports officially. You also get ESLint’s familiar disable comments, and you can write custom rules against a shell syntax tree.
Install
The plugin requires Node.js ^20.19.0 || ^22.13.0 || >=24 and is tested with ESLint v10, according to its README:
npm install --save-dev eslint @eslint/shell
Configure
Add a block for your script files to eslint.config.js:
// eslint.config.js
import { defineConfig } from "eslint/config";
import shell from "@eslint/shell";
export default defineConfig([
{
// recommended rules for *.sh and *.bash files
files: ["**/*.sh", "**/*.bash"],
plugins: { shell },
extends: ["shell/recommended"],
},
]);
The config uses ES module syntax, so either name the file eslint.config.mjs or set "type": "module" in package.json. Otherwise Node.js will fail with “Cannot use import statement outside a module”.
The recommended config uses the Bash dialect. If your scripts must run on /bin/sh (for example in Alpine-based Docker images), use the POSIX language so Bash-only syntax is not treated as valid:
{
files: ["docker/**/*.sh"],
plugins: { shell },
extends: ["shell/recommended"],
language: "shell/posix",
}
The three languages are shell/bash, shell/posix and shell/mksh. Then run:
npx eslint "scripts/**/*.sh"
The rules, with examples
Each rule mirrors a well-known ShellCheck check. Here is a script that triggers most of them:
#!/usr/bin/env bash
# deploy.sh (before)
VERSION=`git describe --tags`
UNUSED_FLAG=1
cd $DEPLOY_DIR
for f in $(ls *.tar.gz); do
cat $f | gzip -t
done
echo 'Deploying $VERSION'
printf "Release $VERSION\n"
read answer
NAME=$(echo $1)
And what each rule complains about:
shell/no-backticks(SC2006, fixable): use$(...)instead of legacy backticks.shell/no-unused-vars(SC2034):UNUSED_FLAGis assigned but never used.shell/no-unquoted-expansions(SC2086/SC2046, fixable):$DEPLOY_DIRand$fare subject to word splitting and globbing. A path with a space breaks the script.shell/require-cd-guard(SC2164, with suggestions): ifcdfails, the script keeps running in the wrong directory. Add|| exit.shell/no-ls-iteration(SC2045): don’t loop overlsoutput, use a glob.shell/no-expansions-in-single-quotes(SC2016, warning):'$VERSION'prints literally.shell/no-variables-in-printf-format(SC2059): variables in the format string can inject%sequences.shell/require-read-r(SC2162, fixable): without-r,readmangles backslashes.shell/no-useless-echo(SC2116):$(echo $1)is just$1.
One more recommended rule, shell/no-useless-cat (SC2002), flags piping from a single-file cat; it did not fire on the loop above in our test. We ran ESLint 10.14 with shell/recommended on the script above, and this is the output:
before.sh
3:9 error Use $(...) notation instead of legacy backticks shell/no-backticks
4:1 error Variable 'UNUSED_FLAG' is assigned but never used shell/no-unused-vars
5:1 error Use 'cd ... || exit' or 'cd ... || return' in case cd fails shell/require-cd-guard
5:4 error Double quote "$DEPLOY_DIR" to prevent word splitting... shell/no-unquoted-expansions
6:10 error Iterating over ls output is fragile. Use globs... shell/no-ls-iteration
6:10 error Quote this command substitution to prevent word splitting shell/no-unquoted-expansions
7:7 error Double quote "$f" to prevent word splitting and globbing shell/no-unquoted-expansions
9:6 warning Expressions don't expand in single quotes... shell/no-expansions-in-single-quotes
10:8 error Don't use variables in the printf format string... shell/no-variables-in-printf-format
11:1 error Using read without -r will mangle backslashes shell/require-read-r
11:6 error Variable 'answer' is assigned but never used shell/no-unused-vars
12:1 error Variable 'NAME' is assigned but never used shell/no-unused-vars
12:6 error Useless echo? Instead of 'cmd $(echo foo)', just use 'cmd foo' shell/no-useless-echo
12:13 error Double quote "$1" to prevent word splitting and globbing shell/no-unquoted-expansions
✖ 14 problems (13 errors, 1 warning)
5 errors and 0 warnings potentially fixable with the `--fix` option.
Notice that the linter also caught answer and NAME being assigned and never used, which hints that the script was unfinished. The fixed version below lints with zero problems:
#!/usr/bin/env bash
# deploy.sh (after)
VERSION=$(git describe --tags)
cd "$DEPLOY_DIR" || exit 1
for f in *.tar.gz; do
gzip -t "$f"
done
echo "Deploying $VERSION"
printf 'Release %s\n' "$VERSION"
read -r answer
echo "You said: $answer"
NAME=$1
echo "Deploying for $NAME"
Several rules are auto-fixable, so start with:
npx eslint "scripts/**/*.sh" --fix
Review the diff before committing. Quoting changes are usually safe, but check any place where you relied on word splitting on purpose.
Tuning rules and disabling them locally
shell/no-unused-vars takes an option for variables that other scripts read, such as exported config:
rules: {
"shell/no-unused-vars": ["error", { allowed: ["APP_ENV", "LOG_LEVEL"] }],
}
Standard ESLint comments work inside shell scripts:
# eslint-disable-next-line shell/no-backticks
echo `pwd`
echo `date` # eslint-disable-line shell/no-backticks -- legacy, remove in Q1
Scripts without a .sh extension
Many repos have extensionless scripts such as bin/setup or Git hooks. ESLint only lints what matches files, so list them explicitly:
{
files: ["**/*.sh", "**/*.bash", "bin/*", ".husky/*"],
ignores: [".husky/_/**"],
plugins: { shell },
extends: ["shell/recommended"],
}
Run it in CI
Add a script to package.json:
{
"scripts": {
"lint:sh": "eslint \"**/*.sh\" \"bin/*\""
}
}
And a GitHub Actions job:
name: lint-shell
on: [pull_request]
jobs:
shell:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm run lint:sh
Writing a custom rule
The plugin parses scripts into an ESTree-style tree using mvdan-sh, the parser behind shfmt, and you can explore it in the ESLint Code Explorer. Nodes carry start and end offsets instead of range/loc, so use sourceCode.getRange(node) and sourceCode.getLoc(node). The parser is also exported if you want to experiment:
import { parseShell } from "@eslint/shell";
const { ast, comments } = parseShell('echo "hello"\n');
console.log(JSON.stringify(ast, null, 2));
Print the tree for a pattern you want to ban, such as curl ... | bash, and check the node types in the syntax-tree docs before writing the rule.
Takeaway
@eslint/shell puts shell scripts under the same linting setup as the rest of your code. Install it, enable shell/recommended, run --fix once, and add a CI step. The unquoted-expansion and cd-guard rules alone will catch bugs that have probably already bitten your deploy scripts.
Sources: ESLint blog announcement, eslint/shell README.
