fix: v2.5.0 bug fixes across listen, config, and validation (#342)
* fix: make the CLI safe to run without a terminal
Six triaged issues plus three related bugs found while scoping them. They share
one failure shape: the CLI silently does something other than what the caller
asked, and the first symptom is missing traffic rather than an error.
Design rule applied throughout: a prompt must never be the only path. Without a
terminal, reads pick the safe default and writes fail loudly; rendering degrades
rather than dies; exit codes tell the truth.
- #332 `ci --local` / `login --local` no longer write the global config. The
login flows saved the profile before --local was inspected, so the flag added
a second write instead of redirecting the first, silently repointing the
machine's active project. Gated at the single writeConfig chokepoint.
- #333 `listen` falls back to compact output when stdout is not a terminal. The
interactive renderer opens /dev/tty itself, so in CI, Docker, nohup or an AI
agent it died at startup and the command exited 0 having forwarded nothing.
Renderer failures now propagate as a non-zero exit rather than being logged
and discarded.
- #334 `listen` honours HOOKDECK_API_KEY. It previously fell through to a guest
account: traffic arrived locally so it looked like it worked, but none of it
was in the caller's project. HOOKDECK_API_KEY holds a Project API key, which
/cli-auth/validate rejects with 401, so it is exchanged for a CLI client key
via POST /cli-auth/ci exactly as `hookdeck ci` does, then saved. Precedence:
--cli-key, stored login, HOOKDECK_API_KEY, guest.
- #335 Explicitly empty secret and identity flags are rejected. `"$UNSET_VAR"`
expands to "", and hasAny() is a pure != "" test, so the value was dropped and
the source created with no verification at all while looking configured. An
empty value never cleared anything: the API expresses "no verification" as a
null auth object, and `--config '{"auth": null}'` still does that.
- #336 The release skill's CI gate used the legacy commit-status API, which
GitHub Actions never writes, so it returned pending off total_count 0 and
blocked every release. Replaced with the GraphQL statusCheckRollup. The skill
was also duplicated byte-for-byte under .agents/; that is now a symlink, as
.claude/skills and .cursor/skills already were.
- #331 Dropped go-github v28, whose only use was one unauthenticated call and
whose only other effect was linking x/crypto/openpgp through package init
(GO-2026-5932, no upstream fix). Replaced with a net/http call, now testable
and tested. Added a govulncheck job, since nothing enforced the scan.
Also fixed, previously unreported:
- Unauthenticated commands no longer hang. Any command failing for want of
credentials dropped into interactive login: blocking on Enter, opening a
browser, then polling for ~4 minutes. Now gated on a terminal, and reports how
to authenticate without one.
- Destructive commands no longer exit 0 on a skipped confirmation. The five
delete/dismiss commands called fmt.Scanln and discarded its error, so without
a terminal they printed "cancelled" and returned nil: a CI job deleted nothing
and reported success. They now fail and name --force.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRp3XjWGqiYSun29SSNVJt
* test: cover the real-world usage each fix was reported from
The previous acceptance tests asserted configuration and error text. These
assert the outcomes users actually reported, and each one has been verified to
fail against unmodified main and pass on this branch.
- listen forwards a real event end to end with no terminal. Starts a local HTTP
server, creates a source and CLI connection, runs `listen` with no --output,
POSTs to the source URL and waits for the payload to arrive locally. This is
the scenario #333 was found in: start a service, start a tunnel, send an
event, check it arrives. Asserting only on the absence of a TTY error would
have passed even if nothing was being forwarded.
- Unauthenticated commands fail fast. Asserts elapsed time, not just the error:
against main this test takes 261s, which is the ~4 minute browser-login poll
#337 describes. A message-only assertion would pass while still hanging.
- `ci --local` leaves the active project alone, checked through `whoami` rather
than file contents. The reported symptom was not "a file changed", it was that
every other hookdeck invocation on the machine silently moved project (#332).
- delete without --force now covered on destination, connection and
transformation as well as source. The five commands share one confirmation
helper, but each has to call it; a command that quietly stops doing so keeps
working and just stops catching the bug (#338).
Issue dismiss is covered by unit tests only: it shares the same helper, and
creating a real issue takes ~40s of setup for a one-line wiring check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRp3XjWGqiYSun29SSNVJt
* fix: address Copilot review — guest-profile precedence, test race, wording
Five review findings, all valid. One was a real bug.
- HOOKDECK_API_KEY was ignored once a guest profile existed. GuestLogin
persists its key, so Profile.APIKey is non-empty after a single guest run and
the "no credentials stored" test treated the throwaway account as a real
login. Every later run then stayed on the guest project despite an exported
Project API key — #334 again, one run later. A guest profile is now
identified by guest_url alongside the key, and the environment key takes
precedence over it while still losing to a real stored login. Replacing a
guest profile is announced rather than silent, since discarding a sandbox
link is exactly the kind of side effect this release is about surfacing.
- The acceptance helpers raced. os/exec writes subprocess output from its own
goroutine while the tests read the buffer mid-run, so a bare bytes.Buffer
could return partial output. Reads and writes now go through a mutex.
- "Check it is exported" misdiagnosed the reported cause. In #335 the secret
was in a workspace .env that application code loaded but the shell never did,
so the variable was not set in the invoking shell at all; exporting only
matters for a variable that is set locally but not visible to child
processes. The error and the README now say "set in this shell" and note that
.env values are not loaded automatically.
- REFERENCE.md advertised --cli-key as a global flag. The root persistent flag
was registered unhidden while --api-key beside it is hidden, and regenerating
the docs surfaced it — contradicting README "CLI authentication keys" and
AGENTS.md, which state authentication is command-specific. It is now hidden
like --api-key: still functional for existing callers, no longer advertised.
The command-specific `login --cli-key` and `listen --cli-key` remain
documented.
Adds the guest-profile acceptance case the review asked for, using a synthetic
guest config so it stays deterministic and does not create a real guest account
on every CI run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRp3XjWGqiYSun29SSNVJt
* refactor: make .agents/skills the canonical skills location
.agents/skills is the cross-harness location — it is not tied to any one tool,
so it is the right home for the real files. This inverts what landed earlier in
this branch, which kept the files at a root skills/ and pointed .agents/skills
at it.
- .agents/skills/hookdeck-cli-release/ now holds the files (tracked as a rename,
so history is preserved).
- .claude/skills and .cursor/skills are symlinks to ../.agents/skills.
- Root skills/ is gone; there is one canonical directory and no second copy to
drift out of sync, which was the underlying problem in #336.
The skill sits one directory deeper than before, so its relative links needed
../../ -> ../../../. All four (release workflow, .goreleaser/, .goreleaser/mac.yml,
README) verified to resolve. AGENTS.md, README.md and CLAUDE.md updated to point
at the new location.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRp3XjWGqiYSun29SSNVJt
* fix: address second Copilot pass — error context, help text, issue coverage
Three findings, all valid.
- The HOOKDECK_API_KEY guidance was being discarded. CILogin returns a 401
APIError, and Execute classifies it with IsUnauthorizedError — which matches
both through errors.As and through a "status code: 401" substring check — so
any wrapping lost the message and the user saw the generic "API key is invalid
or expired" instead of the one thing that helps: that HOOKDECK_API_KEY takes a
Project API key and a CLI client key belongs in --cli-key. Added an
actionableError type that Execute checks before its 401 handling, so a command
that has already explained the failure keeps its message.
- listen's help documented a precedence it no longer follows. It said stored
credentials always beat HOOKDECK_API_KEY, which is untrue for a guest profile
since the review fix. Help text and README now state the exception and that
replacing a guest profile discards the sandbox link. REFERENCE.md regenerated.
- issue dismiss was the only one of the five destructive commands without a
non-interactive test. The earlier justification for skipping it — that
creating a real issue costs ~40s of setup — was wrong: runIssueDismissCmd
confirms before it calls DismissIssue, so a non-existent ID exercises the
guard in ~2s. All five callers are now covered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRp3XjWGqiYSun29SSNVJt
* test: cover the actionableError classification ordering
The actionableError type added in 570b71f changes the root error path but had no
test. The assertion that matters is the precondition: a wrapped 401 IS still
recognised by IsUnauthorizedError, so it is purely Execute's case ordering that
keeps the specific guidance. Writing that down means the day the precondition
stops holding, the test says so and the extra case can be removed rather than
lingering as cargo.
Also covers the other direction: an unmarked error must still receive the
generic recovery message, or every 401 would print raw API text instead of
telling the user how to sign in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRp3XjWGqiYSun29SSNVJt
* fix: identify the CLI to GitHub, and pin govulncheck
Two suppressed comments from the third Copilot pass. Neither is breaking today,
both are worth doing.
- The release check sent Go's default "Go-http-client/1.1". GitHub accepts it
(verified: 200), but asks callers to identify themselves and applies rate
limits per User-Agent — and go-github used to set one, so dropping it was an
unintended regression from that removal. Now sends "hookdeck-cli/<version>".
Built inline rather than via pkg/useragent, which imports pkg/version and
would make this an import cycle.
- The govulncheck job installed @latest, so a new release could change behaviour
or fail CI with no change to the repo. Pinned to v1.6.0. The vulnerability
database is still fetched at run time, so newly disclosed issues are picked up
without a version bump.
Verified end to end: a binary stamped 2.0.0 still detects v2.4.0 against the
real API, and govulncheck v1.6.0 reports no findings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRp3XjWGqiYSun29SSNVJt
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>