diff --git a/.agents/skills/hookdeck-cli-release/SKILL.md b/.agents/skills/hookdeck-cli-release/SKILL.md new file mode 100644 index 00000000..0fa560e8 --- /dev/null +++ b/.agents/skills/hookdeck-cli-release/SKILL.md @@ -0,0 +1,181 @@ +--- +name: hookdeck-cli-release +description: >- + Guides maintainers through Hookdeck CLI releases (stable GA, beta from main, + beta from feature branches) and user-centric GitHub release notes. Validates + proposed versions against SemVer from the actual change set (e.g. breaking + changes require a major bump). Use when cutting a release, publishing a tag, + drafting release notes, choosing vMAJOR.MINOR.PATCH, GoReleaser, npm publish, + pre-releases, following the release checklist, or `gh release create`. +--- + +# Hookdeck CLI — release workflow + +## Canonical documentation + +Follow **[README.md](../../README.md) § Releasing** for human-oriented steps (GitHub UI alternative, install commands for beta/stable). + +**Agents:** perform the **publish** step with the **GitHub CLI** (`gh`) — see **Publish with GitHub CLI (`gh`)** below (temp notes file → `gh release create` → remove temp file). + +This skill adds **how automation works**, **release note expectations**, and a **research loop** for drafting notes. + +## Agent checklist (end-to-end) + +Follow **in order**. Treat items with **gate** as blocking unless the maintainer explicitly overrides. + +- [ ] **Release shape:** GA from **`main`** vs beta from **`main`** vs beta from **feature branch** — matches [README § Releasing](../../README.md) and maintainer intent. +- [ ] **`PREV_TAG` / `NEW_TAG`:** Confirmed (or proposed and agreed); baseline tag is correct for the line of development (e.g. last GA vs beta series). +- [ ] **Change set:** Reviewed `git log PREV_TAG..HEAD` (and diff if needed); changes grouped for **user-facing** release notes (see **Research loop**). +- [ ] **SemVer gate:** Proposed `NEW_TAG` matches **minimum** MAJOR/MINOR/PATCH for the delta (see **SemVer: validate the proposed version**). Stop and realign if under-bumped. +- [ ] **Release notes:** Draft complete (see **Drafting release notes** and [references/release-notes-template.md](references/release-notes-template.md)); includes **Full Changelog** compare link; **contributor shout-outs only when warranted** (see that section). +- [ ] **CI gate:** Latest commit on the **target branch** has **green** GitHub checks (mandatory for GA on `main`; required for betas on the branch being tagged). +- [ ] **Approval:** Maintainer signed off on tag name, notes, and branch — no unilateral surprise tags. +- [ ] **Publish:** Write notes to a **temporary file**, run **`gh release create`** (see **Publish with GitHub CLI (`gh`)**), then **`rm`** the temp file. Use `--prerelease` for betas. (Humans may still use the GitHub UI per README.) +- [ ] **Post-publish (optional):** Confirm the **`release`** workflow in Actions completed successfully for the new tag. + +For commit-level detail while working through the checklist, use the **Research loop** below. + +## What triggers a release? + +- **[.github/workflows/release.yml](../../.github/workflows/release.yml)** runs on **`push` of tags** matching `v*` (not on ordinary branch pushes). +- Publishing a release in the GitHub UI (with a new tag) or `git push origin vX.Y.Z` both create that tag push and start the workflow. + +## What the workflow does (high level) + +1. **GoReleaser** (macOS, Linux, Windows jobs): builds binaries/archives, updates distribution channels per [.goreleaser/*.yml](../../.goreleaser/mac.yml) (Homebrew, Scoop, Docker, GitHub release artifacts). Config uses `release.mode: append` and `changelog.disable: true` — **GoReleaser does not write the release note body from git**; maintainers supply or edit the GitHub release description. +2. **`publish-npm` job**: Determines which branch contains the tag (prefers `main` / `master`, else first matching remote branch), checks out that branch, sets `package.json` version from the tag, builds npm binaries via GoReleaser, runs **`npm publish`** with `latest` for stable semver or a derived tag (e.g. `beta`) for pre-releases (see workflow `npm_tag` step). + +## Stable (GA) release + +- **Humans (README):** GitHub Releases → Draft → new tag `vM.m.p` → target **`main`** → notes → Publish. +- **Agents:** After gates pass, use **`gh release create`** with `--target main` and `--notes-file` (see **Publish with GitHub CLI (`gh`)**). +- **Do not publish a GA release until CI is green for `main`:** the **latest commit on `main`** must show successful checks in GitHub (same bar as README: ensure tests pass on `main`). Verify on the **Actions** tab (filter branch `main`, confirm the run for the tip of `main` succeeded) or via the commit’s status on github.com. +- **CLI check (optional):** after `git fetch origin main`, confirm combined status is `success` for `origin/main` (replace owner/repo if forked): + + ```bash + SHA=$(git rev-parse origin/main) + gh api "repos/hookdeck/hookdeck-cli/commits/${SHA}/status" --jq .state + ``` + + Do **not** tag or publish GA if this is `failure` or still `pending` for required work. + +- Stable tags drive **`latest`** on npm, stable Homebrew/Scoop formulas, Docker `latest`. + +## Pre-release (beta) + +- **From `main`:** Tag like `v1.3.0-beta.1`, target `main`, mark **pre-release** (`gh release create ... --prerelease`). Good for broad beta testing. **Still verify `main` is green** (same CI check as GA) before tagging. +- **From a feature branch:** Same tag pattern; **`--target `** so the workflow builds that tip. **Verify CI is green for that branch’s latest commit** before tagging. Add notes on **what to test** (betas often ship with minimal notes; still document intent). +- Install paths for beta: see README (npm `@beta`, `hookdeck-beta` brew/scoop, Docker image tag — **not** `latest` for beta). + +## Publish with GitHub CLI (`gh`) + +**Agents should create the release with `gh`**, not only push a bare tag. That creates the GitHub Release (with notes) and the tag together, which matches how maintainers expect the **`release`** workflow to run. + +1. **Create a temp file for notes** (never commit it). Register cleanup so the file is removed even if `gh` fails: + + ```bash + NOTES_FILE="$(mktemp "${TMPDIR:-/tmp}/hookdeck-cli-release-notes.XXXXXX.md")" + trap 'rm -f "$NOTES_FILE"' EXIT + ``` + +2. **Write** the final markdown body to `"$NOTES_FILE"` (same content you would paste in the GitHub UI). + +3. **Create the release** (run from a clone of `hookdeck/hookdeck-cli`, or use `--repo` as below): + + **Stable GA from `main`:** + + ```bash + gh release create "vM.m.p" \ + --repo hookdeck/hookdeck-cli \ + --target main \ + --title "vM.m.p" \ + --notes-file "$NOTES_FILE" + ``` + + **Pre-release (beta):** add `--prerelease`. **Feature branch:** set `--target ` instead of `main`. + +4. **Cleanup:** With `trap` above, the temp file is deleted on shell exit. If you did not use `trap`, run `rm -f "$NOTES_FILE"` after `gh` succeeds. + +**Requirements:** `gh` installed and authenticated (`gh auth login`). Do not put secrets in the notes file. + +### Fallback: tag without `gh` + +If `gh` is unavailable, a maintainer may use **README** flow (UI) or: + +```bash +git checkout +git tag vX.Y.Z[-beta.N] +git push origin vX.Y.Z[-beta.N] +``` + +Then **edit the GitHub release** to add notes, or create the release in the UI so assets and changelog align with team practice. + +## SemVer: validate the proposed version + +The user may suggest a tag (e.g. `v2.0.1`). **Always sanity-check it** against what actually changed since **`PREV_TAG`** (usually the last **GA** tag on that line of development—confirm with the maintainer for long beta series). + +**Interpret SemVer for this CLI (user-facing contract):** + +| Change since `PREV_TAG` | Bump | Examples | +|-------------------------|------|----------| +| **Breaking** — requires users to change scripts, configs, or habits | **MAJOR** | Removed or renamed commands/flags; different defaults that break automation; dropped or incompatible config file fields; incompatible change to documented machine-readable output | +| **New capability**, backward compatible | **MINOR** | New commands or flags; new subcommands; additive behavior; deprecations **announced** but old path still works | +| **Fixes / internal / docs-only** (no new user-facing capability, no break) | **PATCH** | Bug fixes; telemetry/CI; dependency bumps with no CLI contract change; help text clarifications | + +**Signals (hints only):** Conventional commits with `BREAKING CHANGE:` / `feat!:` / `fix!:` suggest severity—still **read the diff and release notes**; commits can be mis-tagged. + +**Pre-releases** (`v2.1.0-beta.1`): the **base version** (`2.1.0`) must still follow the table above relative to the last GA. A beta for a **major** rewrite should be `v3.0.0-beta.1`, not `v2.5.0-beta.1`, if the delta includes breaking changes vs `v2.x` GA. + +**Agent behavior:** + +1. After categorizing changes for release notes, state the **minimum** SemVer bump required. +2. Compare to the user’s proposed `NEW_TAG`. If they conflict (e.g. patch tag but breaking changes), **do not treat the user’s version as authoritative**—explain the mismatch and recommend the correct `vMAJOR.MINOR.PATCH` (and pre-release suffix if applicable). +3. If ambiguous (unclear whether a change breaks callers), **ask the maintainer** before tagging. + +## Drafting release notes (user-centric) + +Use **[references/release-notes-template.md](references/release-notes-template.md)** as a starting skeleton. + +**Sections:** Include only headings that have real content — **omit** empty sections (e.g. do not add “Breaking changes” with “None”). + +**Patterns observed in this repo:** + +- **Large GA (e.g. v2.0.0):** `Summary`, then as needed: `Breaking changes / migration`, `New features` (subsections per area), `Improvements / behavior changes`, `Internal` — skip any block with nothing to say. +- **Feature release (e.g. v1.9.0):** `## Features` with detailed bullets + **Full Changelog** compare link. +- **Patch (e.g. v1.9.1):** `## Fixes`, `## Updates`, PR links with authors + **Full Changelog** — omit unused sections. + +Always include a **Full Changelog** line: + +`https://github.com/hookdeck/hookdeck-cli/compare/...` + +**Contributors / shout-outs:** Do **not** add a generic “thanks to all contributors” block every release. **Regular maintainers and repeat contributors do not need a call-out.** Only include a contributor section when: + +- There are **new contributors** first shipping in this release (welcome them by name/GitHub handle), and/or +- Someone made an **exceptionally large** contribution worth highlighting for this specific release. + +Otherwise omit the **Contributors** section entirely. + +## Research loop (agent or maintainer) + +1. **Tags:** Confirm `PREV_TAG` and `NEW_TAG` with the user (or `git describe --tags --abbrev=0` on the release branch). For beta series, baseline may be last **GA** tag. +2. **Commits:** `git log PREV_TAG..HEAD --oneline` and read full messages. Treat **Conventional Commits** (`feat:`, `fix:`, `BREAKING CHANGE:`) as hints only — rewrite for **user impact** (commands, flags, migrations). +3. **Group:** Merge related commits; call out breaking changes and required user actions explicitly. +4. **SemVer check:** Using **SemVer: validate the proposed version**, classify the delta since `PREV_TAG` and verify the proposed `NEW_TAG` matches the required **MAJOR / MINOR / PATCH** bump. Flag mismatches before any tag or release. +5. **PRs / links:** Map commits to PRs (`gh pr list`, GitHub compare UI) for **PR links in the notes**. Use **Contributors** shout-outs only per **Drafting release notes** (new contributors or exceptional contribution—not every author every time). +6. **Sanity:** Skim diff or `REFERENCE.md` / user-facing help if commits are unclear. +7. **CI on GitHub (gate):** Before tagging, confirm the **branch you will release** (`main` for typical GA, or the feature branch for a branch beta) has **green checks on the latest commit** in GitHub Actions / commit status. For GA from `main`, treat this as **mandatory**; do not proceed on red or unknown pending required checks. + +## Safety and governance + +- **CI:** Do not cut a **stable GA** release unless **`main`’s latest run of checks** in GitHub is green (see **Stable (GA) release** and research step 7). For betas, require green CI for the **target branch** you are tagging. +- **SemVer:** Do not publish a tag that **under-bumps** the version for the change set (e.g. patch release that includes breaking CLI changes); resolve with the maintainer first. +- Do not push surprise tags; respect branch protection and team process. +- Never put secrets or tokens in release notes or skill content. + +## Related files + +| Topic | Location | +|--------|-----------| +| Maintainer steps, install commands | [README.md § Releasing](../../README.md) | +| CI entrypoint | [.github/workflows/release.yml](../../.github/workflows/release.yml) | +| Artifacts / brew / scoop / docker | [.goreleaser/](../../.goreleaser/) | diff --git a/.agents/skills/hookdeck-cli-release/references/release-notes-template.md b/.agents/skills/hookdeck-cli-release/references/release-notes-template.md new file mode 100644 index 00000000..ab40143d --- /dev/null +++ b/.agents/skills/hookdeck-cli-release/references/release-notes-template.md @@ -0,0 +1,41 @@ +# Release notes template (Hookdeck CLI) + +Copy and fill in for the GitHub release description (e.g. write into the temp file used with `gh release create --notes-file`). Adjust heading levels (`##` vs `###`) to match recent GA style (v2.0.0 used `###` subsections) or smaller patch style (v1.9.x used `##`). + +**Do not include a section if there is nothing to say** — omit the heading entirely (do not add “Breaking changes” with “None”, empty “Fixes”, etc.). Typical releases only need a subset of the sections below. + +## Summary + + + +## Breaking changes / migration + + + +## New features + + + +## Fixes + + + +## Improvements / behavior changes + + + +## Internal / reliability / infrastructure + + + +## Contributors (optional) + + + +**Full Changelog**: https://github.com/hookdeck/hookdeck-cli/compare/PREV_TAG...NEW_TAG diff --git a/.github/workflows/test-acceptance.yml b/.github/workflows/test-acceptance.yml index 11a40961..4adcc22f 100644 --- a/.github/workflows/test-acceptance.yml +++ b/.github/workflows/test-acceptance.yml @@ -14,7 +14,7 @@ jobs: include: - slice: "0" api_key_secret: HOOKDECK_CLI_TESTING_API_KEY - tags: "basic connection source mcp listen project_use connection_list connection_upsert connection_error_hints connection_oauth_aws connection_update" + tags: "basic guest connection source mcp listen project_use connection_list connection_upsert connection_error_hints connection_oauth_aws connection_update" - slice: "1" api_key_secret: HOOKDECK_CLI_TESTING_API_KEY_2 tags: "request event" diff --git a/.gitignore b/.gitignore index 4326d607..e0950864 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ test-scripts/.install-test/ # Claude Code temporary worktrees .claude/worktrees/ +.worktrees/ diff --git a/AGENTS.md b/AGENTS.md index c052a2b5..64dc062e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -566,6 +566,21 @@ default: --- +## CLI authentication keys (agents) + +Human-facing detail: **README.md § [CLI authentication keys](README.md#cli-authentication-keys)**. + +Summary for code and docs work: + +- **CLI client key** — Stored as `api_key` in config after successful `hookdeck login`, `hookdeck login --cli-key`, or `hookdeck ci`. Used for `cli` API auth (`GET /cli-auth/validate`, gateway commands, `listen`, etc.). +- **Claimed** — Key linked to user + project; validate succeeds. Keys from **Event Gateway dashboard onboarding** and **Console CLI destination** setup (`hookdeck login --cli-key …` in product UI) are claimed when shown. +- **Unclaimed** — Device/browser login (`POST /cli-auth`) before sign-in completes; poll until claimed. Not the usual case for UI copy-paste `--cli-key`. +- **Project API key** — Dashboard project settings key; passed to `hookdeck ci --api-key` / `HOOKDECK_API_KEY` only. Server returns a **CI CLI client key**; do not document root hidden `--api-key` / `--cli-key` as user-facing global flags. +- **Guest** — `listen` without login may call `POST /cli/guest`; separate from `--cli-key` onboarding. +- **`project list`** — Requires a user-associated CLI client key (`hookdeck login` or `hookdeck login --cli-key`). CI keys from `hookdeck ci` and raw Project API keys cannot list or switch projects (acceptance: `HOOKDECK_CLI_TESTING_CLI_KEY`). + +--- + ## Agent skills - **Location:** Repo-specific agent skills live under **`skills/`** at the repository root (e.g. `skills/hookdeck-cli-release/`). diff --git a/README.md b/README.md index a42eb4c5..f59ffea4 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ For a complete reference of all commands and flags, see [REFERENCE.md](REFERENCE - [Testing](#testing) - [Releasing](#releasing) - [Repository Setup](#repository-setup) +- [CLI authentication keys](#cli-authentication-keys) - [License](#license) **Quick links:** [Local development (Listen)](#listen) · [Resource management (CLI)](#event-gateway) / [Manage connections](#manage-connections) · [AI / agent integration (Event Gateway MCP)](#event-gateway-mcp) @@ -180,7 +181,15 @@ If you are in an environment without a browser (e.g., a TTY-only terminal), you hookdeck login --interactive ``` -> Login is optional, if you do not login a temporary guest account will be created for you when you run other commands. +To authenticate with a **CLI client key** from the Hookdeck product (no browser step when the key is already associated with your account and project): + +```sh +hookdeck login --cli-key +``` + +The CLI validates the key via the API and writes your config, replacing a guest Console profile if one exists. For example, Hookdeck may show this command during Event Gateway onboarding or when authorizing the CLI as a Console destination. + +> Guest sandbox upgrade (keeping Console data) requires `hookdeck login` without `--cli-key`, not product copy-paste keys. If you do not log in, a temporary **guest** account is created when you run commands such as `hookdeck listen`. ### Listen @@ -485,7 +494,7 @@ To install completions permanently, redirect the output to your shell's completi ### Running in CI -If you want to use Hookdeck in CI for tests or any other purposes, you can use your HOOKDECK_API_KEY to authenticate and start forwarding events. +If you want to use Hookdeck in CI for tests or any other purposes, authenticate with a Project API key from the dashboard. The `ci` command exchanges it for a CLI client key stored in your config. ```sh $ hookdeck ci --api-key $HOOKDECK_API_KEY @@ -817,11 +826,7 @@ Error: --local and --hookdeck-config flags cannot be used together ⚠️ **IMPORTANT**: Configuration files contain your Hookdeck credentials and should be treated as sensitive. -**Credential Types:** - -- **CLI Key**: Created when you run `hookdeck login` (interactive authentication) -- **CI Key**: Created in the Hookdeck dashboard for use in CI/CD pipelines -- Both are stored as `api_key` in config files +Config files store a **CLI client key** as `api_key` after `hookdeck login`, `hookdeck login --cli-key`, or `hookdeck ci`. **Recommended practices:** @@ -833,10 +838,9 @@ Error: --local and --hookdeck-config flags cannot be used together .hookdeck/ ``` -- **CI/CD environments**: Use the `HOOKDECK_API_KEY` environment variable: +- **CI/CD environments**: Use a Project API key via `HOOKDECK_API_KEY` and `hookdeck ci` (see [Running in CI](#running-in-ci)): ```sh - # The ci command automatically reads HOOKDECK_API_KEY - export HOOKDECK_API_KEY="your-ci-key" + export HOOKDECK_API_KEY="your-project-api-key" hookdeck ci hookdeck listen 3000 ``` @@ -1210,7 +1214,6 @@ hookdeck listen 3030 webhooks -p prod The following flags can be used with any command: -- `--api-key`: Your API key to use for the command. - `--color`: Turn on/off color output (on, off, auto). - `--hookdeck-config`: Path to the CLI configuration file. You can also set the `HOOKDECK_CONFIG_FILE` environment variable to the config file path. - `--device-name`: A unique name for your device. @@ -1218,7 +1221,9 @@ The following flags can be used with any command: - `--log-level`: Set the logging level (debug, info, warn, error). - `--profile` or `-p`: Use a specific configuration profile. -There are also some hidden flags that are mainly used for development and debugging: +Authentication uses command-specific flags (`hookdeck login --cli-key`, `hookdeck ci --api-key`, or `HOOKDECK_API_KEY`), not global flags. + +There are also hidden flags for development and debugging (not listed in user-facing help); for example `--api-base` sets the API base URL when testing against a local stack: * `--api-base`: Sets the API base URL. * `--dashboard-base`: Sets the web dashboard base URL. @@ -1582,6 +1587,51 @@ To maintain code quality and protect the main branch, configure the following se These settings ensure that all changes to `main` go through proper review and testing before being merged. +## CLI authentication keys + +Reference for how Hookdeck credentials relate to CLI commands. After any successful login or `hookdeck ci`, the CLI stores a **CLI client key** in your config file as `api_key` (see [Configuration files](#configuration-files)). The same field name is used regardless of how the key was obtained. + +### CLI client keys (what the CLI runs as) + +A **CLI client key** identifies the Hookdeck CLI to the API (`cli` authentication). It powers `hookdeck listen`, `hookdeck gateway …`, and most other commands after you are configured. + +| How you get it | Typical command | Server check | +|----------------|-----------------|--------------| +| Browser or device login | `hookdeck login` | Validate, or poll until fully associated (see below) | +| Product UI copy-paste | `hookdeck login --cli-key ` | Validate (user and project set at creation) | +| CI / automation | `hookdeck ci --api-key …` | Creates a team-scoped CLI client key (see below) | +| Guest sandbox | `hookdeck listen` (no prior login) | Guest user and project set at creation | + +Each CLI client key on the server has optional `user_id` and `team_id` fields. There are three association states: + +| State | `user_id` | `team_id` | When | +|-------|-----------|-----------|------| +| Pending device login | null | null | Start of `hookdeck login` browser flow, before you finish sign-in | +| CI / automation | null | set | After `hookdeck ci` (`POST /cli-auth/ci`) | +| Fully associated | set | set | Dashboard/Console UI keys, guest sandboxes, or after device login completes | + +- **Pending device login** — The CLI polls `GET /cli-auth/poll` until both `user_id` and `team_id` are set. `GET /cli-auth/validate` does not succeed until association is complete. +- **CI keys** — Scoped to a project (team) but not tied to a user. Validate works immediately; poll requires both fields, so CI keys are configured via validate, not poll. +- **Fully associated** — User and project are set. Validate works immediately. Keys from dashboard onboarding or Console CLI destination setup are fully associated when created. + +### Project API key (input to `hookdeck ci` only) + +A **Project API key** is a long-lived key from the Hookdeck dashboard (project settings). It is **not** what the CLI stores in config for day-to-day use. Pass it once to: + +```sh +hookdeck ci --api-key $HOOKDECK_API_KEY # or set HOOKDECK_API_KEY +``` + +The CLI calls `POST /cli-auth/ci` with that Project API key; the server returns a **CLI client key** scoped to that project. That returned key is saved as `api_key` in your config. Use `hookdeck listen` and gateway commands after `hookdeck ci`, not the original Project API key. + +### Guest credentials + +If you run `hookdeck listen` without an existing profile, the CLI can create a **guest** sandbox (`POST /cli/guest`). The config may include `guest_url` and an `api_key` for that sandbox. `hookdeck login` reuses that existing guest key and waits for the server to upgrade the guest account into a permanent account. + +### `project list` / `project use` + +Listing and switching projects requires a **user-associated** CLI client key (for example from `hookdeck login` or `hookdeck login --cli-key`). The server returns all projects your user can access. Keys from `hookdeck ci` are scoped to a single project with no user association—they cannot list or switch projects across your account. A Project API key alone is not sufficient either; use interactive login or a product-issued CLI key instead. + ## License Copyright (c) Hookdeck. All rights reserved. diff --git a/pkg/cmd/login.go b/pkg/cmd/login.go index 9d11363c..a3b59e48 100644 --- a/pkg/cmd/login.go +++ b/pkg/cmd/login.go @@ -16,6 +16,7 @@ type loginCmd struct { cmd *cobra.Command interactive bool local bool + cli_key string } func newLoginCmd() *loginCmd { @@ -25,14 +26,25 @@ func newLoginCmd() *loginCmd { Use: "login", Args: validators.NoArgs, Short: "Login to your Hookdeck account", - Long: `Login to your Hookdeck account to setup the CLI`, + Long: `Login to your Hookdeck account to setup the CLI. + +With a guest Console profile (after hookdeck listen), hookdeck login opens the browser to sign you +up and keep your sandbox data. + +Use --cli-key with a claimed CLI client key from the Hookdeck product (for example Event Gateway +onboarding or Console CLI authorization). The CLI validates the key and saves your config, replacing +a guest profile when present. Device login (hookdeck login without --cli-key) is required for guest +upgrade and sandbox retention.`, Example: ` $ hookdeck login + $ hookdeck login --cli-key + $ hookdeck logout && hookdeck login # existing Platform account $ hookdeck login -i # interactive mode (no browser) $ hookdeck login --local # save credentials to .hookdeck/config.toml`, RunE: lc.runLoginCmd, } lc.cmd.Flags().BoolVarP(&lc.interactive, "interactive", "i", false, "Run interactive configuration mode if you cannot open a browser") lc.cmd.Flags().BoolVar(&lc.local, "local", false, "Save credentials to current directory (.hookdeck/config.toml)") + lc.cmd.Flags().StringVar(&lc.cli_key, "cli-key", "", "CLI key from Hookdeck dashboard onboarding") return lc } @@ -42,8 +54,21 @@ func (lc *loginCmd) runLoginCmd(cmd *cobra.Command, args []string) error { return fmt.Errorf("Error: --local and --hookdeck-config flags cannot be used together\n --local creates config at: .hookdeck/config.toml\n --hookdeck-config uses custom path: %s", Config.ConfigFileFlag) } + cli_key := lc.cli_key + if cli_key == "" { + if cli_key_flag := cmd.Root().PersistentFlags().Lookup("cli-key"); cli_key_flag != nil && cli_key_flag.Changed { + cli_key = Config.Profile.APIKey + } + } + + if cli_key != "" && lc.interactive { + return fmt.Errorf("--cli-key cannot be used with --interactive") + } + var err error - if lc.interactive { + if cli_key != "" { + err = login.ConfigureFromClaimedCliKey(&Config, cli_key) + } else if lc.interactive { err = login.InteractiveLogin(&Config) } else { err = login.Login(&Config, os.Stdin) diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index a3a44e6e..8bf3c642 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -220,8 +220,7 @@ func init() { rootCmd.PersistentFlags().StringVarP(&Config.Profile.Name, "profile", "p", "", fmt.Sprintf("profile name (default \"%s\")", hookdeck.DefaultProfileName)) - rootCmd.PersistentFlags().StringVar(&Config.Profile.APIKey, "cli-key", "", "(deprecated) Your API key to use for the command") - rootCmd.PersistentFlags().MarkHidden("cli-key") + rootCmd.PersistentFlags().StringVar(&Config.Profile.APIKey, "cli-key", "", "Hookdeck CLI key (e.g. from dashboard onboarding or hookdeck login)") rootCmd.PersistentFlags().StringVar(&Config.Profile.APIKey, "api-key", "", "Your API key to use for the command") rootCmd.PersistentFlags().MarkHidden("api-key") diff --git a/pkg/config/config.go b/pkg/config/config.go index f57d26aa..21f2adf4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -222,11 +222,16 @@ func (c *Config) writeProjectConfig(configPath string, isNewFile bool) error { // Write config file using WriteConfigAs which explicitly takes a path // This avoids the viper internal "configPath" issue + v.SetConfigPermissions(os.FileMode(0600)) writeErr := v.WriteConfigAs(configPath) if writeErr != nil { return fmt.Errorf("failed to write config to %s: %w", configPath, writeErr) } + if chmodErr := os.Chmod(configPath, os.FileMode(0600)); chmodErr != nil { + return fmt.Errorf("failed to set permissions for %s: %w", configPath, chmodErr) + } + return nil } diff --git a/pkg/config/profile_credentials_test.go b/pkg/config/profile_credentials_test.go index 0a8d3e52..72772aa4 100644 --- a/pkg/config/profile_credentials_test.go +++ b/pkg/config/profile_credentials_test.go @@ -63,7 +63,7 @@ func TestProfile_ApplyPollAPIKeyResponse(t *testing.T) { require.Equal(t, "https://guest", p.GuestURL) }) - t.Run("clears-style guest with empty string", func(t *testing.T) { + t.Run("clears guest URL when empty string passed", func(t *testing.T) { p := &Profile{GuestURL: "old"} p.ApplyPollAPIKeyResponse(&hookdeck.PollAPIKeyResponse{ APIKey: "k123456789012", diff --git a/pkg/config/use_project_local_test.go b/pkg/config/use_project_local_test.go index 11922fae..8c1a3145 100644 --- a/pkg/config/use_project_local_test.go +++ b/pkg/config/use_project_local_test.go @@ -53,6 +53,10 @@ func TestUseProjectLocal(t *testing.T) { _, statErr := os.Stat(localConfigPath) require.NoError(t, statErr, "config.toml should be created") + info, statErr := os.Stat(localConfigPath) + require.NoError(t, statErr) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "local config should be owner-read/write only") + var configData map[string]interface{} _, decodeErr := toml.DecodeFile(localConfigPath, &configData) require.NoError(t, decodeErr, "config.toml should be valid TOML") diff --git a/pkg/gateway/mcp/tool_login.go b/pkg/gateway/mcp/tool_login.go index 8e314539..7e9bfeb8 100644 --- a/pkg/gateway/mcp/tool_login.go +++ b/pkg/gateway/mcp/tool_login.go @@ -117,10 +117,12 @@ func handleLogin(srv *Server) mcpsdk.ToolHandler { } deviceName, _ := os.Hostname() + if cfg.DeviceName == "" { + cfg.DeviceName = deviceName + } - // Initiate browser-based device auth flow. authClient := &hookdeck.Client{BaseURL: parsedBaseURL, TelemetryDisabled: cfg.TelemetryDisabled} - session, err := authClient.StartLogin(deviceName) + session, err := authClient.StartLogin(cfg.DeviceName) if err != nil { return ErrorResult(fmt.Sprintf("Failed to start login: %s", err)), nil } diff --git a/pkg/hookdeck/auth.go b/pkg/hookdeck/auth.go index ed59d7da..9d3560a4 100644 --- a/pkg/hookdeck/auth.go +++ b/pkg/hookdeck/auth.go @@ -21,6 +21,7 @@ type ValidateAPIKeyResponse struct { UserID string `json:"user_id"` UserName string `json:"user_name"` UserEmail string `json:"user_email"` + UserIsGuest bool `json:"user_is_guest"` OrganizationName string `json:"organization_name"` OrganizationID string `json:"organization_id"` ProjectID string `json:"team_id"` @@ -104,7 +105,8 @@ func (c *Client) StartLogin(deviceName string) (*LoginSession, error) { // StartGuestLogin initiates a guest login flow and returns a session to wait for completion func (c *Client) StartGuestLogin(deviceName string) (*GuestSession, error) { guest, err := c.CreateGuestUser(CreateGuestUserInput{ - DeviceName: deviceName, + DeviceName: deviceName, + LinkContext: "signup", }) if err != nil { return nil, err diff --git a/pkg/hookdeck/client.go b/pkg/hookdeck/client.go index e0fdfafa..88dbdb29 100644 --- a/pkg/hookdeck/client.go +++ b/pkg/hookdeck/client.go @@ -186,7 +186,7 @@ func (c *Client) PerformRequest(ctx context.Context, req *http.Request) (*http.R "prefix": "client.Client.PerformRequest", "method": req.Method, "url": req.URL.String(), - "headers": req.Header, + "headers": redactHeadersForLog(req.Header), } if req.Body != nil { @@ -199,7 +199,7 @@ func (c *Client) PerformRequest(ctx context.Context, req *http.Request) (*http.R // For now, just log and continue. } else { req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - logFields["body"] = string(bodyBytes) + logFields["body"] = redactRequestBodyForLog(string(bodyBytes)) } } log.WithFields(logFields).Debug("Performing request") diff --git a/pkg/hookdeck/guest.go b/pkg/hookdeck/guest.go index 482e8635..04dc133f 100644 --- a/pkg/hookdeck/guest.go +++ b/pkg/hookdeck/guest.go @@ -15,8 +15,16 @@ type GuestUser struct { PollURL string `json:"poll_url"` } +type GuestSigninLinkResponse struct { + // Id is the guest user id (not the sign-in token id). + Id string `json:"id"` + Url string `json:"link"` + ExpiresAt string `json:"expires_at"` +} + type CreateGuestUserInput struct { - DeviceName string `json:"device_name"` + DeviceName string `json:"device_name,omitempty"` + LinkContext string `json:"link_context,omitempty"` } func (c *Client) CreateGuestUser(input CreateGuestUserInput) (GuestUser, error) { @@ -29,9 +37,26 @@ func (c *Client) CreateGuestUser(input CreateGuestUserInput) (GuestUser, error) return GuestUser{}, err } if res.StatusCode != http.StatusOK { - return GuestUser{}, fmt.Errorf("unexpected http status code: %d %s", res.StatusCode, err) + return GuestUser{}, fmt.Errorf("unexpected http status code: %d", res.StatusCode) } guest_user := GuestUser{} postprocessJsonResponse(res, &guest_user) return guest_user, nil } + +func (c *Client) RefreshGuestSigninLink() (GuestSigninLinkResponse, error) { + input_bytes, err := json.Marshal(CreateGuestUserInput{LinkContext: "signup"}) + if err != nil { + return GuestSigninLinkResponse{}, err + } + res, err := c.Post(context.Background(), APIPathPrefix+"/cli/guest", input_bytes, nil) + if err != nil { + return GuestSigninLinkResponse{}, err + } + if res.StatusCode != http.StatusOK { + return GuestSigninLinkResponse{}, fmt.Errorf("unexpected http status code: %d", res.StatusCode) + } + response := GuestSigninLinkResponse{} + postprocessJsonResponse(res, &response) + return response, nil +} diff --git a/pkg/hookdeck/request_log_redact.go b/pkg/hookdeck/request_log_redact.go new file mode 100644 index 00000000..62255b06 --- /dev/null +++ b/pkg/hookdeck/request_log_redact.go @@ -0,0 +1,40 @@ +package hookdeck + +import ( + "encoding/json" + "net/http" +) + +func redactHeadersForLog(headers http.Header) http.Header { + if headers == nil { + return nil + } + + redacted := headers.Clone() + if redacted.Get("Authorization") != "" { + redacted.Set("Authorization", "[redacted]") + } + return redacted +} + +func redactRequestBodyForLog(body string) string { + if body == "" { + return body + } + + var parsed map[string]json.RawMessage + if err := json.Unmarshal([]byte(body), &parsed); err != nil { + return body + } + + if _, ok := parsed["guest_api_key"]; !ok { + return body + } + + parsed["guest_api_key"] = json.RawMessage(`"[redacted]"`) + redacted, err := json.Marshal(parsed) + if err != nil { + return body + } + return string(redacted) +} diff --git a/pkg/hookdeck/request_log_redact_test.go b/pkg/hookdeck/request_log_redact_test.go new file mode 100644 index 00000000..006f8dd9 --- /dev/null +++ b/pkg/hookdeck/request_log_redact_test.go @@ -0,0 +1,31 @@ +package hookdeck + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRedactHeadersForLog_redactsAuthorization(t *testing.T) { + headers := http.Header{} + headers.Set("Authorization", "Basic c2tfdGVzdDo=") + headers.Set("Content-Type", "application/json") + + redacted := redactHeadersForLog(headers) + require.Equal(t, "[redacted]", redacted.Get("Authorization")) + require.Equal(t, "application/json", redacted.Get("Content-Type")) + require.Equal(t, "Basic c2tfdGVzdDo=", headers.Get("Authorization")) +} + +func TestRedactRequestBodyForLog_redactsGuestAPIKey(t *testing.T) { + body := `{"device_name":"laptop","guest_api_key":"hk_secret"}` + redacted := redactRequestBodyForLog(body) + require.Contains(t, redacted, `"guest_api_key":"[redacted]"`) + require.NotContains(t, redacted, "hk_secret") +} + +func TestRedactRequestBodyForLog_leavesNonJSONUnchanged(t *testing.T) { + body := "guest_api_key=hk_secret" + require.Equal(t, body, redactRequestBodyForLog(body)) +} diff --git a/pkg/listen/listen.go b/pkg/listen/listen.go index e6bc0f74..8ddcb41e 100644 --- a/pkg/listen/listen.go +++ b/pkg/listen/listen.go @@ -73,7 +73,7 @@ func Listen(URL *url.URL, sourceQuery string, connectionFilterString string, fla return err } } else if config.Profile.GuestURL != "" && config.Profile.APIKey != "" { - // User is logged in with a guest account (has both GuestURL and APIKey) + // Guest profile: use saved URL at startup; interactive TUI refreshes async. guestURL = config.Profile.GuestURL } @@ -199,6 +199,7 @@ Specify a single destination to update the path. For example, pass a connection Connections: connections, Filters: flags.Filters, APIClient: apiClient, + AppConfig: config, } renderer := proxy.NewRenderer(rendererCfg) diff --git a/pkg/listen/proxy/renderer.go b/pkg/listen/proxy/renderer.go index 250d5fa0..f18d7e09 100644 --- a/pkg/listen/proxy/renderer.go +++ b/pkg/listen/proxy/renderer.go @@ -4,6 +4,7 @@ import ( "net/url" "time" + "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" "github.com/hookdeck/hookdeck-cli/pkg/websocket" ) @@ -59,6 +60,7 @@ type RendererConfig struct { Connections []*hookdeck.Connection Filters *hookdeck.SessionFilters APIClient *hookdeck.Client + AppConfig *config.Config } // NewRenderer creates the appropriate renderer based on output mode diff --git a/pkg/listen/proxy/renderer_interactive.go b/pkg/listen/proxy/renderer_interactive.go index 9cf1f076..51493855 100644 --- a/pkg/listen/proxy/renderer_interactive.go +++ b/pkg/listen/proxy/renderer_interactive.go @@ -39,6 +39,7 @@ func NewInteractiveRenderer(cfg *RendererConfig) *InteractiveRenderer { Connections: cfg.Connections, Filters: cfg.Filters, APIClient: cfg.APIClient, + AppConfig: cfg.AppConfig, } model := tui.NewModel(tuiCfg) diff --git a/pkg/listen/tui/model.go b/pkg/listen/tui/model.go index f23a6e4c..4e20404b 100644 --- a/pkg/listen/tui/model.go +++ b/pkg/listen/tui/model.go @@ -9,13 +9,16 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/login" "github.com/hookdeck/hookdeck-cli/pkg/websocket" ) const ( maxEvents = 1000 // Maximum events to keep in memory (all navigable) timeLayout = "2006-01-02 15:04:05" // Time format for display + guestURLRefreshInterval = 50 * time.Minute ) // EventInfo represents a single event with all its data @@ -87,6 +90,7 @@ type Config struct { Connections []*hookdeck.Connection Filters interface{} // Session filters (stored as interface{} to avoid circular dependency) APIClient *hookdeck.Client + AppConfig *configpkg.Config } // NewModel creates a new TUI model @@ -103,9 +107,11 @@ func NewModel(cfg *Config) Model { // Init initializes the model (required by Bubble Tea) func (m Model) Init() tea.Cmd { - return tea.Batch( - tickWaitingAnimation(), - ) + cmds := []tea.Cmd{tickWaitingAnimation()} + if m.cfg != nil && m.cfg.AppConfig != nil && m.cfg.AppConfig.Profile.GuestURL != "" { + cmds = append(cmds, refreshGuestURLCmd(m.cfg.AppConfig), tickGuestURLRefresh()) + } + return tea.Batch(cmds...) } // AddEvent adds a new event to the history @@ -419,6 +425,33 @@ func tickWaitingAnimation() tea.Cmd { }) } +// TickGuestURLRefreshMsg triggers a guest sign-in link refresh. +type TickGuestURLRefreshMsg struct{} + +// GuestURLRefreshedMsg carries an updated guest sign-in URL. +type GuestURLRefreshedMsg struct { + GuestURL string +} + +func tickGuestURLRefresh() tea.Cmd { + return tea.Tick(guestURLRefreshInterval, func(t time.Time) tea.Msg { + return TickGuestURLRefreshMsg{} + }) +} + +func refreshGuestURLCmd(app_config *configpkg.Config) tea.Cmd { + if app_config == nil || app_config.Profile.GuestURL == "" || app_config.Profile.APIKey == "" { + return nil + } + return func() tea.Msg { + guest_url := login.RefreshGuestSigninLink(app_config) + if guest_url == "" { + return nil + } + return GuestURLRefreshedMsg{GuestURL: guest_url} + } +} + // ServerHealthMsg is sent when server health status changes type ServerHealthMsg struct { Healthy bool diff --git a/pkg/listen/tui/update.go b/pkg/listen/tui/update.go index ba289d01..d1b2f793 100644 --- a/pkg/listen/tui/update.go +++ b/pkg/listen/tui/update.go @@ -72,6 +72,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case TickGuestURLRefreshMsg: + if m.cfg == nil { + return m, tickGuestURLRefresh() + } + return m, tea.Batch(refreshGuestURLCmd(m.cfg.AppConfig), tickGuestURLRefresh()) + + case GuestURLRefreshedMsg: + if msg.GuestURL != "" && m.cfg != nil { + m.cfg.GuestURL = msg.GuestURL + } + return m, nil + case retryResultMsg: // Retry completed (new attempt will arrive via websocket as a new event) return m, nil diff --git a/pkg/login/claimed_cli_key.go b/pkg/login/claimed_cli_key.go new file mode 100644 index 00000000..8200aeb7 --- /dev/null +++ b/pkg/login/claimed_cli_key.go @@ -0,0 +1,54 @@ +package login + +import ( + "fmt" + "os" + "strings" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +// ConfigureFromClaimedCliKey validates a product-issued CLI key (dashboard onboarding, Console +// destination, etc.) and saves the profile. Unlike Login(), this path does not start browser +// device auth or guest sandbox claim—even when the existing profile is a guest Console session. +func ConfigureFromClaimedCliKey(config *configpkg.Config, cli_key string) error { + cli_key = strings.TrimSpace(cli_key) + if cli_key == "" { + return fmt.Errorf("--cli-key is required") + } + if err := validators.APIKey(cli_key); err != nil { + return err + } + + config.Profile.APIKey = cli_key + + spinner := ansi.StartNewSpinner("Verifying credentials...", os.Stdout) + response, err := config.GetAPIClient().ValidateAPIKey() + if err != nil { + ansi.StopSpinner(spinner, "", os.Stdout) + return err + } + + message := SuccessMessage( + response.UserName, + response.UserEmail, + response.OrganizationName, + response.ProjectName, + response.ProjectMode == "console", + ) + ansi.StopSpinner(spinner, message, os.Stdout) + + config.Profile.ApplyValidateAPIKeyResponse(response, true) + + if err := config.Profile.SaveProfile(); err != nil { + return err + } + if err := config.Profile.UseProfile(); err != nil { + return err + } + config.RefreshCachedAPIClient() + + return nil +} diff --git a/pkg/login/claimed_cli_key_test.go b/pkg/login/claimed_cli_key_test.go new file mode 100644 index 00000000..dda7b466 --- /dev/null +++ b/pkg/login/claimed_cli_key_test.go @@ -0,0 +1,94 @@ +package login + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" +) + +func TestConfigureFromClaimedCliKey_guestProfileReplacesCredentials(t *testing.T) { + configpkg.ResetAPIClientForTesting() + t.Cleanup(configpkg.ResetAPIClientForTesting) + + onboardingKey := "hk_test_onboard_abcdefghij" + var serverURL string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.True(t, strings.HasSuffix(r.URL.Path, "/cli-auth/validate")) + + body, err := json.Marshal(map[string]string{ + "user_id": "usr_platform", + "user_name": "Platform User", + "user_email": "platform@example.com", + "organization_name": "Acme", + "organization_id": "org_1", + "team_id": "tm_gateway", + "team_name_no_org": "Production", + "team_mode": "inbound", + "client_id": "cl_onboard", + }) + require.NoError(t, err) + _, _ = w.Write(body) + })) + serverURL = ts.URL + t.Cleanup(ts.Close) + + configPath := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(`profile = "default" + +[default] +api_key = "hk_test_guestkey_abcdefghij" +guest_url = "https://console.test/signin/guest?token=abc" +`), 0o600)) + + cfg, err := configpkg.LoadConfigFromFile(configPath) + require.NoError(t, err) + cfg.APIBaseURL = serverURL + cfg.LogLevel = "error" + cfg.TelemetryDisabled = true + + err = ConfigureFromClaimedCliKey(cfg, onboardingKey) + require.NoError(t, err) + require.Equal(t, onboardingKey, cfg.Profile.APIKey) + require.Equal(t, "tm_gateway", cfg.Profile.ProjectId) + require.Empty(t, cfg.Profile.GuestURL) + + rewritten, err := os.ReadFile(configPath) + require.NoError(t, err) + require.Contains(t, string(rewritten), onboardingKey) + require.Contains(t, string(rewritten), "tm_gateway") + require.NotContains(t, string(rewritten), "usr_guest") +} + +func TestConfigureFromClaimedCliKey_validateFailureDoesNotStartDeviceLogin(t *testing.T) { + configpkg.ResetAPIClientForTesting() + t.Cleanup(configpkg.ResetAPIClientForTesting) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.True(t, strings.HasSuffix(r.URL.Path, "/cli-auth/validate")) + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(ts.Close) + + cfg := &configpkg.Config{ + APIBaseURL: ts.URL, + LogLevel: "error", + TelemetryDisabled: true, + } + cfg.Profile = configpkg.Profile{ + Name: "default", + GuestURL: "https://console.test/guest", + } + + err := ConfigureFromClaimedCliKey(cfg, "hk_test_invalid_abcdefghij") + require.Error(t, err) +} diff --git a/pkg/login/client_login.go b/pkg/login/client_login.go index 200c5a46..b00d4080 100644 --- a/pkg/login/client_login.go +++ b/pkg/login/client_login.go @@ -5,6 +5,7 @@ import ( "io" "net/url" "os" + "time" log "github.com/sirupsen/logrus" "golang.org/x/term" @@ -25,6 +26,9 @@ var stdinIsTerminal = func() bool { return term.IsTerminal(int(os.Stdin.Fd())) } +const guestUpgradePollInterval = 2 * time.Second +const guestUpgradeMaxAttempts = 2 * 60 + // Login function is used to obtain credentials via hookdeck dashboard. func Login(config *configpkg.Config, input io.Reader) error { var s *spinner.Spinner @@ -32,8 +36,7 @@ func Login(config *configpkg.Config, input io.Reader) error { if config.Profile.APIKey != "" { log.WithFields(log.Fields{ "prefix": "login.Login", - "APIKey": config.Profile.APIKey, - }).Debug("Logging in with API key") + }).Debug("Logging in with saved API key") s = ansi.StartNewSpinner("Verifying credentials...", os.Stdout) response, err := config.GetAPIClient().ValidateAPIKey() @@ -47,21 +50,25 @@ func Login(config *configpkg.Config, input io.Reader) error { fmt.Fprintln(os.Stdout, "Your saved API key is no longer valid. Starting browser sign-in...") config.Profile.APIKey = "" } else if response.UserID != "" { - message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectMode == "console") - ansi.StopSpinner(s, message, os.Stdout) + if config.Profile.GuestURL == "" || !response.UserIsGuest { + message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectMode == "console") + ansi.StopSpinner(s, message, os.Stdout) - config.Profile.ApplyValidateAPIKeyResponse(response, true) + config.Profile.ApplyValidateAPIKeyResponse(response, true) - if err = config.Profile.SaveProfile(); err != nil { - return err - } - if err = config.Profile.UseProfile(); err != nil { - return err - } + if err = config.Profile.SaveProfile(); err != nil { + return err + } + if err = config.Profile.UseProfile(); err != nil { + return err + } - config.RefreshCachedAPIClient() + config.RefreshCachedAPIClient() - return nil + return nil + } + ansi.StopSpinner(s, "", os.Stdout) + return waitForGuestUpgrade(config, input) } else { ansi.StopSpinner(s, "", os.Stdout) if !stdinIsTerminal() { @@ -87,6 +94,12 @@ func Login(config *configpkg.Config, input io.Reader) error { return err } + return waitForLoginSession(config, input, session) +} + +func waitForLoginSession(config *configpkg.Config, input io.Reader, session *hookdeck.LoginSession) error { + var s *spinner.Spinner + if isSSH() || !canOpenBrowser() { fmt.Printf("To authenticate with Hookdeck, please go to: %s\n", session.BrowserURL) @@ -97,7 +110,7 @@ func Login(config *configpkg.Config, input io.Reader) error { s = ansi.StartNewSpinner("Waiting for confirmation...", os.Stdout) - err = openBrowser(session.BrowserURL) + err := openBrowser(session.BrowserURL) if err != nil { msg := fmt.Sprintf("Failed to open browser, please go to %s manually.", session.BrowserURL) ansi.StopSpinner(s, msg, os.Stdout) @@ -225,3 +238,64 @@ func isSSH() bool { return false } + +func waitForGuestUpgrade(config *configpkg.Config, input io.Reader) error { + guestURL := RefreshGuestSigninLink(config) + if guestURL == "" { + return fmt.Errorf("unable to create guest sign-up link") + } + + var s *spinner.Spinner + if isSSH() || !canOpenBrowser() { + fmt.Printf("To create a permanent Hookdeck account, please go to: %s\n", guestURL) + s = ansi.StartNewSpinner("Waiting for account creation...", os.Stdout) + } else { + fmt.Printf("Press Enter to open the browser (^C to quit)") + fmt.Fscanln(input) + + s = ansi.StartNewSpinner("Waiting for account creation...", os.Stdout) + + err := openBrowser(guestURL) + if err != nil { + msg := fmt.Sprintf("Failed to open browser, please go to %s manually.", guestURL) + ansi.StopSpinner(s, msg, os.Stdout) + s = ansi.StartNewSpinner("Waiting for account creation...", os.Stdout) + } + } + + response, err := waitForGuestUpgradeCompletion(config) + if err != nil { + return err + } + + config.Profile.ApplyValidateAPIKeyResponse(response, true) + + if err = config.Profile.SaveProfile(); err != nil { + return err + } + if err = config.Profile.UseProfile(); err != nil { + return err + } + + config.RefreshCachedAPIClient() + + message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectMode == "console") + ansi.StopSpinner(s, message, os.Stdout) + + return nil +} + +func waitForGuestUpgradeCompletion(config *configpkg.Config) (*hookdeck.ValidateAPIKeyResponse, error) { + for attempt := 0; attempt < guestUpgradeMaxAttempts; attempt++ { + response, err := config.GetAPIClient().ValidateAPIKey() + if err != nil { + return nil, err + } + if !response.UserIsGuest { + return response, nil + } + time.Sleep(guestUpgradePollInterval) + } + + return nil, fmt.Errorf("exceeded max attempts waiting for guest account creation") +} diff --git a/pkg/login/client_login_test.go b/pkg/login/client_login_test.go index 5439fd51..c60c92f9 100644 --- a/pkg/login/client_login_test.go +++ b/pkg/login/client_login_test.go @@ -121,6 +121,88 @@ api_key = "hk_test_oldkey_abcdefghij" require.Equal(t, "hk_test_newkey_abcdefghij", cfg.Profile.APIKey) } +// TestLogin_guestProfileWithValidKeyStartsGuestUpgrade verifies that a guest Console +// profile with a still-valid API key opens a refreshed guest signup link and waits for +// the same key to validate as a permanent user. +func TestLogin_guestProfileWithValidKeyStartsGuestUpgrade(t *testing.T) { + configpkg.ResetAPIClientForTesting() + t.Cleanup(configpkg.ResetAPIClientForTesting) + + oldCan := canOpenBrowser + oldOpen := openBrowser + canOpenBrowser = func() bool { return false } + openBrowser = func(string) error { return nil } + t.Cleanup(func() { + canOpenBrowser = oldCan + openBrowser = oldOpen + }) + + validateHits := 0 + guestRefreshHits := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/cli-auth/validate"): + validateHits++ + resp := map[string]interface{}{ + "user_id": "usr_guest", + "user_name": "Guest", + "user_email": "guest@example.com", + "user_is_guest": validateHits == 1, + "organization_name": "Org", + "organization_id": "org_1", + "team_id": "tm_console", + "team_name_no_org": "Sandbox", + "team_mode": "console", + "client_id": "cl_guest", + } + enc, err := json.Marshal(resp) + require.NoError(t, err) + _, _ = w.Write(enc) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/cli/guest"): + guestRefreshHits++ + user, pass, ok := r.BasicAuth() + require.True(t, ok) + require.Equal(t, "hk_test_guestkey_abcdefghij", user) + require.Empty(t, pass) + var body map[string]string + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Equal(t, "signup", body["link_context"]) + enc, err := json.Marshal(map[string]string{ + "id": "usr_guest", + "key": "hk_test_guestkey_abcdefghij", + "link": "https://example.test/signin/guest?token=fresh&redirect=signup", + }) + require.NoError(t, err) + _, _ = w.Write(enc) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + t.Cleanup(ts.Close) + + configPath := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(`profile = "default" + +[default] +api_key = "hk_test_guestkey_abcdefghij" +guest_url = "https://console.test/signin/guest?token=abc" +`), 0o600)) + + cfg, err := configpkg.LoadConfigFromFile(configPath) + require.NoError(t, err) + cfg.APIBaseURL = ts.URL + cfg.DeviceName = "test-device" + cfg.LogLevel = "error" + cfg.TelemetryDisabled = true + + err = Login(cfg, strings.NewReader("\n")) + require.NoError(t, err) + require.Equal(t, 2, validateHits) + require.Equal(t, 1, guestRefreshHits) + require.Equal(t, "hk_test_guestkey_abcdefghij", cfg.Profile.APIKey) + require.Empty(t, cfg.Profile.GuestURL) +} + func TestLogin_ciKeyHeadlessFailsFast(t *testing.T) { configpkg.ResetAPIClientForTesting() t.Cleanup(configpkg.ResetAPIClientForTesting) diff --git a/pkg/login/guest_link.go b/pkg/login/guest_link.go new file mode 100644 index 00000000..749da340 --- /dev/null +++ b/pkg/login/guest_link.go @@ -0,0 +1,31 @@ +package login + +import ( + configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" + log "github.com/sirupsen/logrus" +) + +// RefreshGuestSigninLink mints a fresh guest sign-in URL for the active guest profile. +// On failure, returns the existing guest URL and logs a warning. +func RefreshGuestSigninLink(config *configpkg.Config) string { + if config == nil || config.Profile.GuestURL == "" || config.Profile.APIKey == "" { + return "" + } + + response, err := config.GetAPIClient().RefreshGuestSigninLink() + if err != nil { + log.WithError(err).Warn("Failed to refresh guest sign-in link; using saved URL") + return config.Profile.GuestURL + } + if response.Url == "" { + log.Warn("Guest sign-in link refresh returned empty link; using saved URL") + return config.Profile.GuestURL + } + + config.Profile.GuestURL = response.Url + if err := config.Profile.SaveProfile(); err != nil { + log.WithError(err).Warn("Refreshed guest sign-in link but failed to save profile") + } + + return config.Profile.GuestURL +} diff --git a/pkg/login/guest_link_test.go b/pkg/login/guest_link_test.go new file mode 100644 index 00000000..db5d8bc3 --- /dev/null +++ b/pkg/login/guest_link_test.go @@ -0,0 +1,161 @@ +package login + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/stretchr/testify/require" +) + +func TestRefreshGuestSigninLink_updatesProfileOnSuccess(t *testing.T) { + configpkg.ResetAPIClientForTesting() + t.Cleanup(configpkg.ResetAPIClientForTesting) + + signin_link_hits := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/cli/guest") { + signin_link_hits++ + user, pass, ok := r.BasicAuth() + require.True(t, ok) + require.Equal(t, "hk_test_guest_refresh_key12", user) + require.Empty(t, pass) + var input map[string]string + require.NoError(t, json.NewDecoder(r.Body).Decode(&input)) + require.Equal(t, "signup", input["link_context"]) + require.NotContains(t, input, "device_name") + body, err := json.Marshal(map[string]string{ + "id": "usr_guest_refresh", + "key": "hk_test_guest_refresh_key12", + "link": "https://api.example.test/signin/guest?token=fresh_token", + }) + require.NoError(t, err) + _, _ = w.Write(body) + return + } + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + })) + t.Cleanup(ts.Close) + + configPath := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(`profile = "default" + +[default] +api_key = "hk_test_guest_refresh_key12" +guest_url = "https://api.example.test/signin/guest?token=stale_token" +`), 0o600)) + + cfg, err := configpkg.LoadConfigFromFile(configPath) + require.NoError(t, err) + cfg.APIBaseURL = ts.URL + cfg.LogLevel = "error" + cfg.TelemetryDisabled = true + + got := RefreshGuestSigninLink(cfg) + require.Equal(t, 1, signin_link_hits) + require.Equal(t, "https://api.example.test/signin/guest?token=fresh_token", got) + require.Equal(t, got, cfg.Profile.GuestURL) + + reloaded, err := os.ReadFile(configPath) + require.NoError(t, err) + require.Contains(t, string(reloaded), "fresh_token") +} + +func TestRefreshGuestSigninLink_fallsBackToSavedURLOnAPIError(t *testing.T) { + configpkg.ResetAPIClientForTesting() + t.Cleanup(configpkg.ResetAPIClientForTesting) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/cli/guest") { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"server boom"}`)) + return + } + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + })) + t.Cleanup(ts.Close) + + saved_url := "https://api.example.test/signin/guest?token=stale_token" + configPath := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(`profile = "default" + +[default] +api_key = "hk_test_guest_refresh_key12" +guest_url = "`+saved_url+`" +`), 0o600)) + + cfg, err := configpkg.LoadConfigFromFile(configPath) + require.NoError(t, err) + cfg.APIBaseURL = ts.URL + cfg.LogLevel = "error" + cfg.TelemetryDisabled = true + + got := RefreshGuestSigninLink(cfg) + require.Equal(t, saved_url, got) + require.Equal(t, saved_url, cfg.Profile.GuestURL) +} + +func TestRefreshGuestSigninLink_preservesSavedURLOnEmptyLink(t *testing.T) { + configpkg.ResetAPIClientForTesting() + t.Cleanup(configpkg.ResetAPIClientForTesting) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/cli/guest") { + body, err := json.Marshal(map[string]string{ + "id": "usr_guest_refresh", + "link": "", + }) + require.NoError(t, err) + _, _ = w.Write(body) + return + } + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + })) + t.Cleanup(ts.Close) + + saved_url := "https://api.example.test/signin/guest?token=stale_token" + configPath := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(`profile = "default" + +[default] +api_key = "hk_test_guest_refresh_key12" +guest_url = "`+saved_url+`" +`), 0o600)) + + cfg, err := configpkg.LoadConfigFromFile(configPath) + require.NoError(t, err) + cfg.APIBaseURL = ts.URL + cfg.LogLevel = "error" + cfg.TelemetryDisabled = true + + got := RefreshGuestSigninLink(cfg) + require.Equal(t, saved_url, got) + require.Equal(t, saved_url, cfg.Profile.GuestURL) + + reloaded, err := os.ReadFile(configPath) + require.NoError(t, err) + require.Contains(t, string(reloaded), "stale_token") +} + +func TestRefreshGuestSigninLink_returnsEmptyWithoutGuestProfile(t *testing.T) { + cfg := &configpkg.Config{ + LogLevel: "error", + TelemetryDisabled: true, + } + cfg.Profile = configpkg.Profile{ + Name: "default", + APIKey: "hk_test_guest_refresh_key12", + Config: cfg, + } + + require.Empty(t, RefreshGuestSigninLink(cfg)) + + cfg.Profile.GuestURL = "https://example.test/signin/guest?token=x" + cfg.Profile.APIKey = "" + require.Empty(t, RefreshGuestSigninLink(cfg)) +} diff --git a/test/acceptance/README.md b/test/acceptance/README.md index f4ecd2fd..d0c3f163 100644 --- a/test/acceptance/README.md +++ b/test/acceptance/README.md @@ -13,6 +13,8 @@ These tests run automatically in CI using API keys from `hookdeck ci`. They don' **Login recovery (mock API, `basic` tag):** `login_auth_acceptance_test.go` runs the real CLI with `--api-base` pointing at a local server that returns **401** on `GET .../cli-auth/validate`, then completes a fake device-auth poll — this asserts `hookdeck login` continues into browser/device flow after a stale key (no human, no real Hookdeck key). The same file includes **`TestCIFailsFastWithInvalidAPIKeyAcceptance`**, which runs `hookdeck ci --api-key` with a bogus key against the real API and expects a quick failure with the friendly **Authentication failed** message, and asserts output does **not** contain browser/device-login phrases (`Press Enter to open the browser`, `To authenticate with Hookdeck`, etc.) so CI never enters the interactive `hookdeck login` flow. +**Guest login (mock API, `guest` tag):** `guest_login_acceptance_test.go` asserts `POST /cli-auth` receives guest credentials when a guest profile is present, and omits them after logout (empty profile). + ### 2. Manual Tests (Require Human Interaction) These tests require browser-based authentication via `hookdeck login` and must be run manually by developers. @@ -28,7 +30,9 @@ These tests require browser-based authentication via `hookdeck login` and must b Some tests (e.g. `TestTelemetryGatewayConnectionListProxy` in `telemetry_test.go`, `TestTelemetryListenProxy` in `telemetry_listen_test.go`) use a **recording proxy**: the CLI is run with `--api-base` pointing at a local HTTP server that forwards every request to the real Hookdeck API and records method, path, and the `X-Hookdeck-CLI-Telemetry` header. The same `CLIRunner` and `go run main.go` flow are used as in other acceptance tests; only the API base URL is overridden so traffic goes through the proxy. This verifies that a single CLI run sends consistent telemetry (same `invocation_id` and `command_path`) on all API calls. Helpers: `StartRecordingProxy`, `AssertTelemetryConsistent`. -**Login telemetry tests** use the same proxy approach with **HOOKDECK_CLI_TESTING_CLI_KEY** (not the API/CI key), because the validate endpoint accepts CLI keys from interactive login; if unset, those tests are skipped. **TestTelemetryLoginProxy** runs `hookdeck login --api-key KEY` with `--api-base` set to the proxy and asserts exactly one recorded request (GET `/2025-07-01/cli-auth/validate`) with consistent telemetry. **TestTelemetryLoginCommandFlagsProxy** additionally asserts the telemetry JSON includes **`command_flags`** containing **`api-key`** or **`cli-key`** on the wire when that flag is passed. Other telemetry tests still use the normal API key via `NewCLIRunner`. +**Login telemetry tests** use the same proxy approach with **HOOKDECK_CLI_TESTING_CLI_KEY** (a CLI client key, not a Project API key used with `hookdeck ci`). If unset, those tests are skipped. **TestTelemetryLoginProxy** runs `hookdeck login --api-key KEY` with `--api-base` set to the proxy and asserts exactly one recorded request (GET `/2025-07-01/cli-auth/validate`) with consistent telemetry. **TestTelemetryLoginCommandFlagsProxy** additionally asserts the telemetry JSON includes **`command_flags`** containing **`api-key`** or **`cli-key`** on the wire when that flag is passed. Other telemetry tests still use the normal Project API key via `NewCLIRunner`. + +See **README.md § [CLI authentication keys](../README.md#cli-authentication-keys)** for claimed vs unclaimed keys and how Project API keys relate to `hookdeck ci`. ## Setup @@ -39,8 +43,8 @@ For local testing, create a `.env` file in this directory: ```bash # test/acceptance/.env HOOKDECK_CLI_TESTING_API_KEY=your_api_key_here -# Optional: CLI key (from interactive login) required for project list tests only -# HOOKDECK_CLI_TESTING_CLI_KEY=your_cli_key_here +# Optional: CLI client key for project list tests only (claimed key; see README § CLI authentication keys) +# HOOKDECK_CLI_TESTING_CLI_KEY=your_cli_client_key_here ``` The `.env` file is automatically loaded when tests run. **This file is git-ignored and should never be committed.** @@ -65,27 +69,27 @@ No test-name list in the workflow—tests are partitioned by **feature tags** (s ### Run all automated tests (one key) Pass all feature tags so every automated test file is included: ```bash -go test -tags="basic connection source destination gateway mcp listen project_use connection_list connection_upsert connection_error_hints connection_oauth_aws connection_update request event telemetry attempt metrics issue transformation" ./test/acceptance/... -v +go test -tags="basic guest connection source destination gateway mcp listen project_use connection_list connection_upsert connection_error_hints connection_oauth_aws connection_update request event telemetry attempt metrics issue transformation" ./test/acceptance/... -v ``` ### Run one slice (for CI or local) Same commands as CI; use when debugging a subset or running in parallel: ```bash # Slice 0 (same tags as CI job 0) -ACCEPTANCE_SLICE=0 HOOKDECK_CLI_TELEMETRY_DISABLED=1 go test -tags="basic connection source mcp listen project_use connection_list connection_upsert connection_error_hints connection_oauth_aws connection_update" ./test/acceptance/... -v -timeout 12m +ACCEPTANCE_SLICE=0 HOOKDECK_CLI_TELEMETRY_DISABLED=1 go test -tags="basic guest connection source mcp listen project_use connection_list connection_upsert connection_error_hints connection_oauth_aws connection_update" ./test/acceptance/... -v -timeout 12m # Slice 1 (same tags as CI job 1) ACCEPTANCE_SLICE=1 HOOKDECK_CLI_TELEMETRY_DISABLED=1 go test -tags="request event" ./test/acceptance/... -v -timeout 12m # Slice 2 (same tags as CI job 2) -ACCEPTANCE_SLICE=2 HOOKDECK_CLI_TELEMETRY_DISABLED=1 go test -tags="attempt metrics issue transformation" ./test/acceptance/... -v -timeout 12m +ACCEPTANCE_SLICE=2 HOOKDECK_CLI_TELEMETRY_DISABLED=1 go test -tags="attempt metrics issue transformation destination gateway" ./test/acceptance/... -v -timeout 12m # Telemetry (same as CI acceptance-telemetry: force telemetry on) ACCEPTANCE_SLICE=0 HOOKDECK_CLI_TELEMETRY_DISABLED=0 go test -tags=telemetry ./test/acceptance/... -v -timeout 12m ``` For slice 1 set `HOOKDECK_CLI_TESTING_API_KEY_2`; for slice 2 set `HOOKDECK_CLI_TESTING_API_KEY_3` (or set `HOOKDECK_CLI_TESTING_API_KEY` to that key). For telemetry, use the slice 0 key and set `HOOKDECK_CLI_TELEMETRY_DISABLED=0` (overrides a global opt-out). -**Project list tests** (`TestProjectListShowsType`, `TestProjectListJSONOutput`) require a **CLI key**, not an API or CI key: only keys created via interactive login can list or switch projects. Set `HOOKDECK_CLI_TESTING_CLI_KEY` in your `.env` (or environment) to run these tests; if unset, they are skipped with a clear message. +**Project list tests** (`TestProjectListShowsType`, `TestProjectListJSONOutput`, and related filters) require a **CLI client key** (`HOOKDECK_CLI_TESTING_CLI_KEY`), not the Project API key used with `hookdeck ci`. Claimed keys from dashboard onboarding or Console CLI destination setup work; unclaimed device-auth keys are not suitable. If unset, these tests are skipped. See **README.md § [CLI authentication keys](../README.md#cli-authentication-keys)**. ### Run in parallel locally (three keys) From the **repository root**, run the script that runs three matrix slices plus telemetry in parallel (same as CI): @@ -114,7 +118,7 @@ Use the same `-tags` as "Run all" if you want to skip the full acceptance set. A Tests are partitioned by **feature build tags** so CI and local runs can execute three matrix slices in parallel (each slice uses its own Hookdeck project and config file). -- **Slice 0 features:** `basic`, `connection`, `source`, `mcp`, `listen`, `project_use`, `connection_list`, `connection_upsert`, `connection_error_hints`, `connection_oauth_aws`, `connection_update` +- **Slice 0 features:** `basic`, `guest`, `connection`, `source`, `mcp`, `listen`, `project_use`, `connection_list`, `connection_upsert`, `connection_error_hints`, `connection_oauth_aws`, `connection_update` - **Slice 1 features:** `request`, `event` - **Slice 2 features:** `attempt`, `metrics`, `issue`, `transformation`, `destination`, `gateway` - **Telemetry job:** `telemetry` only — separate CI job with telemetry **not** disabled (see [CI/CD](#cicd)) diff --git a/test/acceptance/guest_login_acceptance_test.go b/test/acceptance/guest_login_acceptance_test.go new file mode 100644 index 00000000..893f7c08 --- /dev/null +++ b/test/acceptance/guest_login_acceptance_test.go @@ -0,0 +1,201 @@ +//go:build guest + +package acceptance + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func runGuestLoginCLI(t *testing.T, projectRoot, configPath, serverURL string, extraArgs ...string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + mainGo := filepath.Join(projectRoot, "main.go") + args := append([]string{"run", mainGo, + "--api-base", serverURL, + "--hookdeck-config", configPath, + "--log-level", "error", + "login", + }, extraArgs...) + + cmd := exec.CommandContext(ctx, "go", args...) + cmd.Dir = projectRoot + env := appendEnvOverride(os.Environ(), "HOOKDECK_CONFIG_FILE", configPath) + env = appendEnvOverride(env, "SSH_CONNECTION", "acceptance-guest-login-mock") + env = appendEnvOverride(env, "HOOKDECK_CLI_TELEMETRY_DISABLED", "1") + cmd.Stdin = strings.NewReader("\n") + cmd.Env = env + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + require.NoError(t, err, "stdout=%q stderr=%q", stdout.String(), stderr.String()) +} + +func newGuestLoginMock(t *testing.T, assertBody func(map[string]interface{}), browserURL string) (*httptest.Server, string) { + t.Helper() + var serverURL string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/cli-auth/validate"): + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("Unauthorized")) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/cli/guest"): + body, encErr := json.Marshal(map[string]string{ + "id": "usr_guest_accept", + "key": "hk_guest_accept_key", + "link": "https://example.test/signin/guest?token=guest", + }) + require.NoError(t, encErr) + _, _ = w.Write(body) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/cli-auth"): + raw, readErr := io.ReadAll(r.Body) + require.NoError(t, readErr) + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(raw, &payload)) + assertBody(payload) + pollURL := serverURL + "/2025-07-01/cli-auth/poll?key=pollkey" + respBody, encErr := json.Marshal(map[string]string{ + "browser_url": browserURL, + "poll_url": pollURL, + }) + require.NoError(t, encErr) + _, _ = w.Write(respBody) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/cli-auth/poll"): + resp := map[string]interface{}{ + "claimed": true, + "key": "hk_test_guest_claimed", + "team_id": "tm_guest", + "team_mode": "console", + "team_name": "Guest Sandbox", + "user_name": "Guest", + "user_email": "guest@example.com", + "organization_name": "GuestOrg", + "organization_id": "org_guest", + "client_id": "cl_guest", + } + enc, encErr := json.Marshal(resp) + require.NoError(t, encErr) + _, _ = w.Write(enc) + default: + t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + serverURL = ts.URL + t.Cleanup(ts.Close) + return ts, serverURL +} + +func guestProfileConfig() string { + return `profile = "default" + +[default] +api_key = "hk_test_stale_guest01" +guest_url = "https://example.test/signin/guest?token=guest" +` +} + +func loggedOutProfileConfig() string { + return `profile = "default" + +[default] +` +} + +func TestGuestLoginDefaultClaimGuestAcceptance(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + projectRoot, err := filepath.Abs("../..") + require.NoError(t, err) + + configPath := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(guestProfileConfig()), 0o600)) + + validateHits := 0 + guestRefreshHits := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/cli-auth/validate"): + validateHits++ + body, encErr := json.Marshal(map[string]interface{}{ + "user_id": "usr_guest_accept", + "user_name": "Guest", + "user_email": "guest@example.com", + "user_is_guest": validateHits == 1, + "organization_name": "GuestOrg", + "organization_id": "org_guest", + "team_id": "tm_guest", + "team_name_no_org": "Guest Sandbox", + "team_mode": "console", + "client_id": "cl_guest", + }) + require.NoError(t, encErr) + _, _ = w.Write(body) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/cli/guest"): + guestRefreshHits++ + user, pass, ok := r.BasicAuth() + require.True(t, ok) + require.Equal(t, "hk_test_stale_guest01", user) + require.Empty(t, pass) + + var payload map[string]interface{} + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + require.Equal(t, "signup", payload["link_context"]) + require.NotContains(t, payload, "device_name") + + body, encErr := json.Marshal(map[string]string{ + "id": "usr_guest_accept", + "key": "hk_test_stale_guest01", + "link": "https://example.test/signin/guest?token=fresh&redirect=signup", + }) + require.NoError(t, encErr) + _, _ = w.Write(body) + default: + t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + serverURL := ts.URL + t.Cleanup(ts.Close) + + runGuestLoginCLI(t, projectRoot, configPath, serverURL) + require.Equal(t, 2, validateHits) + require.Equal(t, 1, guestRefreshHits) +} + +func TestGuestLoginAfterLogoutOmitsGuestCredentialsAcceptance(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + projectRoot, err := filepath.Abs("../..") + require.NoError(t, err) + + configPath := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(loggedOutProfileConfig()), 0o600)) + + ts, serverURL := newGuestLoginMock(t, func(body map[string]interface{}) { + require.NotContains(t, body, "auth_intent") + require.NotContains(t, body, "guest_user_id") + require.NotContains(t, body, "guest_api_key") + }, "https://example.test/signin?redirect=%2Fcli-auth%2Fkey") + + runGuestLoginCLI(t, projectRoot, configPath, serverURL) + _ = ts +} diff --git a/test/acceptance/run_parallel.sh b/test/acceptance/run_parallel.sh index 9c7beb65..9104ee65 100755 --- a/test/acceptance/run_parallel.sh +++ b/test/acceptance/run_parallel.sh @@ -29,7 +29,7 @@ SLICE1_LOG="$LOG_DIR/slice1.log" SLICE2_LOG="$LOG_DIR/slice2.log" TELEMETRY_LOG="$LOG_DIR/telemetry.log" -SLICE0_TAGS="basic connection source mcp listen project_use connection_list connection_upsert connection_error_hints connection_oauth_aws connection_update" +SLICE0_TAGS="basic guest connection source mcp listen project_use connection_list connection_upsert connection_error_hints connection_oauth_aws connection_update" SLICE1_TAGS="request event" SLICE2_TAGS="attempt metrics issue transformation destination gateway"