mirror of
https://github.com/obra/superpowers.git
synced 2026-06-12 21:59:04 +08:00
Compare commits
1 Commits
codex/trim
...
codex/shel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
867238cfc1 |
@@ -755,9 +755,10 @@ Two rules this enforces, which you must respect:
|
|||||||
- Don't write per-OS variants of the hook script. One extensionless bash script
|
- Don't write per-OS variants of the hook script. One extensionless bash script
|
||||||
plus the polyglot wrapper covers all three platforms.
|
plus the polyglot wrapper covers all three platforms.
|
||||||
|
|
||||||
`hooks/run-hook.cmd` itself is the authoritative implementation — read it. See
|
`hooks/run-hook.cmd` itself is the authoritative implementation — read it.
|
||||||
`docs/windows/polyglot-hooks.md` for the background and rationale behind the
|
(`docs/windows/polyglot-hooks.md` covers the background and rationale but
|
||||||
dispatcher pattern.
|
describes an earlier per-script `.cmd`/`.sh` variant, so trust the code over that
|
||||||
|
doc where they differ.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
# Cross-Platform Polyglot Hooks for Claude Code
|
# Cross-Platform Polyglot Hooks for Claude Code
|
||||||
|
|
||||||
Claude Code plugins need hooks that work on Windows, macOS, and Linux. This document describes the single generic dispatcher pattern used in `hooks/run-hook.cmd`.
|
Claude Code plugins need hooks that work on Windows, macOS, and Linux. This document explains the polyglot wrapper technique that makes this possible.
|
||||||
|
|
||||||
> **Authoritative source:** `hooks/run-hook.cmd` is the canonical implementation. When this document and the code diverge, trust the code.
|
|
||||||
|
|
||||||
## The Problem
|
## The Problem
|
||||||
|
|
||||||
@@ -12,22 +10,52 @@ Claude Code runs hook commands through the system's default shell:
|
|||||||
|
|
||||||
This creates several challenges:
|
This creates several challenges:
|
||||||
|
|
||||||
1. **Script execution**: Windows CMD can't execute `.sh` files directly
|
1. **Script execution**: Windows CMD can't execute `.sh` files directly - it tries to open them in a text editor
|
||||||
2. **Path format**: Windows uses backslashes (`C:\path`), Unix uses forward slashes (`/path`)
|
2. **Path format**: Windows uses backslashes (`C:\path`), Unix uses forward slashes (`/path`)
|
||||||
3. **Environment variables**: `$VAR` syntax doesn't work in CMD
|
3. **Environment variables**: `$VAR` syntax doesn't work in CMD
|
||||||
4. **`.sh` auto-prepend**: Claude Code on Windows automatically prepends `bash` to any command that contains `.sh` in its path — this interferes with the dispatcher if scripts have extensions
|
4. **No `bash` in PATH**: Even with Git Bash installed, `bash` isn't in the PATH when CMD runs
|
||||||
|
|
||||||
## The Solution: Extensionless Scripts + Single Generic Dispatcher
|
## The Solution: Polyglot `.cmd` Wrapper
|
||||||
|
|
||||||
The repo uses one generic `run-hook.cmd` dispatcher for all hooks. Hook scripts are **extensionless** (`session-start`, not `session-start.sh`). This is deliberate: it prevents Claude Code's Windows auto-detection from prepending `bash` to the dispatcher command and breaking it.
|
A polyglot script is valid syntax in multiple languages simultaneously. Our wrapper is valid in both CMD and bash:
|
||||||
|
|
||||||
### File Structure
|
```cmd
|
||||||
|
: << 'CMDBLOCK'
|
||||||
|
@echo off
|
||||||
|
"C:\Program Files\Git\bin\bash.exe" -l -c "\"$(cygpath -u \"$CLAUDE_PLUGIN_ROOT\")/hooks/session-start.sh\""
|
||||||
|
exit /b
|
||||||
|
CMDBLOCK
|
||||||
|
|
||||||
|
# Unix shell runs from here
|
||||||
|
"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
#### On Windows (CMD.exe)
|
||||||
|
|
||||||
|
1. `: << 'CMDBLOCK'` - CMD sees `:` as a label (like `:label`) and ignores `<< 'CMDBLOCK'`
|
||||||
|
2. `@echo off` - Suppresses command echoing
|
||||||
|
3. The bash.exe command runs with:
|
||||||
|
- `-l` (login shell) to get proper PATH with Unix utilities
|
||||||
|
- `cygpath -u` converts Windows path to Unix format (`C:\foo` → `/c/foo`)
|
||||||
|
4. `exit /b` - Exits the batch script, stopping CMD here
|
||||||
|
5. Everything after `CMDBLOCK` is never reached by CMD
|
||||||
|
|
||||||
|
#### On Unix (bash/sh)
|
||||||
|
|
||||||
|
1. `: << 'CMDBLOCK'` - `:` is a no-op, `<< 'CMDBLOCK'` starts a heredoc
|
||||||
|
2. Everything until `CMDBLOCK` is consumed by the heredoc (ignored)
|
||||||
|
3. `# Unix shell runs from here` - Comment
|
||||||
|
4. The script runs directly with the Unix path
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
hooks/
|
hooks/
|
||||||
├── hooks.json # Points to run-hook.cmd with extensionless script name
|
├── hooks.json # Points to the .cmd wrapper
|
||||||
├── run-hook.cmd # Cross-platform dispatcher (the polyglot wrapper)
|
├── session-start.cmd # Polyglot wrapper (cross-platform entry point)
|
||||||
└── session-start # Actual hook logic — extensionless bash script
|
└── session-start.sh # Actual hook logic (bash script)
|
||||||
```
|
```
|
||||||
|
|
||||||
### hooks.json
|
### hooks.json
|
||||||
@@ -37,12 +65,11 @@ hooks/
|
|||||||
"hooks": {
|
"hooks": {
|
||||||
"SessionStart": [
|
"SessionStart": [
|
||||||
{
|
{
|
||||||
"matcher": "startup|clear|compact",
|
"matcher": "startup|resume|clear|compact",
|
||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start",
|
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.cmd\""
|
||||||
"async": false
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -51,63 +78,41 @@ hooks/
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The path is quoted because `${CLAUDE_PLUGIN_ROOT}` may contain spaces.
|
Note: The path must be quoted because `${CLAUDE_PLUGIN_ROOT}` may contain spaces on Windows (e.g., `C:\Program Files\...`).
|
||||||
|
|
||||||
## How `run-hook.cmd` Works at a High Level
|
## Requirements
|
||||||
|
|
||||||
`run-hook.cmd` is a polyglot script: Windows treats the first block as batch
|
### Windows
|
||||||
commands, while Unix shells treat that block as a no-op heredoc and continue
|
- **Git for Windows** must be installed (provides `bash.exe` and `cygpath`)
|
||||||
after it.
|
- Default installation path: `C:\Program Files\Git\bin\bash.exe`
|
||||||
|
- If Git is installed elsewhere, the wrapper needs modification
|
||||||
|
|
||||||
Do not copy an implementation from this document. Read `hooks/run-hook.cmd`
|
### Unix (macOS/Linux)
|
||||||
directly when changing the dispatcher, and run `tests/hooks/test-session-start.sh`
|
- Standard bash or sh shell
|
||||||
afterward.
|
- The `.cmd` file must have execute permission (`chmod +x`)
|
||||||
|
|
||||||
### How it works on Windows (CMD.exe)
|
|
||||||
|
|
||||||
1. The batch section validates the script name and resolves the hook directory
|
|
||||||
from the dispatcher's own location.
|
|
||||||
2. It tries bash in three places:
|
|
||||||
- `C:\Program Files\Git\bin\bash.exe`
|
|
||||||
- `C:\Program Files (x86)\Git\bin\bash.exe`
|
|
||||||
- `bash` on `PATH` (MSYS2, Cygwin, or a non-default Git install)
|
|
||||||
3. If bash is found, it runs the named extensionless hook script from the hooks
|
|
||||||
directory.
|
|
||||||
4. If no bash is found, the dispatcher exits `0` silently — the plugin
|
|
||||||
continues working, it just skips the hook.
|
|
||||||
5. `exit /b` stops CMD before it reaches the Unix section.
|
|
||||||
|
|
||||||
### How it works on Unix (bash/sh)
|
|
||||||
|
|
||||||
1. `: << 'CMDBLOCK'` opens a heredoc on a no-op command.
|
|
||||||
2. The entire CMD batch block is consumed by the heredoc and ignored.
|
|
||||||
3. After `CMDBLOCK`, bash resolves the script directory and `exec`s the named
|
|
||||||
extensionless script directly.
|
|
||||||
|
|
||||||
### Key design decisions
|
|
||||||
|
|
||||||
| Decision | Why |
|
|
||||||
|----------|-----|
|
|
||||||
| Extensionless scripts | Prevents Claude Code's Windows `.sh`-auto-prepend from interfering with the dispatcher command |
|
|
||||||
| No `-l` (login shell) | Not needed; hook scripts should be self-contained and not depend on login-shell PATH setup |
|
|
||||||
| No `cygpath` | Bash receives the Windows path directly and handles it correctly; `cygpath` was needed by the old `-c "..."` invocation pattern, not by direct exec |
|
|
||||||
| Silent exit on no-bash | Avoids breaking the plugin for users who don't have Git for Windows; hook context injection is skipped gracefully |
|
|
||||||
|
|
||||||
## Writing Cross-Platform Hook Scripts
|
## Writing Cross-Platform Hook Scripts
|
||||||
|
|
||||||
Your hook logic goes in the extensionless script file. A few portable patterns:
|
Your actual hook logic goes in the `.sh` file. To ensure it works on Windows (via Git Bash):
|
||||||
|
|
||||||
### Do
|
### Do:
|
||||||
- Use pure bash builtins when possible
|
- Use pure bash builtins when possible
|
||||||
- Use `$(command)` instead of backticks
|
- Use `$(command)` instead of backticks
|
||||||
- Quote all variable expansions: `"$VAR"`
|
- Quote all variable expansions: `"$VAR"`
|
||||||
|
- Use `printf` or here-docs for output
|
||||||
|
|
||||||
### Avoid
|
### Avoid:
|
||||||
- Relying on PATH-dependent tools without fallbacks (the hook runs without `-l`, so login-shell PATH is not set)
|
- External commands that may not be in PATH (sed, awk, grep)
|
||||||
- Giving scripts a `.sh` extension — this triggers Claude Code's Windows auto-prepend
|
- If you must use them, they're available in Git Bash but ensure PATH is set up (use `bash -l`)
|
||||||
|
|
||||||
### Example: JSON escaping without external tools
|
### Example: JSON Escaping Without sed/awk
|
||||||
|
|
||||||
|
Instead of:
|
||||||
|
```bash
|
||||||
|
escaped=$(echo "$content" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' | awk '{printf "%s\\n", $0}')
|
||||||
|
```
|
||||||
|
|
||||||
|
Use pure bash:
|
||||||
```bash
|
```bash
|
||||||
escape_for_json() {
|
escape_for_json() {
|
||||||
local input="$1"
|
local input="$1"
|
||||||
@@ -128,21 +133,80 @@ escape_for_json() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Reusable Wrapper Pattern
|
||||||
|
|
||||||
|
For plugins with multiple hooks, you can create a generic wrapper that takes the script name as an argument:
|
||||||
|
|
||||||
|
### run-hook.cmd
|
||||||
|
```cmd
|
||||||
|
: << 'CMDBLOCK'
|
||||||
|
@echo off
|
||||||
|
set "SCRIPT_DIR=%~dp0"
|
||||||
|
set "SCRIPT_NAME=%~1"
|
||||||
|
"C:\Program Files\Git\bin\bash.exe" -l -c "cd \"$(cygpath -u \"%SCRIPT_DIR%\")\" && \"./%SCRIPT_NAME%\""
|
||||||
|
exit /b
|
||||||
|
CMDBLOCK
|
||||||
|
|
||||||
|
# Unix shell runs from here
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
SCRIPT_NAME="$1"
|
||||||
|
shift
|
||||||
|
"${SCRIPT_DIR}/${SCRIPT_NAME}" "$@"
|
||||||
|
```
|
||||||
|
|
||||||
|
### hooks.json using the reusable wrapper
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "startup",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start.sh"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" validate-bash.sh"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### "bash is not recognized"
|
### "bash is not recognized"
|
||||||
|
CMD can't find bash. The wrapper uses the full path `C:\Program Files\Git\bin\bash.exe`. If Git is installed elsewhere, update the path.
|
||||||
|
|
||||||
CMD couldn't find bash in any of the three locations the dispatcher tries. The dispatcher exits silently (0) rather than erroring, so the hook is skipped. Install Git for Windows at the standard path or ensure `bash` is on `PATH`.
|
### "cygpath: command not found" or "dirname: command not found"
|
||||||
|
Bash isn't running as a login shell. Ensure `-l` flag is used.
|
||||||
|
|
||||||
### Hook runs on Unix but does nothing on Windows
|
### Path has weird `\/` in it
|
||||||
|
`${CLAUDE_PLUGIN_ROOT}` expanded to a Windows path ending with backslash, then `/hooks/...` was appended. Use `cygpath` to convert the entire path.
|
||||||
|
|
||||||
Check that the script filename is **extensionless** in `hooks.json`. A command like `run-hook.cmd session-start.sh` can trigger Claude Code's `.sh` auto-detection and bypass the intended CMD dispatcher path, or just try to run a non-existent `session-start.sh` script.
|
### Script opens in text editor instead of running
|
||||||
|
The hooks.json is pointing directly to the `.sh` file. Point to the `.cmd` wrapper instead.
|
||||||
|
|
||||||
### Hook doesn't fire at all
|
### Works in terminal but not as hook
|
||||||
|
Claude Code may run hooks differently. Test by simulating the hook environment:
|
||||||
Verify the `matcher` in `hooks.json` matches the event type your harness emits. Claude Code uses `startup|clear|compact`; Codex uses `startup|resume|clear`. Check `hooks-codex.json` for the Codex variant.
|
```powershell
|
||||||
|
$env:CLAUDE_PLUGIN_ROOT = "C:\path\to\plugin"
|
||||||
|
cmd /c "C:\path\to\plugin\hooks\session-start.cmd"
|
||||||
|
```
|
||||||
|
|
||||||
## Related Issues
|
## Related Issues
|
||||||
|
|
||||||
- [anthropics/claude-code#9758](https://github.com/anthropics/claude-code/issues/9758) — `.sh` scripts open in editor on Windows
|
- [anthropics/claude-code#9758](https://github.com/anthropics/claude-code/issues/9758) - .sh scripts open in editor on Windows
|
||||||
- [anthropics/claude-code#3417](https://github.com/anthropics/claude-code/issues/3417) — Hooks don't work on Windows
|
- [anthropics/claude-code#3417](https://github.com/anthropics/claude-code/issues/3417) - Hooks don't work on Windows
|
||||||
|
- [anthropics/claude-code#6023](https://github.com/anthropics/claude-code/issues/6023) - CLAUDE_PROJECT_DIR not found
|
||||||
|
|||||||
211
scripts/lint-shell.sh
Executable file
211
scripts/lint-shell.sh
Executable file
@@ -0,0 +1,211 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Lint shell scripts in this repository.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/lint-shell.sh [--all] [--format] [--strict] [file ...]
|
||||||
|
#
|
||||||
|
# By default, runs ShellCheck and shell syntax checks on changed shell scripts.
|
||||||
|
# Use --format to format with shfmt before linting. Use --all for the full tracked
|
||||||
|
# baseline, or pass files explicitly to lint a smaller set.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "error: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_tool() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || die "required tool '$1' is not on PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_shell_file() {
|
||||||
|
local path="$1"
|
||||||
|
local first_line=""
|
||||||
|
|
||||||
|
[[ -f "$path" ]] || return 1
|
||||||
|
|
||||||
|
case "$path" in
|
||||||
|
*.sh)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
IFS= read -r first_line <"$path" || true
|
||||||
|
[[ "$first_line" =~ ^#!.*[/[:space:]](bash|dash|ksh|sh)([[:space:]]|$) ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_git_work_tree() {
|
||||||
|
git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|
||||||
|
|| die "run this from inside a git work tree, or pass files explicitly"
|
||||||
|
}
|
||||||
|
|
||||||
|
add_shell_file() {
|
||||||
|
local path
|
||||||
|
local existing
|
||||||
|
|
||||||
|
path="$1"
|
||||||
|
if ! is_shell_file "$path"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${#files[@]}" -gt 0 ]]; then
|
||||||
|
for existing in "${files[@]}"; do
|
||||||
|
if [[ "$existing" == "$path" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
files+=("$path")
|
||||||
|
}
|
||||||
|
|
||||||
|
collect_all_shell_files() {
|
||||||
|
local path
|
||||||
|
|
||||||
|
ensure_git_work_tree
|
||||||
|
|
||||||
|
while IFS= read -r -d '' path; do
|
||||||
|
add_shell_file "$path"
|
||||||
|
done < <(git ls-files -z)
|
||||||
|
}
|
||||||
|
|
||||||
|
collect_changed_shell_files() {
|
||||||
|
local path
|
||||||
|
|
||||||
|
ensure_git_work_tree
|
||||||
|
|
||||||
|
if git rev-parse --verify HEAD >/dev/null 2>&1; then
|
||||||
|
while IFS= read -r -d '' path; do
|
||||||
|
add_shell_file "$path"
|
||||||
|
done < <(git diff --name-only -z --diff-filter=ACMR HEAD)
|
||||||
|
|
||||||
|
while IFS= read -r -d '' path; do
|
||||||
|
add_shell_file "$path"
|
||||||
|
done < <(git diff --cached --name-only -z --diff-filter=ACMR)
|
||||||
|
else
|
||||||
|
collect_all_shell_files
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r -d '' path; do
|
||||||
|
add_shell_file "$path"
|
||||||
|
done < <(git ls-files --others --exclude-standard -z)
|
||||||
|
}
|
||||||
|
|
||||||
|
collect_requested_shell_files() {
|
||||||
|
local path
|
||||||
|
|
||||||
|
for path in "$@"; do
|
||||||
|
add_shell_file "$path"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
syntax_shell_for() {
|
||||||
|
local path="$1"
|
||||||
|
local first_line=""
|
||||||
|
|
||||||
|
IFS= read -r first_line <"$path" || true
|
||||||
|
|
||||||
|
case "$first_line" in
|
||||||
|
*"/sh"* | *" env sh"* | *"/dash"* | *" env dash"*)
|
||||||
|
printf 'sh'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'bash'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
run_syntax_checks() {
|
||||||
|
local file
|
||||||
|
local shell_name
|
||||||
|
|
||||||
|
for file in "$@"; do
|
||||||
|
shell_name="$(syntax_shell_for "$file")"
|
||||||
|
case "$shell_name" in
|
||||||
|
sh)
|
||||||
|
sh -n "$file"
|
||||||
|
;;
|
||||||
|
bash)
|
||||||
|
bash -n "$file"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "unsupported shell for syntax check: $shell_name"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
format=false
|
||||||
|
strict=false
|
||||||
|
all=false
|
||||||
|
requested_files=()
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--all)
|
||||||
|
all=true
|
||||||
|
;;
|
||||||
|
--format)
|
||||||
|
format=true
|
||||||
|
;;
|
||||||
|
--strict)
|
||||||
|
strict=true
|
||||||
|
;;
|
||||||
|
-h | --help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--)
|
||||||
|
shift
|
||||||
|
requested_files+=("$@")
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
die "unknown option: $1"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
requested_files+=("$1")
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
require_tool shellcheck
|
||||||
|
if [[ "$format" == true ]]; then
|
||||||
|
require_tool shfmt
|
||||||
|
fi
|
||||||
|
|
||||||
|
files=()
|
||||||
|
if [[ "${#requested_files[@]}" -gt 0 ]]; then
|
||||||
|
collect_requested_shell_files "${requested_files[@]}"
|
||||||
|
elif [[ "$all" == true ]]; then
|
||||||
|
collect_all_shell_files
|
||||||
|
else
|
||||||
|
collect_changed_shell_files
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${#files[@]}" -eq 0 ]]; then
|
||||||
|
echo "No shell files found."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$format" == true ]]; then
|
||||||
|
echo "Formatting ${#files[@]} shell files"
|
||||||
|
shfmt_args=(-i 2 -ci -bn)
|
||||||
|
shfmt "${shfmt_args[@]}" -w "${files[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Linting ${#files[@]} shell files"
|
||||||
|
|
||||||
|
shellcheck_args=(--severity=warning --external-sources --source-path=SCRIPTDIR)
|
||||||
|
if [[ "$strict" == true ]]; then
|
||||||
|
shellcheck_args+=("--enable=check-extra-masked-returns,check-set-e-suppressed,quote-safe-variables,deprecate-which,avoid-nullary-conditions")
|
||||||
|
fi
|
||||||
|
|
||||||
|
shellcheck "${shellcheck_args[@]}" "${files[@]}"
|
||||||
|
run_syntax_checks "${files[@]}"
|
||||||
@@ -107,23 +107,10 @@ if [[ -z "$OWNER_PID" || "$OWNER_PID" == "1" ]]; then
|
|||||||
OWNER_PID="$PPID"
|
OWNER_PID="$PPID"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Windows/MSYS2: Node.js cannot see POSIX PIDs from the MSYS2 namespace.
|
|
||||||
# Passing a PID node cannot verify causes server to log owner-pid-invalid
|
|
||||||
# and self-terminate at the 60-second lifecycle check. Clear it so the
|
|
||||||
# watchdog is disabled and the idle timeout becomes the only shutdown trigger.
|
|
||||||
case "${OSTYPE:-}" in
|
|
||||||
msys*|cygwin*|mingw*) OWNER_PID="" ;;
|
|
||||||
esac
|
|
||||||
if [[ -n "${MSYSTEM:-}" ]]; then
|
|
||||||
OWNER_PID=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Foreground mode for environments that reap detached/background processes.
|
# Foreground mode for environments that reap detached/background processes.
|
||||||
if [[ "$FOREGROUND" == "true" ]]; then
|
if [[ "$FOREGROUND" == "true" ]]; then
|
||||||
env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs &
|
echo "$$" > "$PID_FILE"
|
||||||
SERVER_PID=$!
|
env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs
|
||||||
echo "$SERVER_PID" > "$PID_FILE"
|
|
||||||
wait "$SERVER_PID"
|
|
||||||
exit $?
|
exit $?
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
179
tests/shell-lint/test-lint-shell.sh
Normal file
179
tests/shell-lint/test-lint-shell.sh
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
SCRIPT_UNDER_TEST="$REPO_ROOT/scripts/lint-shell.sh"
|
||||||
|
|
||||||
|
FAILURES=0
|
||||||
|
TEST_ROOT="$(mktemp -d)"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TEST_ROOT"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
pass() {
|
||||||
|
echo " [PASS] $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo " [FAIL] $1"
|
||||||
|
FAILURES=$((FAILURES + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
local haystack="$1"
|
||||||
|
local needle="$2"
|
||||||
|
local description="$3"
|
||||||
|
|
||||||
|
if printf '%s' "$haystack" | grep -Fq -- "$needle"; then
|
||||||
|
pass "$description"
|
||||||
|
else
|
||||||
|
fail "$description"
|
||||||
|
echo " expected to find: $needle"
|
||||||
|
echo " in:"
|
||||||
|
printf '%s\n' "$haystack" | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_not_contains() {
|
||||||
|
local haystack="$1"
|
||||||
|
local needle="$2"
|
||||||
|
local description="$3"
|
||||||
|
|
||||||
|
if printf '%s' "$haystack" | grep -Fq -- "$needle"; then
|
||||||
|
fail "$description"
|
||||||
|
echo " did not expect to find: $needle"
|
||||||
|
echo " in:"
|
||||||
|
printf '%s\n' "$haystack" | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
pass "$description"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_git_identity() {
|
||||||
|
local repo="$1"
|
||||||
|
|
||||||
|
git -C "$repo" config user.name "Test Bot"
|
||||||
|
git -C "$repo" config user.email "test@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_stub_tool() {
|
||||||
|
local path="$1"
|
||||||
|
local name="$2"
|
||||||
|
|
||||||
|
cat >"$path" <<EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
{
|
||||||
|
printf '${name}:'
|
||||||
|
for arg in "\$@"; do
|
||||||
|
printf ' <%s>' "\$arg"
|
||||||
|
done
|
||||||
|
printf '\n'
|
||||||
|
} >> "\$SUPERPOWERS_SHELL_LINT_TEST_LOG"
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod +x "$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
make_fixture_repo() {
|
||||||
|
local repo="$1"
|
||||||
|
|
||||||
|
git init -q -b main "$repo"
|
||||||
|
configure_git_identity "$repo"
|
||||||
|
|
||||||
|
mkdir -p "$repo/hooks"
|
||||||
|
cat >"$repo/tracked.sh" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "tracked"
|
||||||
|
EOF
|
||||||
|
cat >"$repo/hooks/session-start" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
echo "extensionless"
|
||||||
|
EOF
|
||||||
|
cat >"$repo/README.md" <<'EOF'
|
||||||
|
# Fixture
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "not a shell script"
|
||||||
|
```
|
||||||
|
EOF
|
||||||
|
cat >"$repo/untracked.sh" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "untracked"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
git -C "$repo" add tracked.sh hooks/session-start README.md
|
||||||
|
git -C "$repo" commit -q -m "fixture"
|
||||||
|
|
||||||
|
printf '\necho "changed"\n' >>"$repo/tracked.sh"
|
||||||
|
printf '\necho "changed extensionless"\n' >>"$repo/hooks/session-start"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_lint_shell() {
|
||||||
|
local repo="$1"
|
||||||
|
local fakebin="$2"
|
||||||
|
local log="$3"
|
||||||
|
shift 3
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$repo"
|
||||||
|
PATH="$fakebin:$PATH" \
|
||||||
|
SUPERPOWERS_SHELL_LINT_TEST_LOG="$log" \
|
||||||
|
bash "$SCRIPT_UNDER_TEST" "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Shell lint script tests"
|
||||||
|
|
||||||
|
fixture="$TEST_ROOT/repo"
|
||||||
|
fakebin="$TEST_ROOT/bin"
|
||||||
|
log="$TEST_ROOT/tool.log"
|
||||||
|
mkdir -p "$fixture" "$fakebin"
|
||||||
|
: >"$log"
|
||||||
|
write_stub_tool "$fakebin/shellcheck" "shellcheck"
|
||||||
|
write_stub_tool "$fakebin/shfmt" "shfmt"
|
||||||
|
make_fixture_repo "$fixture"
|
||||||
|
|
||||||
|
if output="$(run_lint_shell "$fixture" "$fakebin" "$log" 2>&1)"; then
|
||||||
|
pass "lint-shell check mode exits successfully with stub tools"
|
||||||
|
else
|
||||||
|
fail "lint-shell check mode exits successfully with stub tools"
|
||||||
|
printf '%s\n' "$output" | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
|
||||||
|
tool_log="$(cat "$log")"
|
||||||
|
assert_contains "$output" "Linting 3 shell files" "reports changed shell file count"
|
||||||
|
assert_not_contains "$tool_log" "shfmt:" "does not run shfmt in lint mode"
|
||||||
|
assert_contains "$tool_log" "shellcheck:" "runs ShellCheck"
|
||||||
|
assert_contains "$tool_log" "<--severity=warning>" "uses warning severity as the baseline"
|
||||||
|
assert_contains "$tool_log" "<--external-sources>" "allows ShellCheck to follow sourced files"
|
||||||
|
assert_contains "$tool_log" "<--source-path=SCRIPTDIR>" "resolves ShellCheck sources relative to each script"
|
||||||
|
assert_contains "$tool_log" "<hooks/session-start>" "includes changed extensionless shell shebang file"
|
||||||
|
assert_contains "$tool_log" "<tracked.sh>" "includes changed tracked .sh file"
|
||||||
|
assert_contains "$tool_log" "<untracked.sh>" "includes untracked shell files by default"
|
||||||
|
assert_not_contains "$tool_log" "README.md" "ignores Markdown with shell snippets"
|
||||||
|
|
||||||
|
: >"$log"
|
||||||
|
if output="$(run_lint_shell "$fixture" "$fakebin" "$log" --all --format 2>&1)"; then
|
||||||
|
pass "lint-shell --format exits successfully with stub tools"
|
||||||
|
else
|
||||||
|
fail "lint-shell --format exits successfully with stub tools"
|
||||||
|
printf '%s\n' "$output" | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
|
||||||
|
tool_log="$(cat "$log")"
|
||||||
|
assert_contains "$tool_log" "<-w>" "uses shfmt write mode with --format"
|
||||||
|
assert_contains "$tool_log" "shellcheck:" "runs ShellCheck after --format"
|
||||||
|
assert_contains "$tool_log" "<--severity=warning>" "keeps warning severity after --format"
|
||||||
|
assert_contains "$tool_log" "<hooks/session-start>" "--all includes tracked extensionless shell shebang file"
|
||||||
|
assert_contains "$tool_log" "<tracked.sh>" "--all includes tracked .sh file"
|
||||||
|
assert_not_contains "$tool_log" "untracked.sh" "--all ignores untracked shell files"
|
||||||
|
|
||||||
|
if [[ "$FAILURES" -eq 0 ]]; then
|
||||||
|
echo "All shell lint script tests passed"
|
||||||
|
else
|
||||||
|
echo "$FAILURES shell lint script test(s) failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user