summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-18 12:42:02 +0300
committerPaul Buetow <paul@buetow.org>2026-07-18 12:42:02 +0300
commit42036d5ec36160a9caa6de64f9476726a5386a69 (patch)
treecea966e9683dc5adc967912341b9847abd40dfed
parent3c35381b71f992eb7a86552697ea647bb76b68b1 (diff)
Support FreeBSD and macOS via resolved GNU tool variables
shuriken relied on GNU-only extensions (find -printf, stat -c, cp -a, sort -R) and hard-refused to run outside Linux. Resolve each of the four tools once at startup to a FIND/STAT/CP/SORT variable, preferring a g-prefixed sibling (gfind, gstat, gcp, gsort) when present on PATH - mirroring the $SED/$GREP/$DATE tool-selection pattern in the sibling gemtexter project - so the same source runs unmodified on Linux, macOS, and FreeBSD once GNU coreutils/findutils are installed there. - src/lib/compat.source.sh: add resolve_gnu_tool + FIND/STAT/CP/SORT; add a fast verify_gnu_tool_versions preflight (--version contains "GNU") ahead of the existing behavioral probes, which now run against the resolved variables instead of hardcoded tool names; split the probes and error reporting into their own ~30-line functions. - Replace the GNU-only find -printf / stat -c / cp -a / sort -R call sites in photo-list, image, album-photo-select, metadata-cache, config.staging, and random source files with the resolved variables. POSIX-portable find/sort calls elsewhere are untouched. - README.md: drop the "Linux-only" claim; document brew/pkg GNU coreutils+findutils install steps for macOS and FreeBSD. - tests/cli.sh: add coverage for the g-prefixed-sibling preference and for the new --version-string preflight's error message, alongside the existing behavioral-probe guard tests. just build / just check-generated / just shellcheck / just test / git diff --check all pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
-rw-r--r--README.md53
-rwxr-xr-xbin/shuriken164
-rw-r--r--src/lib/album-photo-select.source.sh4
-rw-r--r--src/lib/compat.source.sh134
-rw-r--r--src/lib/config.staging.source.sh4
-rw-r--r--src/lib/image.source.sh8
-rw-r--r--src/lib/metadata-cache.source.sh4
-rw-r--r--src/lib/photo-list.source.sh6
-rw-r--r--src/lib/random.source.sh4
-rwxr-xr-xtests/cli.sh93
10 files changed, 394 insertions, 80 deletions
diff --git a/README.md b/README.md
index 8e9eca3..055e3e3 100644
--- a/README.md
+++ b/README.md
@@ -8,13 +8,46 @@ JavaScript.
## Platform compatibility
-shuriken relies on **GNU** versions of the standard Unix tools. Specifically,
-it uses GNU-only extensions such as `find -printf`, `stat -c`, `cp -a`, and
-`sort -R`, which are not supported by the BSD variants shipped with macOS and
-BSD systems. As a result shuriken is currently **Linux-only** and will not run
-unmodified on macOS or BSD-based systems (including the stock tools that ship
-with macOS). If you are on macOS, run shuriken inside a Linux container or VM
-instead.
+shuriken relies on **GNU** versions of four standard Unix tools: it uses the
+GNU-only extensions `find -printf`, `stat -c`, `cp -a`, and `sort -R`, which
+are not supported by the BSD variants of those tools shipped with macOS and
+FreeBSD. shuriken runs on Linux, macOS, and FreeBSD: at startup it resolves
+each of the four tools to whichever binary is actually GNU, preferring a
+`g`-prefixed sibling (`gfind`, `gstat`, `gcp`, `gsort`) over the plain name
+when one is on `PATH` (mirroring the tool-selection variables used by the
+sibling [gemtexter](https://codeberg.org/snonux/gemtexter) project). On Linux
+the plain names are already GNU, so nothing extra is required. On macOS and
+FreeBSD you must install GNU coreutils/findutils first — shuriken verifies at
+startup that the resolved tools are genuinely GNU and exits with a clear error
+naming the missing tool otherwise, rather than failing confusingly deep inside
+generation.
+
+### Installing GNU coreutils on macOS
+
+Via [Homebrew](https://brew.sh):
+
+```sh
+brew install coreutils findutils
+```
+
+Homebrew installs these under their GNU names prefixed with `g`
+(`gstat`, `gcp`, `gsort` from `coreutils`; `gfind` from `findutils`) so they
+do not clobber the system BSD tools of the same bare name; shuriken picks them
+up automatically. (`brew install gnu-sed grep` are not required by shuriken
+today, but are commonly installed alongside for other GNU-reliant scripts.)
+
+### Installing GNU coreutils on FreeBSD
+
+Via `pkg`:
+
+```sh
+pkg install coreutils findutils
+```
+
+This installs the GNU tools under their `g`-prefixed names (`gstat`, `gcp`,
+`gsort`, `gfind`) alongside the base-system BSD tools, which shuriken picks up
+automatically. (`pkg install gsed gnugrep` are not required by shuriken today
+but provide `gsed`/`ggrep` for other GNU-reliant scripts.)
## Example site
@@ -43,9 +76,9 @@ shuriken --clean # remove ./dist and leftover staging dirs
```
ImageMagick (`magick` or `convert`) and Bash 5.1 or newer are required.
-GNU coreutils/findutils (the default `find`, `stat`, `cp`, and `sort` on a
-Linux distribution) are also required; the BSD/macOS equivalents are not
-sufficient (see *Platform compatibility* above).
+GNU coreutils/findutils (`find`, `stat`, `cp`, and `sort`) are also required;
+on Linux the default tools already are GNU, on macOS/FreeBSD install the
+`g`-prefixed GNU versions first (see *Platform compatibility* above).
## Main flags
diff --git a/bin/shuriken b/bin/shuriken
index a475a3a..5b8f974 100755
--- a/bin/shuriken
+++ b/bin/shuriken
@@ -115,37 +115,113 @@ log_warning() {
}
# Inlined from src/lib/compat.source.sh
-# Runtime compatibility guard: verify the standard Unix tools shuriken shells
-# out to are the GNU variants. shuriken relies on GNU-only extensions -- find
-# -printf, stat -c, cp -a, and sort -R -- that the BSD tools shipped with macOS
-# and BSD systems do not provide (see the "Platform compatibility" section of
-# README.md). require_gnu_tools probes each feature in a throwaway temp dir and
-# exits with a clear error naming the offending tool if any probe fails, so the
-# check runs before any real work begins (it is called from main, after CLI
-# parsing and before run_action).
+# Cross-platform tool resolution and runtime compatibility guard.
#
-# The probes intentionally exercise the exact GNU-only behavior the codebase
-# depends on rather than parsing --help text (which is unstable across
-# implementations), so a tool that lacks the feature fails the probe regardless
-# of its version string.
+# shuriken relies on GNU-only extensions of four standard Unix tools -- find
+# -printf, stat -c, cp -a, and sort -R (see the "Platform compatibility"
+# section of README.md). Those extensions are not supported by the BSD
+# variants of the same tools shipped with macOS and FreeBSD. Rather than being
+# Linux-only, shuriken resolves each of the four tools to a variable (FIND,
+# STAT, CP, SORT) once at startup: on Linux the plain command is already GNU,
+# and on macOS/FreeBSD the GNU version is normally installed side-by-side
+# under a "g" prefix (gfind, gstat, gcp, gsort, via Homebrew/pkg -- see
+# README.md) to avoid clobbering the system tools. This mirrors the
+# $SED/$GREP/$DATE tool-selection variables in the sibling gemtexter project
+# (github.com/snonux/gemtexter). Every call site elsewhere in the codebase
+# that depends on the GNU-only behavior uses these variables instead of the
+# bare command name; call sites that only need POSIX-portable behavior are
+# left as plain "find"/"sort"/etc and are unaffected by any of this.
+#
+# require_gnu_tools (called from main, after CLI parsing and before
+# run_action) verifies the resolved tools are genuinely GNU before any real
+# work begins, so a misconfigured host fails fast with a clear error instead
+# of failing confusingly deep inside generation.
+
+# Resolve the binary name for a single bare coreutils/findutils tool name:
+# the GNU-prefixed variant (e.g. "gfind" for "find") if one is on PATH,
+# otherwise the bare name unchanged. On Linux a "g"-prefixed sibling is not
+# normally installed, so this resolves to the bare (already-GNU) name; on
+# macOS/FreeBSD it picks up the Homebrew/pkg-installed GNU version. No uname
+# check is needed: preferring the g-prefixed binary when present is correct
+# on every platform.
+resolve_gnu_tool() {
+ local -r bare_name="$1"
+
+ if command -v "g$bare_name" >/dev/null 2>&1; then
+ printf '%s\n' "g$bare_name"
+ else
+ printf '%s\n' "$bare_name"
+ fi
+}
+
+FIND=$(resolve_gnu_tool find)
+STAT=$(resolve_gnu_tool stat)
+CP=$(resolve_gnu_tool cp)
+SORT=$(resolve_gnu_tool sort)
+readonly FIND STAT CP SORT
+
+# Quick preflight: confirm each resolved tool self-reports as GNU before
+# running the (slower) behavioral probes in require_gnu_tools. This gives a
+# fast, specific error on a genuine BSD/macOS host that has no g-prefixed
+# tools installed at all, naming exactly which tool and pointing at the
+# README install instructions, rather than only failing on the deeper
+# behavioral probe below. A tool whose --version output happens to lie (or
+# omit a version string) still falls through to the behavioral probe, which
+# is authoritative.
+verify_gnu_tool_versions() {
+ local -r tool="$1"
+ local -r label="$2"
+ local version_output
+
+ version_output=$("$tool" --version 2>/dev/null || true)
+ if [[ "$version_output" != *GNU* ]]; then
+ printf 'ERROR: "%s" (%s) does not report itself as GNU.\n' \
+ "$tool" "$label" >&2
+ printf 'ERROR: Install GNU coreutils/findutils -- see the\n' >&2
+ printf 'ERROR: "Platform compatibility" section of README.md.\n' >&2
+ return 1
+ fi
+}
+# Runtime compatibility guard: verify the resolved tools (FIND/STAT/CP/SORT)
+# actually behave like the GNU variants shuriken depends on. Each probe
+# exercises the exact GNU-only behavior the codebase relies on (rather than
+# parsing --help/--version text, which is unstable across implementations),
+# so a tool that merely claims to be GNU but lacks the feature still fails
+# here. Called from main, after CLI parsing and before run_action, so the
+# check runs before any real work begins.
require_gnu_tools() {
local probe_dir
- local probe_out
- local failed=''
+
+ verify_gnu_tool_versions "$FIND" find || return 1
+ verify_gnu_tool_versions "$STAT" stat || return 1
+ verify_gnu_tool_versions "$CP" cp || return 1
+ verify_gnu_tool_versions "$SORT" sort || return 1
probe_dir=$(mktemp -d 2>/dev/null) || return 1
# shellcheck disable=SC2064 # expand probe_dir now, clean up on any return
trap "rm -rf '$probe_dir'" RETURN
+ probe_gnu_tool_behavior "$probe_dir"
+}
+
+# The behavioral half of require_gnu_tools, split out to keep each function
+# under ~30 lines (per repo convention): probes FIND/STAT/CP/SORT's actual
+# GNU-only behavior in a throwaway temp dir and reports via
+# report_gnu_tool_failure on the first failure.
+probe_gnu_tool_behavior() {
+ local -r probe_dir="$1"; shift
+ local probe_out
+ local failed=''
+
# GNU find supports the -printf action; BSD/macOS find does not.
- probe_out=$(find "$probe_dir" -maxdepth 0 -printf '%f\n' 2>/dev/null) \
+ probe_out=$("$FIND" "$probe_dir" -maxdepth 0 -printf '%f\n' 2>/dev/null) \
&& [ -n "$probe_out" ] || failed='find (missing the -printf action)'
# GNU stat uses -c FORMAT; BSD/macOS stat uses -f and rejects -c.
if [ -z "$failed" ]; then
printf 'probe\n' > "$probe_dir/file"
- probe_out=$(stat -c '%s' "$probe_dir/file" 2>/dev/null) \
+ probe_out=$("$STAT" -c '%s' "$probe_dir/file" 2>/dev/null) \
&& [[ "$probe_out" =~ ^[0-9]+$ ]] \
|| failed='stat (missing the -c option)'
fi
@@ -154,7 +230,7 @@ require_gnu_tools() {
if [ -z "$failed" ]; then
mkdir "$probe_dir/src"
printf 'x\n' > "$probe_dir/src/inner"
- if ! cp -a "$probe_dir/src" "$probe_dir/dest" 2>/dev/null \
+ if ! "$CP" -a "$probe_dir/src" "$probe_dir/dest" 2>/dev/null \
|| [ ! -f "$probe_dir/dest/inner" ]; then
failed='cp (missing the -a option)'
fi
@@ -162,19 +238,25 @@ require_gnu_tools() {
# GNU sort supports -R (random shuffle); BSD sort lacks it.
if [ -z "$failed" ]; then
- probe_out=$(printf 'a\nb\nc\n' | sort -R 2>/dev/null) \
+ probe_out=$(printf 'a\nb\nc\n' | "$SORT" -R 2>/dev/null) \
&& [ -n "$probe_out" ] || failed='sort (missing the -R option)'
fi
- if [ -n "$failed" ]; then
- printf 'ERROR: shuriken requires the GNU versions of the standard Unix\n' >&2
- printf 'ERROR: tools (GNU coreutils/findutils). Non-GNU or unsupported: %s\n' \
- "$failed" >&2
- printf 'ERROR: shuriken is Linux-only and will not run on macOS or BSD.\n' >&2
- return 1
- fi
+ [ -z "$failed" ] || report_gnu_tool_failure "$failed"
+}
- return 0
+# Shared error reporter for require_gnu_tools/probe_gnu_tool_behavior: prints
+# the clear "install GNU tools" message naming the offending tool/feature and
+# returns 1 (never exits directly, so callers stay in control of unwinding).
+report_gnu_tool_failure() {
+ local -r failed="$1"; shift
+
+ printf 'ERROR: shuriken requires the GNU versions of the standard Unix\n' >&2
+ printf 'ERROR: tools (GNU coreutils/findutils). Non-GNU or unsupported: %s\n' \
+ "$failed" >&2
+ printf 'ERROR: On macOS/FreeBSD, install GNU coreutils/findutils (see the\n' >&2
+ printf 'ERROR: "Platform compatibility" section of README.md) and retry.\n' >&2
+ return 1
}
# Inlined from src/lib/bootstrap.source.sh
@@ -1741,11 +1823,14 @@ is_supported_image_file() {
incoming_image_files() {
local file
+ # $FIND (compat.source.sh): -printf is a GNU-only action, so this must run
+ # against the resolved GNU find, not whatever "find" plain resolves to on
+ # macOS/FreeBSD. The trailing sort is POSIX-portable and left as-is.
while IFS= read -r file; do
if is_supported_image_file "$file"; then
printf '%s\n' "$file"
fi
- done < <(find "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n') \
+ done < <("$FIND" "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n') \
| sort
}
@@ -1790,11 +1875,12 @@ count_tree_files() {
warn_unsupported_incoming_files() {
local file
+ # $FIND (compat.source.sh): -printf is a GNU-only action.
while IFS= read -r file; do
if ! is_supported_image_file "$file"; then
log_warning "Ignoring unsupported incoming file: $file"
fi
- done < <(find "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n' | sort)
+ done < <("$FIND" "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n' | sort)
}
scalephotos() {
@@ -1912,9 +1998,11 @@ maybe_shuffle() {
if random_seed_is_set; then
deterministic_shuffle
else
- sort -R
+ # $SORT (compat.source.sh): -R (random shuffle) is GNU-only.
+ "$SORT" -R
fi
else
+ # Plain sort here is POSIX-portable; no GNU-only flag involved.
sort
fi
}
@@ -1945,11 +2033,13 @@ maybe_shuffle() {
# suppressed (the stats background loader) adds its own 2>/dev/null. This is the
# SORTED listing; album_photo_files deliberately keeps its own maybe_shuffle
# variant because the album's display order is the configurable shuffle, not a
-# plain sort.
+# plain sort. Uses $FIND (compat.source.sh): -printf is GNU-only, so this must
+# run against the resolved GNU find, not whatever "find" plain resolves to on
+# macOS/FreeBSD. The trailing plain "sort" is POSIX-portable and left as-is.
list_photos() {
local -r photos_dir="$1"; shift
- find "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' | sort
+ "$FIND" "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' | sort
}
# Pick one seeded-random entry from an already-collected list, addressed by
@@ -2063,12 +2153,14 @@ exif_cache_dir() {
# Build the cache signature line ("<photo>:<size>:<mtime>") used to decide
# whether a cache entry is still valid for the source file. Kept private to this
# module alongside its only consumers, plus the stats test that pre-seeds caches.
+# Uses $STAT (compat.source.sh): -c FORMAT is GNU-only (BSD/macOS stat uses -f
+# and rejects -c), so this must run against the resolved GNU stat.
photo_cache_signature() {
local -r photo="$1"; shift
local -r photo_path="$1"; shift
local stat_output
- stat_output=$(stat -c '%s:%Y' "$photo_path")
+ stat_output=$("$STAT" -c '%s:%Y' "$photo_path")
printf '%s:%s\n' "$photo" "$stat_output"
}
@@ -3416,11 +3508,11 @@ build_preview_thumbnail() {
# Unlike the other photo listings this one keeps its own find rather than using
# list_photos (photo-list.source.sh): it pipes through maybe_shuffle, not sort,
# because the album's display order is the configurable (seeded) shuffle, not a
-# plain sort.
+# plain sort. Uses $FIND (compat.source.sh) since -printf is a GNU-only action.
album_photo_files() {
local -r photos_dir="$1"; shift
- find "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' \
+ "$FIND" "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' \
| maybe_shuffle
}
@@ -6195,12 +6287,14 @@ prepare_generation_staging_dir() {
return
fi
+ # $CP (compat.source.sh): -a (archive mode) is not supported by minimal/older
+ # BSD cp variants, so this must run against the resolved GNU cp.
for cache_dir in photos thumbs blurs; do
if [ -d "$final_dist/$cache_dir" ]; then
if ! mkdir -p "$staging_dir/$cache_dir"; then
return 1
fi
- if ! cp -a "$final_dist/$cache_dir/." "$staging_dir/$cache_dir/"; then
+ if ! "$CP" -a "$final_dist/$cache_dir/." "$staging_dir/$cache_dir/"; then
return 1
fi
fi
diff --git a/src/lib/album-photo-select.source.sh b/src/lib/album-photo-select.source.sh
index 06190f7..ea661b4 100644
--- a/src/lib/album-photo-select.source.sh
+++ b/src/lib/album-photo-select.source.sh
@@ -12,11 +12,11 @@
# Unlike the other photo listings this one keeps its own find rather than using
# list_photos (photo-list.source.sh): it pipes through maybe_shuffle, not sort,
# because the album's display order is the configurable (seeded) shuffle, not a
-# plain sort.
+# plain sort. Uses $FIND (compat.source.sh) since -printf is a GNU-only action.
album_photo_files() {
local -r photos_dir="$1"; shift
- find "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' \
+ "$FIND" "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' \
| maybe_shuffle
}
diff --git a/src/lib/compat.source.sh b/src/lib/compat.source.sh
index 718c7ed..63111fd 100644
--- a/src/lib/compat.source.sh
+++ b/src/lib/compat.source.sh
@@ -1,34 +1,110 @@
-# Runtime compatibility guard: verify the standard Unix tools shuriken shells
-# out to are the GNU variants. shuriken relies on GNU-only extensions -- find
-# -printf, stat -c, cp -a, and sort -R -- that the BSD tools shipped with macOS
-# and BSD systems do not provide (see the "Platform compatibility" section of
-# README.md). require_gnu_tools probes each feature in a throwaway temp dir and
-# exits with a clear error naming the offending tool if any probe fails, so the
-# check runs before any real work begins (it is called from main, after CLI
-# parsing and before run_action).
+# Cross-platform tool resolution and runtime compatibility guard.
#
-# The probes intentionally exercise the exact GNU-only behavior the codebase
-# depends on rather than parsing --help text (which is unstable across
-# implementations), so a tool that lacks the feature fails the probe regardless
-# of its version string.
+# shuriken relies on GNU-only extensions of four standard Unix tools -- find
+# -printf, stat -c, cp -a, and sort -R (see the "Platform compatibility"
+# section of README.md). Those extensions are not supported by the BSD
+# variants of the same tools shipped with macOS and FreeBSD. Rather than being
+# Linux-only, shuriken resolves each of the four tools to a variable (FIND,
+# STAT, CP, SORT) once at startup: on Linux the plain command is already GNU,
+# and on macOS/FreeBSD the GNU version is normally installed side-by-side
+# under a "g" prefix (gfind, gstat, gcp, gsort, via Homebrew/pkg -- see
+# README.md) to avoid clobbering the system tools. This mirrors the
+# $SED/$GREP/$DATE tool-selection variables in the sibling gemtexter project
+# (github.com/snonux/gemtexter). Every call site elsewhere in the codebase
+# that depends on the GNU-only behavior uses these variables instead of the
+# bare command name; call sites that only need POSIX-portable behavior are
+# left as plain "find"/"sort"/etc and are unaffected by any of this.
+#
+# require_gnu_tools (called from main, after CLI parsing and before
+# run_action) verifies the resolved tools are genuinely GNU before any real
+# work begins, so a misconfigured host fails fast with a clear error instead
+# of failing confusingly deep inside generation.
+
+# Resolve the binary name for a single bare coreutils/findutils tool name:
+# the GNU-prefixed variant (e.g. "gfind" for "find") if one is on PATH,
+# otherwise the bare name unchanged. On Linux a "g"-prefixed sibling is not
+# normally installed, so this resolves to the bare (already-GNU) name; on
+# macOS/FreeBSD it picks up the Homebrew/pkg-installed GNU version. No uname
+# check is needed: preferring the g-prefixed binary when present is correct
+# on every platform.
+resolve_gnu_tool() {
+ local -r bare_name="$1"
+
+ if command -v "g$bare_name" >/dev/null 2>&1; then
+ printf '%s\n' "g$bare_name"
+ else
+ printf '%s\n' "$bare_name"
+ fi
+}
+
+FIND=$(resolve_gnu_tool find)
+STAT=$(resolve_gnu_tool stat)
+CP=$(resolve_gnu_tool cp)
+SORT=$(resolve_gnu_tool sort)
+readonly FIND STAT CP SORT
+# Quick preflight: confirm each resolved tool self-reports as GNU before
+# running the (slower) behavioral probes in require_gnu_tools. This gives a
+# fast, specific error on a genuine BSD/macOS host that has no g-prefixed
+# tools installed at all, naming exactly which tool and pointing at the
+# README install instructions, rather than only failing on the deeper
+# behavioral probe below. A tool whose --version output happens to lie (or
+# omit a version string) still falls through to the behavioral probe, which
+# is authoritative.
+verify_gnu_tool_versions() {
+ local -r tool="$1"
+ local -r label="$2"
+ local version_output
+
+ version_output=$("$tool" --version 2>/dev/null || true)
+ if [[ "$version_output" != *GNU* ]]; then
+ printf 'ERROR: "%s" (%s) does not report itself as GNU.\n' \
+ "$tool" "$label" >&2
+ printf 'ERROR: Install GNU coreutils/findutils -- see the\n' >&2
+ printf 'ERROR: "Platform compatibility" section of README.md.\n' >&2
+ return 1
+ fi
+}
+
+# Runtime compatibility guard: verify the resolved tools (FIND/STAT/CP/SORT)
+# actually behave like the GNU variants shuriken depends on. Each probe
+# exercises the exact GNU-only behavior the codebase relies on (rather than
+# parsing --help/--version text, which is unstable across implementations),
+# so a tool that merely claims to be GNU but lacks the feature still fails
+# here. Called from main, after CLI parsing and before run_action, so the
+# check runs before any real work begins.
require_gnu_tools() {
local probe_dir
- local probe_out
- local failed=''
+
+ verify_gnu_tool_versions "$FIND" find || return 1
+ verify_gnu_tool_versions "$STAT" stat || return 1
+ verify_gnu_tool_versions "$CP" cp || return 1
+ verify_gnu_tool_versions "$SORT" sort || return 1
probe_dir=$(mktemp -d 2>/dev/null) || return 1
# shellcheck disable=SC2064 # expand probe_dir now, clean up on any return
trap "rm -rf '$probe_dir'" RETURN
+ probe_gnu_tool_behavior "$probe_dir"
+}
+
+# The behavioral half of require_gnu_tools, split out to keep each function
+# under ~30 lines (per repo convention): probes FIND/STAT/CP/SORT's actual
+# GNU-only behavior in a throwaway temp dir and reports via
+# report_gnu_tool_failure on the first failure.
+probe_gnu_tool_behavior() {
+ local -r probe_dir="$1"; shift
+ local probe_out
+ local failed=''
+
# GNU find supports the -printf action; BSD/macOS find does not.
- probe_out=$(find "$probe_dir" -maxdepth 0 -printf '%f\n' 2>/dev/null) \
+ probe_out=$("$FIND" "$probe_dir" -maxdepth 0 -printf '%f\n' 2>/dev/null) \
&& [ -n "$probe_out" ] || failed='find (missing the -printf action)'
# GNU stat uses -c FORMAT; BSD/macOS stat uses -f and rejects -c.
if [ -z "$failed" ]; then
printf 'probe\n' > "$probe_dir/file"
- probe_out=$(stat -c '%s' "$probe_dir/file" 2>/dev/null) \
+ probe_out=$("$STAT" -c '%s' "$probe_dir/file" 2>/dev/null) \
&& [[ "$probe_out" =~ ^[0-9]+$ ]] \
|| failed='stat (missing the -c option)'
fi
@@ -37,7 +113,7 @@ require_gnu_tools() {
if [ -z "$failed" ]; then
mkdir "$probe_dir/src"
printf 'x\n' > "$probe_dir/src/inner"
- if ! cp -a "$probe_dir/src" "$probe_dir/dest" 2>/dev/null \
+ if ! "$CP" -a "$probe_dir/src" "$probe_dir/dest" 2>/dev/null \
|| [ ! -f "$probe_dir/dest/inner" ]; then
failed='cp (missing the -a option)'
fi
@@ -45,17 +121,23 @@ require_gnu_tools() {
# GNU sort supports -R (random shuffle); BSD sort lacks it.
if [ -z "$failed" ]; then
- probe_out=$(printf 'a\nb\nc\n' | sort -R 2>/dev/null) \
+ probe_out=$(printf 'a\nb\nc\n' | "$SORT" -R 2>/dev/null) \
&& [ -n "$probe_out" ] || failed='sort (missing the -R option)'
fi
- if [ -n "$failed" ]; then
- printf 'ERROR: shuriken requires the GNU versions of the standard Unix\n' >&2
- printf 'ERROR: tools (GNU coreutils/findutils). Non-GNU or unsupported: %s\n' \
- "$failed" >&2
- printf 'ERROR: shuriken is Linux-only and will not run on macOS or BSD.\n' >&2
- return 1
- fi
+ [ -z "$failed" ] || report_gnu_tool_failure "$failed"
+}
+
+# Shared error reporter for require_gnu_tools/probe_gnu_tool_behavior: prints
+# the clear "install GNU tools" message naming the offending tool/feature and
+# returns 1 (never exits directly, so callers stay in control of unwinding).
+report_gnu_tool_failure() {
+ local -r failed="$1"; shift
- return 0
+ printf 'ERROR: shuriken requires the GNU versions of the standard Unix\n' >&2
+ printf 'ERROR: tools (GNU coreutils/findutils). Non-GNU or unsupported: %s\n' \
+ "$failed" >&2
+ printf 'ERROR: On macOS/FreeBSD, install GNU coreutils/findutils (see the\n' >&2
+ printf 'ERROR: "Platform compatibility" section of README.md) and retry.\n' >&2
+ return 1
}
diff --git a/src/lib/config.staging.source.sh b/src/lib/config.staging.source.sh
index b76da58..e48f45b 100644
--- a/src/lib/config.staging.source.sh
+++ b/src/lib/config.staging.source.sh
@@ -29,12 +29,14 @@ prepare_generation_staging_dir() {
return
fi
+ # $CP (compat.source.sh): -a (archive mode) is not supported by minimal/older
+ # BSD cp variants, so this must run against the resolved GNU cp.
for cache_dir in photos thumbs blurs; do
if [ -d "$final_dist/$cache_dir" ]; then
if ! mkdir -p "$staging_dir/$cache_dir"; then
return 1
fi
- if ! cp -a "$final_dist/$cache_dir/." "$staging_dir/$cache_dir/"; then
+ if ! "$CP" -a "$final_dist/$cache_dir/." "$staging_dir/$cache_dir/"; then
return 1
fi
fi
diff --git a/src/lib/image.source.sh b/src/lib/image.source.sh
index 32d1362..34e215a 100644
--- a/src/lib/image.source.sh
+++ b/src/lib/image.source.sh
@@ -45,11 +45,14 @@ is_supported_image_file() {
incoming_image_files() {
local file
+ # $FIND (compat.source.sh): -printf is a GNU-only action, so this must run
+ # against the resolved GNU find, not whatever "find" plain resolves to on
+ # macOS/FreeBSD. The trailing sort is POSIX-portable and left as-is.
while IFS= read -r file; do
if is_supported_image_file "$file"; then
printf '%s\n' "$file"
fi
- done < <(find "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n') \
+ done < <("$FIND" "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n') \
| sort
}
@@ -94,11 +97,12 @@ count_tree_files() {
warn_unsupported_incoming_files() {
local file
+ # $FIND (compat.source.sh): -printf is a GNU-only action.
while IFS= read -r file; do
if ! is_supported_image_file "$file"; then
log_warning "Ignoring unsupported incoming file: $file"
fi
- done < <(find "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n' | sort)
+ done < <("$FIND" "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n' | sort)
}
scalephotos() {
diff --git a/src/lib/metadata-cache.source.sh b/src/lib/metadata-cache.source.sh
index 41ca8d5..971fea8 100644
--- a/src/lib/metadata-cache.source.sh
+++ b/src/lib/metadata-cache.source.sh
@@ -29,12 +29,14 @@ exif_cache_dir() {
# Build the cache signature line ("<photo>:<size>:<mtime>") used to decide
# whether a cache entry is still valid for the source file. Kept private to this
# module alongside its only consumers, plus the stats test that pre-seeds caches.
+# Uses $STAT (compat.source.sh): -c FORMAT is GNU-only (BSD/macOS stat uses -f
+# and rejects -c), so this must run against the resolved GNU stat.
photo_cache_signature() {
local -r photo="$1"; shift
local -r photo_path="$1"; shift
local stat_output
- stat_output=$(stat -c '%s:%Y' "$photo_path")
+ stat_output=$("$STAT" -c '%s:%Y' "$photo_path")
printf '%s:%s\n' "$photo" "$stat_output"
}
diff --git a/src/lib/photo-list.source.sh b/src/lib/photo-list.source.sh
index 9e7ce72..754e09e 100644
--- a/src/lib/photo-list.source.sh
+++ b/src/lib/photo-list.source.sh
@@ -23,11 +23,13 @@
# suppressed (the stats background loader) adds its own 2>/dev/null. This is the
# SORTED listing; album_photo_files deliberately keeps its own maybe_shuffle
# variant because the album's display order is the configurable shuffle, not a
-# plain sort.
+# plain sort. Uses $FIND (compat.source.sh): -printf is GNU-only, so this must
+# run against the resolved GNU find, not whatever "find" plain resolves to on
+# macOS/FreeBSD. The trailing plain "sort" is POSIX-portable and left as-is.
list_photos() {
local -r photos_dir="$1"; shift
- find "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' | sort
+ "$FIND" "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' | sort
}
# Pick one seeded-random entry from an already-collected list, addressed by
diff --git a/src/lib/random.source.sh b/src/lib/random.source.sh
index 5a1ef59..ca5c7d3 100644
--- a/src/lib/random.source.sh
+++ b/src/lib/random.source.sh
@@ -66,9 +66,11 @@ maybe_shuffle() {
if random_seed_is_set; then
deterministic_shuffle
else
- sort -R
+ # $SORT (compat.source.sh): -R (random shuffle) is GNU-only.
+ "$SORT" -R
fi
else
+ # Plain sort here is POSIX-portable; no GNU-only flag involved.
sort
fi
}
diff --git a/tests/cli.sh b/tests/cli.sh
index 52952d5..c248b4c 100755
--- a/tests/cli.sh
+++ b/tests/cli.sh
@@ -6782,6 +6782,93 @@ FAKE
test::teardown
}
+# resolve_gnu_tool (src/lib/compat.source.sh) must prefer a "g"-prefixed
+# sibling over a broken plain-named tool, the macOS/FreeBSD layout produced by
+# `brew install coreutils findutils` / `pkg install coreutils findutils`
+# (see README.md's "Platform compatibility" section): the GNU tool is
+# installed as "gfind" alongside the system's own (non-GNU) "find" rather than
+# replacing it. Here "find" is replaced with a fake that rejects -printf (as
+# BSD/macOS find would), but a real, working find is also installed as
+# "gfind" -- shuriken must resolve FIND to "gfind" and succeed, proving the
+# preference is applied rather than the guard merely tolerating a fluke.
+test_gnu_tool_guard_prefers_g_prefixed_sibling() {
+ local path_bin
+ local real_find
+ local output
+
+ test::setup
+ path_bin="$TEST_TMPDIR/bin"
+ real_find=$(command -v find)
+ test::install_coreutils_without_imagemagick "$path_bin"
+ rm -f "$path_bin/find"
+ cat > "$path_bin/find" <<FAKE
+#!/usr/bin/env bash
+set -euo pipefail
+for arg in "\$@"; do
+ if [ "\$arg" = -printf ]; then
+ printf 'find: unknown predicate -printf\n' >&2
+ exit 2
+ fi
+done
+exec "$real_find" "\$@"
+FAKE
+ chmod 0755 "$path_bin/find"
+ ln -s "$real_find" "$path_bin/gfind"
+
+ output=$(
+ cd "$TEST_TMPDIR"
+ PATH="$path_bin" "$TEST_SHURIKEN" --version
+ )
+
+ test::assert_contains 'This is Shuriken Version' "$output"
+ test::assert_not_contains 'shuriken requires the GNU versions' "$output"
+ test::teardown
+}
+
+# verify_gnu_tool_versions (src/lib/compat.source.sh) is the fast preflight
+# check that runs before the (slower) behavioral probes: it rejects a resolved
+# tool outright when its own --version output does not claim to be GNU, naming
+# the tool and pointing at the README install instructions. This is distinct
+# from (and runs before) the "GNU-tool guard rejects non-GNU find" case above,
+# which covers a tool that claims GNU but lacks the specific behavior.
+test_gnu_tool_guard_rejects_non_gnu_version_string() {
+ local path_bin
+ local real_find
+ local output
+
+ test::setup
+ path_bin="$TEST_TMPDIR/bin"
+ real_find=$(command -v find)
+ test::install_coreutils_without_imagemagick "$path_bin"
+ rm -f "$path_bin/find"
+ cat > "$path_bin/find" <<FAKE
+#!/usr/bin/env bash
+set -euo pipefail
+if [ "\${1:-}" = --version ]; then
+ printf 'find (BSD)\n'
+ exit 0
+fi
+for arg in "\$@"; do
+ if [ "\$arg" = -printf ]; then
+ printf 'find: unknown predicate -printf\n' >&2
+ exit 2
+ fi
+done
+exec "$real_find" "\$@"
+FAKE
+ chmod 0755 "$path_bin/find"
+
+ output=$(
+ cd "$TEST_TMPDIR"
+ PATH="$path_bin" test::capture_failure_output \
+ "$TEST_SHURIKEN" --version
+ )
+
+ test::assert_contains 'does not report itself as GNU' "$output"
+ test::assert_contains 'Platform compatibility' "$output"
+ test::teardown
+}
+
test_extra_args_fail() {
test::assert_failure 'extra operand is rejected' "$TEST_SHURIKEN" --version extra
test::assert_failure \
@@ -7747,6 +7834,12 @@ main() {
'GNU-tool guard rejects non-GNU stat' \
test_gnu_tool_guard_rejects_non_gnu_stat
test::run_case \
+ 'GNU-tool guard prefers a g-prefixed sibling tool' \
+ test_gnu_tool_guard_prefers_g_prefixed_sibling
+ test::run_case \
+ 'GNU-tool guard rejects a tool with a non-GNU version string' \
+ test_gnu_tool_guard_rejects_non_gnu_version_string
+ test::run_case \
'src/shuriken.sh lib source list matches Justfile LIB_SOURCES' \
test_lib_sources_match_justfile_lib_sources
}