Skip to content

Commit ad6b2fd

Browse files
leggetterclaude
andauthored
feat(listen): expose --cli-key, adopt its project, and save it when unauthenticated (#329)
* feat(listen): expose --cli-key and adopt it when unauthenticated The Hookdeck Console hands users a ready-to-run command: npx hookdeck-cli listen [port] <source> --cli-key <key> Two things made that worse than it needed to be. The flag was invisible. --cli-key is declared on the root command as "(deprecated)" and MarkHidden, so it appears in no help output, even though listen's own help documents it with an example. Anything that introspects the CLI concludes the flag does not exist. Declaring it on listen, bound to the same Config field, changes no behaviour and makes it discoverable in `hookdeck listen --help`. The key was also forgotten immediately. It applied to one invocation, so every later run needed it pasted again. listen now validates and saves the key when the machine has no stored credential, after which the flag can be dropped. It deliberately does not save when a credential already exists. Forwarding a Console source for a few minutes should not silently replace an existing login, so in that case the key applies to the run only. Validation happens before the write, so a typo fails with a clear error instead of being persisted. Config.HasStoredAPIKey records whether a key was on disk before InitConfig folds in the flag value, which the coalesced Profile.APIKey can no longer distinguish. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHJQ1QSdKmJMivqw7SER6t * fix(listen): use the supplied --cli-key's own project, and cover it A key given on the command line was sent alongside the project id read from the config file. Those belong to different logins, so anyone with an existing profile who ran the command the Hookdeck Console gives them got "your API key is invalid or expired" and no way to see why. Validation is already project-agnostic (Client.clientForCLIAuthValidate sends an empty ProjectID), so it resolves the project the key really belongs to. Adopting that response replaces the stale project for the run. Saving stays conditional on there being no stored credential, so an existing login is still never replaced. Renames the helper to applyCliKey, since it now always resolves context and only sometimes saves. Tests cover the four branches without touching the network, using the httptest pattern from gateway_test.go: the flag-absent no-op (pointed at a dead port, so a stray validate call fails loudly), adopting the key's project over a stale one, leaving an existing login on disk, and refusing to write a key that fails validation. A mutation check confirms the project-adoption test fails when the fix is removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHJQ1QSdKmJMivqw7SER6t * fix(listen): reject an empty --cli-key locally `--cli-key=` satisfies cobra's Changed check but carries nothing to authenticate with. The empty value then fell through InitConfig's coalesce and the run failed with "your API key is invalid or expired", which describes neither what happened nor how to fix it, after a pointless round-trip to the API. Fail immediately with an error naming the flag. Reads the flag value rather than Profile.APIKey, which by that point may hold a stored key from the config file instead of what the user typed. Raised by Copilot review on #329. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHJQ1QSdKmJMivqw7SER6t --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e0297b5 commit ad6b2fd

4 files changed

Lines changed: 360 additions & 0 deletions

File tree

‎pkg/cmd/listen.go‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"strconv"
2424
"strings"
2525

26+
"github.com/hookdeck/hookdeck-cli/pkg/ansi"
2627
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
2728
"github.com/hookdeck/hookdeck-cli/pkg/listen"
2829
"github.com/spf13/cobra"
@@ -42,6 +43,72 @@ type listenCmd struct {
4243
filterPath string
4344
}
4445

46+
// applyCliKey resolves the project context for a --cli-key supplied on the
47+
// command line, and saves the key when this machine has no stored credential.
48+
//
49+
// A key given on the command line determines its own project. Any project read
50+
// from the config file belongs to a different login, and sending it alongside
51+
// this key is what previously produced "your API key is invalid or expired" for
52+
// anyone who already had a profile. Validation is project-agnostic (see
53+
// Client.clientForCLIAuthValidate), so it resolves the project the key really
54+
// belongs to, and that replaces whatever was on disk for this process.
55+
//
56+
// Saving is separate, and only happens when there is no stored credential. That
57+
// covers the Hookdeck Console path, where the Console hands you a
58+
// `listen ... --cli-key <key>` command and the key would otherwise be needed on
59+
// every later run. When a credential already exists it is left alone: someone
60+
// forwarding a Console source for a few minutes should not silently lose the
61+
// login they had.
62+
//
63+
// The key is validated before anything is written, so a typo fails here with a
64+
// clear error rather than being persisted and confusing the next run.
65+
func (lc *listenCmd) applyCliKey(cmd *cobra.Command) error {
66+
flag := cmd.Flags().Lookup("cli-key")
67+
if flag == nil || !flag.Changed {
68+
return nil
69+
}
70+
71+
// `--cli-key=` passes the Changed check but leaves nothing to authenticate
72+
// with. Without this the empty value falls through InitConfig's coalesce
73+
// and the run fails with "your API key is invalid or expired", which
74+
// describes neither what happened nor how to fix it. Read the flag rather
75+
// than Profile.APIKey, which by now may hold a stored key instead.
76+
if strings.TrimSpace(flag.Value.String()) == "" {
77+
return errors.New("--cli-key needs a value, e.g. --cli-key <key from the Hookdeck Console>")
78+
}
79+
80+
response, err := Config.GetAPIClient().ValidateAPIKey()
81+
if err != nil {
82+
return err
83+
}
84+
85+
// Adopt the key's own project, discarding any stale project from config.
86+
Config.Profile.ApplyValidateAPIKeyResponse(response, true)
87+
Config.RefreshCachedAPIClient()
88+
89+
if Config.HasStoredAPIKey {
90+
// Use the key for this run only; the existing login stays on disk.
91+
return nil
92+
}
93+
94+
if err := Config.Profile.SaveProfile(); err != nil {
95+
return err
96+
}
97+
if err := Config.Profile.UseProfile(); err != nil {
98+
return err
99+
}
100+
Config.RefreshCachedAPIClient()
101+
102+
// Writing credentials is a side effect of a command that otherwise only
103+
// forwards events, so say so rather than doing it silently.
104+
fmt.Printf(
105+
"Saved CLI key for %s. Future runs won't need --cli-key.\n",
106+
ansi.Bold(response.ProjectName),
107+
)
108+
109+
return nil
110+
}
111+
45112
// Map --cli-path to --path
46113
func normalizeCliPathFlag(f *pflag.FlagSet, name string) pflag.NormalizedName {
47114
switch name {
@@ -162,6 +229,14 @@ Destination CLI path will be "/". To set the CLI path, use the "--path" flag.`,
162229
lc.cmd.Flags().BoolVar(&lc.noWSS, "no-wss", false, "Force unencrypted ws:// protocol instead of wss://")
163230
lc.cmd.Flags().MarkHidden("no-wss")
164231

232+
// Declared locally as well as on the root command. The root flag is hidden
233+
// and deprecated, but `listen --cli-key` is a documented, supported way to
234+
// authenticate a single run (from the Hookdeck Console, or in CI), and
235+
// listen's own help promotes it. Binding to the same Config field keeps the
236+
// behaviour identical; this only makes the flag discoverable in
237+
// `hookdeck listen --help`.
238+
lc.cmd.Flags().StringVar(&Config.Profile.APIKey, "cli-key", "", "Hookdeck CLI key used to authenticate this command, e.g. the key shown in the Hookdeck Console")
239+
165240
lc.cmd.Flags().StringVar(&lc.path, "path", "", "Sets the path to which events are forwarded e.g., /webhooks or /api/stripe")
166241
lc.cmd.Flags().IntVar(&lc.maxConnections, "max-connections", 50, "Maximum concurrent connections to local endpoint (default: 50, increase for high-volume testing)")
167242

@@ -241,6 +316,10 @@ Examples:
241316

242317
// listenCmd represents the listen command
243318
func (lc *listenCmd) runListenCmd(cmd *cobra.Command, args []string) error {
319+
if err := lc.applyCliKey(cmd); err != nil {
320+
return err
321+
}
322+
244323
var sourceQuery, connectionQuery string
245324
if len(args) > 1 {
246325
sourceQuery = args[1]

‎pkg/cmd/listen_cli_key_test.go‎

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/hookdeck/hookdeck-cli/pkg/config"
12+
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
// validateStub serves the project-agnostic cli-auth/validate endpoint, which is
18+
// what resolves the project a supplied key belongs to.
19+
func validateStub(t *testing.T, projectID, projectName string) *httptest.Server {
20+
t.Helper()
21+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22+
if r.URL.Path != hookdeck.APIPathPrefix+"/cli-auth/validate" {
23+
http.NotFound(w, r)
24+
return
25+
}
26+
w.Header().Set("Content-Type", "application/json")
27+
_ = json.NewEncoder(w).Encode(hookdeck.ValidateAPIKeyResponse{
28+
ProjectID: projectID,
29+
ProjectMode: "console",
30+
ProjectName: projectName,
31+
})
32+
}))
33+
t.Cleanup(server.Close)
34+
return server
35+
}
36+
37+
func listenCmdWithCliKeyFlag(t *testing.T, key string) *listenCmd {
38+
t.Helper()
39+
lc := newListenCmd()
40+
require.NoError(t, lc.cmd.Flags().Set("cli-key", key))
41+
return lc
42+
}
43+
44+
func TestApplyCliKey(t *testing.T) {
45+
t.Run("no-op when --cli-key was not supplied", func(t *testing.T) {
46+
old := Config
47+
t.Cleanup(func() { Config = old })
48+
config.ResetAPIClientForTesting()
49+
t.Cleanup(config.ResetAPIClientForTesting)
50+
51+
// Points at a port nothing is listening on: if the guard is wrong and a
52+
// validate call is attempted, this fails rather than passing quietly.
53+
Config = config.Config{}
54+
Config.APIBaseURL = "http://127.0.0.1:1"
55+
Config.Profile.ProjectId = "existing_project"
56+
57+
lc := newListenCmd()
58+
require.NoError(t, lc.applyCliKey(lc.cmd))
59+
assert.Equal(t, "existing_project", Config.Profile.ProjectId,
60+
"context must be untouched when the flag is absent")
61+
})
62+
63+
// The bug this guards against: a key given on the command line was sent
64+
// alongside a project id read from a previous login, which belongs to a
65+
// different project, so every call failed with "invalid or expired".
66+
t.Run("adopts the key's own project over a stale stored one", func(t *testing.T) {
67+
old := Config
68+
t.Cleanup(func() { Config = old })
69+
config.ResetAPIClientForTesting()
70+
t.Cleanup(config.ResetAPIClientForTesting)
71+
72+
server := validateStub(t, "project_from_key", "Sandbox")
73+
74+
dir := t.TempDir()
75+
path := filepath.Join(dir, "config.toml")
76+
require.NoError(t, os.WriteFile(path, []byte(`profile = "default"
77+
78+
[default]
79+
api_key = "sk_test_stored_key_1234"
80+
project_id = "stale_project_from_previous_login"
81+
`), 0600))
82+
83+
cfg, err := config.LoadConfigFromFile(path)
84+
require.NoError(t, err)
85+
cfg.APIBaseURL = server.URL
86+
Config = *cfg
87+
88+
lc := listenCmdWithCliKeyFlag(t, "sk_test_flag_key_5678")
89+
Config.Profile.APIKey = "sk_test_flag_key_5678"
90+
91+
require.NoError(t, lc.applyCliKey(lc.cmd))
92+
93+
assert.Equal(t, "project_from_key", Config.Profile.ProjectId,
94+
"the key's project must replace the stale one for this run")
95+
})
96+
97+
// A short debugging session with a Console key should not cost someone the
98+
// login they already had.
99+
t.Run("does not overwrite an existing stored login", func(t *testing.T) {
100+
old := Config
101+
t.Cleanup(func() { Config = old })
102+
config.ResetAPIClientForTesting()
103+
t.Cleanup(config.ResetAPIClientForTesting)
104+
105+
server := validateStub(t, "project_from_key", "Sandbox")
106+
107+
dir := t.TempDir()
108+
path := filepath.Join(dir, "config.toml")
109+
stored := `profile = "default"
110+
111+
[default]
112+
api_key = "sk_test_stored_key_1234"
113+
project_id = "stored_project"
114+
`
115+
require.NoError(t, os.WriteFile(path, []byte(stored), 0600))
116+
117+
cfg, err := config.LoadConfigFromFile(path)
118+
require.NoError(t, err)
119+
cfg.APIBaseURL = server.URL
120+
Config = *cfg
121+
require.True(t, Config.HasStoredAPIKey, "fixture must represent an existing login")
122+
123+
lc := listenCmdWithCliKeyFlag(t, "sk_test_flag_key_5678")
124+
Config.Profile.APIKey = "sk_test_flag_key_5678"
125+
126+
require.NoError(t, lc.applyCliKey(lc.cmd))
127+
128+
after, err := os.ReadFile(path)
129+
require.NoError(t, err)
130+
assert.Equal(t, stored, string(after), "an existing login must be left on disk untouched")
131+
})
132+
133+
// `--cli-key=` satisfies Changed but carries nothing to authenticate with.
134+
// It must fail locally rather than as an opaque auth error from the API.
135+
t.Run("rejects an explicitly empty --cli-key before calling the API", func(t *testing.T) {
136+
old := Config
137+
t.Cleanup(func() { Config = old })
138+
config.ResetAPIClientForTesting()
139+
t.Cleanup(config.ResetAPIClientForTesting)
140+
141+
// A dead port: reaching the network at all fails this test.
142+
Config = config.Config{}
143+
Config.APIBaseURL = "http://127.0.0.1:1"
144+
145+
lc := listenCmdWithCliKeyFlag(t, "")
146+
147+
err := lc.applyCliKey(lc.cmd)
148+
require.Error(t, err)
149+
assert.Contains(t, err.Error(), "--cli-key needs a value",
150+
"the error must name the flag, not surface as an auth failure")
151+
})
152+
153+
t.Run("propagates a validation failure without writing", func(t *testing.T) {
154+
old := Config
155+
t.Cleanup(func() { Config = old })
156+
config.ResetAPIClientForTesting()
157+
t.Cleanup(config.ResetAPIClientForTesting)
158+
159+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
160+
w.WriteHeader(http.StatusUnauthorized)
161+
_, _ = w.Write([]byte(`{"message":"invalid api key"}`))
162+
}))
163+
t.Cleanup(server.Close)
164+
165+
dir := t.TempDir()
166+
path := filepath.Join(dir, "config.toml")
167+
require.NoError(t, os.WriteFile(path, []byte("profile = \"default\"\n"), 0600))
168+
169+
cfg, err := config.LoadConfigFromFile(path)
170+
require.NoError(t, err)
171+
cfg.APIBaseURL = server.URL
172+
Config = *cfg
173+
174+
lc := listenCmdWithCliKeyFlag(t, "sk_test_bogus_key_9999")
175+
Config.Profile.APIKey = "sk_test_bogus_key_9999"
176+
177+
require.Error(t, lc.applyCliKey(lc.cmd), "a key that fails validation must not be adopted")
178+
179+
after, err := os.ReadFile(path)
180+
require.NoError(t, err)
181+
assert.NotContains(t, string(after), "sk_test_bogus_key_9999",
182+
"an unvalidated key must never reach disk")
183+
})
184+
}
185+
186+
// The flag has to be declared on listen itself: the root declaration is hidden,
187+
// so it appears in no help output and tooling that introspects the CLI cannot
188+
// discover it.
189+
func TestListenDeclaresCliKeyFlag(t *testing.T) {
190+
lc := newListenCmd()
191+
192+
flag := lc.cmd.Flags().Lookup("cli-key")
193+
require.NotNil(t, flag, "listen must declare --cli-key")
194+
assert.False(t, flag.Hidden, "--cli-key must be visible in `hookdeck listen --help`")
195+
}

‎pkg/config/config.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ type Config struct {
4242
configFile string // resolved path of config file
4343
viper *viper.Viper
4444

45+
// HasStoredAPIKey reports whether the config file already held a key when
46+
// InitConfig ran, before any --cli-key/--api-key flag was folded in.
47+
// Commands use it to tell "this machine already has a login" apart from
48+
// "the key came from this invocation's flag", which the coalesced
49+
// Profile.APIKey can no longer distinguish.
50+
HasStoredAPIKey bool
51+
4552
// Telemetry
4653
TelemetryDisabled bool
4754

@@ -338,6 +345,11 @@ func (c *Config) constructConfig() {
338345
// "workspace" > "team"
339346
// TODO: use "project" instead of "workspace"
340347
// TODO: use "cli_key" instead of "api_key"
348+
// Record whether a key was already on disk before the flag value wins the
349+
// coalesce below, so commands can distinguish an existing login from a
350+
// key supplied for this run only.
351+
c.HasStoredAPIKey = stringCoalesce(c.viper.GetString(c.Profile.getConfigField("api_key")), c.viper.GetString("api_key"), "") != ""
352+
341353
c.Profile.APIKey = stringCoalesce(c.Profile.APIKey, c.viper.GetString(c.Profile.getConfigField("api_key")), c.viper.GetString("api_key"), "")
342354

343355
c.Profile.ProjectId = stringCoalesce(c.Profile.ProjectId, c.viper.GetString(c.Profile.getConfigField("project_id")), c.viper.GetString("project_id"), c.viper.GetString(c.Profile.getConfigField("workspace_id")), c.viper.GetString(c.Profile.getConfigField("team_id")), c.viper.GetString("workspace_id"), "")

0 commit comments

Comments
 (0)