Disclosure first
I wrote DevToolkit and I sell it for $4, so this is a post about bugs in my own paid product, not a review of someone else's. Three defects, and only one is a bash version problem. The other two are facts about my own machine that I assumed instead of measured. All three are fixed now and the fixed build is what buyers download, which is why they are written up here rather than quietly patched. A fourth bug of the same class was found in a neighbouring file while writing this, and the last section says what became of it.
The platform fact
$ /bin/bash --version
GNU bash, version 3.2.57(1)-release (arm64-apple-darwin26)
$ command -v bash
/bin/bash
That is the only bash on the machine I develop on; /opt/homebrew/bin/bash does not exist. Every script in the pack starts with #!/usr/bin/env bash, which on a default macOS PATH resolves to a 2007 interpreter. A "bash 4+" feature list is not a compatibility statement. That gap causes the second bug below; the first is worse and has nothing to do with bash version at all.
164 GB of free memory on a 24 GB laptop
This is from devtoolkit-v1.1.zip:devtoolkit/env-doctor.sh, lines 20-22. That is the build buyers had until this week; the fixes in this post shipped as v1.3.
# devtoolkit-v1.1.zip:devtoolkit/env-doctor.sh:20-22
free_pages=$(vm_stat | awk '/Pages free/ {gsub("\\.",""); print $3}')
page_size=4096
mem="$((free_pages * page_size / 1024 / 1024)) GB free (approx)"
Three lines, two independent mistakes that multiply. The first is page_size=4096. vm_stat reports memory as a count of pages, and Apple Silicon uses 16 KB pages:
$ sysctl -n hw.pagesize
16384
That assumption was checkable before I wrote it, because vm_stat prints the page size in its own banner line. The second mistake is the unit. Line 22 divides bytes by 1024 twice, which yields megabytes, and prints them under the label GB. Integer division truncates, so the output looked like a plausible round number instead of a suspicious one. With P free pages, line 22 prints P/256 and calls it GB, while the real figure is P/65536. The ratio is exactly 256: four times from the page size, sixty-four from the label.
Side by side on the same machine, same minute:
$ /bin/bash devtoolkit/env-doctor.sh # v1.1 output, the build buyers had until this week
memory: 164 GB free (approx)
$ /bin/bash env-doctor.sh # fixed source in products/devtoolkit/
memory: 0.9 GB free (approx)
$ sysctl -n hw.memsize
25769803776
25,769,803,776 bytes is 24 GiB. The shipped script reported 164 GB free on a machine holding 24 GB total, and a health check that lies about memory is worse than no health check, because the point of it is to be believed at a glance.
Current source, products/devtoolkit/env-doctor.sh:22-28:
# products/devtoolkit/env-doctor.sh:22-23,27-28
page_size=$(sysctl -n vm.page_size 2>/dev/null || sysctl -n hw.pagesize 2>/dev/null || echo 4096)
free_pages=$(vm_stat | awk '/Pages free/ {gsub("\\.",""); print $3; exit}')
avail_mb=$(( (free_pages + spec_pages) * page_size / 1024 / 1024 ))
mem="$(awk -v mb="$avail_mb" 'BEGIN{printf "%.1f", mb/1024}') GB free (approx)"
The trailing awk exists because $(( )) cannot produce a decimal, and speculative pages joined the count because macOS hands those over under pressure. One wrinkle worth admitting: the fallback chain reads vm.page_size first, and that OID does not exist here.
$ sysctl -n vm.page_size
sysctl: unknown oid 'vm.page_size'
The first link in the portability chain I wrote for other people's machines is dead on the only machine I test on, and the last resort in that chain is || echo 4096, the number that caused the original bug, still there as a default.
An empty array is unbound in bash 3.2
img-optimize.sh builds an options array per tool and splats it. Lines 45-47 of the shipped build:
# devtoolkit-v1.1.zip:devtoolkit/img-optimize.sh:45-47
args=(); [ -n "$MAXW" ] && args+=(-resize "${MAXW}x")
[ -n "$Q" ] && args+=(-quality "$Q")
"$tool" "$f" "${args[@]}" "$out";;
Under bash 4.4 an empty array is defined. Under 3.2, "${args[@]}" on an empty array is an unbound variable, and line 4 is set -euo pipefail, so the reference itself aborts the run:
$ /bin/bash -c 'set -euo pipefail; args=(); printf "x %s\n" "${args[@]}"'
bash: args[@]: unbound variable
$ /bin/bash -c 'set -euo pipefail; args=(); printf "x %s\n" ${args[@]+"${args[@]}"}'
x
Read the conditions, because they explain how it shipped. The crash needs ImageMagick present, that branch only, neither --max-width nor --quality passed, and the buyer's bash to be 3.2. None of that was my test path, because ImageMagick is not installed here and I always passed a flag. The QA record reaches it with a magick stub and gets the same abort at line 47 (qa/img-optimize-fix-2026-09-30.md:46-49).
The fix is the second expansion form above, at all three splat sites: products/devtoolkit/img-optimize.sh:117, :122, :132.
# products/devtoolkit/img-optimize.sh:117
"$tool" ${args[@]+"${args[@]}"} "$src" "$out";;
set -euo pipefail is unchanged at line 13. I did not weaken the safety settings to make a bug go quiet.
The file that lied about being a TIFF
Same script, lines 48-52 of the shipped build:
# devtoolkit-v1.1.zip:devtoolkit/img-optimize.sh:48-52
sips)
cp "$f" "$out"
sargs=(); [ -n "$MAXW" ] && sargs+=(--resampleWidth "$MAXW")
case "$ext" in jpg|jpeg) sargs+=(-s format jpeg);; png) sargs+=(-s format png);; esac
[ ${#sargs[@]} -gt 0 ] && sips "$out" "${sargs[@]}" >/dev/null;;
It copies the source bytes to the output name, then only asks sips to re-encode when the extension is jpg or png. For anything else sargs stays empty, the && short-circuits, and nothing converts. You keep a byte-for-byte copy of your PNG wearing a .tiff filename, and the script reports success. I ran it against a real 16x16 PNG:
$ /bin/bash devtoolkit/img-optimize.sh . --format tiff # v1.1, exits 0
$ file optimized/valid.tiff
optimized/valid.tiff: PNG image data, 16 x 16, 8-bit/color RGB, non-interlaced
$ cmp optimized/valid.tiff valid.png && echo identical
identical
WebP was worse in a different direction, because stock sips cannot write it at all:
$ sips -s format webp valid.png --out try.webp
Error: Can't write format: org.webmproject.webp
The old script never asked, so it never saw that error, and reported success instead. The old README listed WebP flat among the supported outputs; README.md:9 now says "WebP needs ImageMagick or an ffmpeg built with libwebp". The store paste-source, products/devtoolkit/LISTING-KIT.md, said "(jpg/png/webp/tiff)" flat for longer than the README did, because nothing re-reads an internal file after the copy goes live. It now carries the same capability split as the README, and a note saying why.
The replacement makes capability a runtime question. img-optimize.sh:66-67 declares what sips can write, and :80-86 picks the first present tool that can honour the request, aborting before anything is written when nothing can:
$ env -i PATH=/usr/bin:/bin /bin/bash img-optimize.sh . --format webp
error: cannot produce format 'webp' on this system.
error: supported here: jpeg png tiff gif bmp jp2 heic
error: nothing was written for 'webp'; no mislabelled file was left behind.
That is a real run with ffmpeg and my user PATH removed, exiting 1. Put an ffmpeg with libwebp back and the same command produces genuine WebP instead of refusing: RIFF (little-endian) data, Web/P image, VP8 encoding, 16x16.
Refusing up front is only half of it. The other half is not trusting the tool when it claims success. Every output, including a plain copy, is re-identified at img-optimize.sh:147-164, and sips -g format, sips -g pixelWidth and file -b --mime-type must agree:
# products/devtoolkit/img-optimize.sh:162-163
[ -n "$HAVE_SIPS" ] && return 0
return 1 # cannot verify = refuse to claim success
The width check exists because a 10-byte text file named garbage.webp reports format: webp from sips while returning <nil> for width (qa/img-optimize-fix-2026-09-30.md:134-136). When neither identifier can be trusted the function returns failure and the file is deleted.
The same crash, in one more file
bulk-rename.sh:9 parses its pattern the way img-optimize.sh used to, with no arity check:
# products/devtoolkit/bulk-rename.sh:9
--pattern) PATTERN="$2"; shift 2;;
With set -euo pipefail at line 4, --pattern as the final argument reads an unset positional. Measured today against current source, not the old ZIP:
$ /bin/bash bulk-rename.sh . --pattern
bulk-rename.sh: line 9: $2: unbound variable
$ /bin/bash img-optimize.sh . --format
error: --format needs a value
Same class of defect, caught by looking at the other files after fixing the first one. The guard at img-optimize.sh:20-22 is [ $# -ge 2 ] || die "...", and copying it into bulk-rename.sh closes it. I have now done that: the guard is at bulk-rename.sh:9, and six cases pass on bash 3.2.57 - the trailing --pattern now prints error: --pattern needs a value and exits 1 instead of aborting on an unbound variable, dry run still renames nothing, --apply still renames, a missing pattern still exits 1, and an empty-string pattern is rejected by the existing -n check. The fixed pack was devtoolkit-v1.4.zip; the download serves devtoolkit-v1.6.zip as of 2026-09-30, which also carries the four fixes below.
Four things that run surfaced since, all fixed in v1.6 on 2026-09-30, and each reproduced against v1.5 first - a guard that never fires on the broken build proves nothing.
-
The script renamed itself. It walks
"$DIR"/*with no exclusion, so a directory containingbulk-rename.shhad the tool renamed too: harmless in dry run, not with--apply. The loop now skips its own basename (SELFatbulk-rename.sh:21, skip at:34). Tested by copying the script into the target folder: both images renamed, script survived. -
The dry run did not predict the apply. Two sources whose pattern produced the same target were both listed by the preview, and apply then skipped one - so the count you approve is not the count you get. The collision check now runs in both modes and also tracks names claimed earlier in the same pass (
claimed()at:25-30, called at:40-43). Measured: the old build previews 2 and performs 1; the new one previews 1 and performs 1. -
A dangling symlink parked at the target name was replaced, not skipped, because the check was
[ -e ], which follows links.[ -L ]now catches it (:27). Old build destroyed the link; new one refuses and leaves both files alone. -
A pattern that empties a filename is refused (
:37) instead of trying to move a file onto the directory itself.
What is unchanged: a skipped collision is still dropped rather than re-planned, so the second rename in a clash never happens. The difference is that the preview now says so before you run it.
What is not tested
All 21 cases in the QA matrix ran under /bin/bash 3.2.57 (qa/img-optimize-fix-2026-09-30.md:153-183). bash 4 and 5 are untested: nothing newer is installed here, and installing one is out of budget. The ${arr[@]+"${arr[@]}"} guard is documented as a no-op on 4.4+, and no bash-4-only construct appears in any file, but nobody has run these scripts on a modern bash. 3.2 is the stricter target, so passing there is evidence, not proof.
ImageMagick is absent, so its branch went through a test double that proves dispatch order and the array fix but not real ImageMagick output. shellcheck is absent and was not installed. The Linux half of the pack is unverified too: The shipped README used to state "Tested on macOS (BSD coreutils) and Linux (GNU) syntax differences" at README.md:40. There is no Linux here, so that sentence described a test that never happened; it now reads "Tested on macOS only ... that portability is designed for, not verified" (README.md:44). The elif [ -f /proc/meminfo ] branch at env-doctor.sh:29-30 has still never run on this machine.
$ [ -e /proc/meminfo ] && echo PRESENT || echo ABSENT
ABSENT
Run the repros yourself
Every error string quoted above is reproducible. Every error string quoted above is reproducible from the command shown next to it - I have since taken the per-gotcha script repository private, so there is nothing to clone here any more. Where a gotcha needs a file, the snippet creates its own scratch directory with mktemp -d and removes it on exit, so you can paste it without cleaning up afterwards. The scripts that need a file create their own scratch directory with mktemp -d and remove it on exit; the rest write nothing at all.
Getting it
DevToolkit is $4, one-time purchase, instant download, 30-day refund: https://payhip.com/b/aEn8M. It is also in the $19 bundle at https://payhip.com/b/yTE86.
The honest limitation, stated plainly: both bugs above were live in what buyers downloaded until this week. devtoolkit-v1.1.zip was the shipping build when this audit ran, and replacing a live product's file means deleting the file it is serving, so I left it alone until the replacement was confirmed uploaded. A paid product with no download is worse than one with known bugs. The swap has now landed twice - v1.3 carried the first three fixes and v1.4 carries the fourth - and the live file is devtoolkit-v1.4.zip; every excerpt above keeps its v1.1 label because it is the before state, not the current code.
What is still not true of v1.3: the fixes were exercised on bash 3.2.57, the version macOS ships. bash 5 is untested, because there is no bash 5 on this machine and installing one costs money I have not spent on this project.
Authorship note: this post was drafted by an AI assistant working from the project's own files and command output. I read it before publishing and corrected the parts that had gone stale while it was being written.
Top comments (0)