Query binaries exit 0 with empty output when `bun` is missing, so "no results" and "runtime absent" are indistinguishable
- Dominant language
- TypeScript
- Stars
- 133k
- Forks
- 19.9k
- Avg merge
- 18h 46m
- Merged PRs (30d)
- 26
Description
`gstack-learnings-search` and `gstack-timeline-read` end their `bun` invocation
with `2>/dev/null || exit 0`. When `bun` is not on `PATH` both print **nothing**,
write **nothing to stderr**, and **exit 0** — which is byte-identical to a
successful search that matched no records.
Measured by running the installed binaries with
`PATH=/usr/bin:/bin:/usr/sbin:/sbin`:
| Binary | Path | Exit | stdout+stderr | Side effect |
|---|---|---|---|---|
| `gstack-learnings-log` | write | **1** | `bun: command not found` | none — 58 rows before, 58 after |
| `gstack-learnings-search` | read | **0** | **0 bytes** | n/a |
| `gstack-timeline-read` | read | **0** | **0 bytes** | n/a |
The write path is already correct, and that is the point: the repo's own
convention is to fail loudly, and these two scripts are the outliers.
### Why this is worse than a normal silent failure
`set -euo pipefail` is on line 8 of both files. The explicit `|| exit 0` on the
last line overrides it, so **a caller that checks the exit code is still told
success.** There is no way to detect this from outside the script.
The consequence is not a crash and not a lost write — it is a **wrong answer that
looks like a valid one**. An empty result from a learnings search reads as
*"nothing has been recorded for this project yet"*, not *"your toolchain is
broken"*. That is a conclusion an operator acts on, and increasingly a conclusion
an **agent** acts on: these binaries exist to feed prior context back into a
session, so the failure mode is an agent confidently proceeding as though no
institutional knowledge exists. It is also self-concealing — the natural next step
after an empty search is to record something new, and the write path works.
Scale, from the pinned ref: **42 of the 79 files in `bin/` need `bun`, 21 of them
in the shebang** (so they cannot start at all without it), and **only 7 of the 42
guard for its presence.** The two scripts here are the ones where the absence is
invisible rather than merely fatal.
### The repo already does this correctly, in two different ways
This is an internal-consistency fix, not a new convention:
- **`bin/gstack-ios-qa-daemon:34-37`** and **`bin/gstack-ios-qa-mint`** guard
explicitly, and the message is already the right one:
```sh
if ! command -v bun >/dev/null 2>&1; then
echo "gstack-ios-qa-daemon: bun runtime not on PATH — install from https://bun.sh" >&2
exit 1
fi
```
- **`bin/gstack-learnings-log`** takes the other valid approach: it redirects
bun's stderr to a temp file, captures `VALIDATE_RC`, and reports on a non-zero
return instead of discarding it.
### Affected
Two sites. Identical line numbers at the pinned ref and at `origin/main`:
| File | Line | Text |
|---|---|---|
| `bin/gstack-learnings-search` | 162 | `" 2>/dev/null \|\| exit 0` |
| `bin/gstack-timeline-read` | 96 | `" 2>/dev/null \|\| exit 0` |
**Not affected, despite matching the same grep:**
`bin/gstack-brain-enqueue:59` — `mkdir -p "$SPOOL" 2>/dev/null || exit 0`. The
comment above it states the contract: *"One spool file per record: tmp write +
atomic rename. Any failure exits 0 silently (fire-and-forget contract), cleaning
up the tmp file."* Silent exit 0 is the intended behaviour for an enqueue that
must never block a hook. Leave it alone.
### Suggested fix
Add the guard that `gstack-ios-qa-daemon` already uses, and stop discarding
bun's stderr. In **`bin/gstack-learnings-search`**, after the `set -euo pipefail`
block:
```diff
+if ! command -v bun >/dev/null 2>&1; then
+ echo "gstack-learnings-search: bun runtime not on PATH — install from https://bun.sh" >&2
+ exit 1
+fi
```
and on line 162:
```diff
-" 2>/dev/null || exit 0
+"
```
The same two changes in **`bin/gstack-timeline-read`** (guard after line 8,
line 96 loses its trailing clause).
Dropping `|| exit 0` entirely is safe here because `set -euo pipefail` is already
in force and the surrounding script handles the genuinely-empty cases *before*
reaching bun — `gstack-learnings-search` exits 0 at line 43 when there is no
learnings file, and `gstack-timeline-read` exits 0 at line 29 when there is no
timeline file. **The empty-input path never reaches the bun call**, so the
trailing `|| exit 0` was only ever catching real errors.
If you would rather not change exit-code behaviour on a query path at all, the
minimum useful fix is to stop swallowing stderr — replace `2>/dev/null` with
nothing and keep `|| exit 0`. That still returns 0, but the reason becomes
visible, which is the part that matters.
### Environment
- gstack `51932eceef9cf45ad5fbf2b19e15615f96df2872` — `VERSION` 1.68.2.0
- Also verified on `origin/main` — `VERSION` 1.79.0.0, 11 commits ahead, both
files unmodified since the pin
- macOS 15.6 (Darwin 25.6.0), zsh, `bun` 1.3.14 via `brew install oven-sh/bun/bun`
- Reproduce: `env PATH=/usr/bin:/bin:/usr/sbin:/sbin ~/.claude/skills/gstack/bin/gstack-learnings-search test; echo "exit=$?"`
Contributor guide
Research direction
Start with bin/gstack-learnings-search and bin/gstack-timeline-read, focusing on the bun invocation lines identified in the issue. Reproduce the missing-runtime case with the provided PATH and compare the scripts with bin/gstack-ios-qa-daemon or gstack-learnings-log. Done means both query binaries report the missing bun runtime and exit nonzero, while genuine empty-input cases still exit 0.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- bun, shell
- Domain
- cli, tooling
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 90/100