Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and the project follows [SemVer](https://semver.org/spec/v2.0.0.html).
## Unreleased

### Added
- **Report a bad render from the CLI.** Every render response now prints a
muted hint line with the render's id and the ready-to-run report command
(interactive, signed-in terminals only, and never for a cached render or a
request the API rejected: json/quiet/piped output is byte-identical).
`urlbox report <renderId>` files the report: `--category` + `--comment`
for agents and scripts, an interactive category picker + comment prompt
for humans. Reports need a signed-in session (`urlbox login`); render
secrets cannot file them.
- `urlbox support` opens the support contact page, with the dashboard
command's headless and machine-readable behaviour.
- **New-version notice.** Once a day, interactive runs check GitHub for a
newer release and print a one-line notice to stderr pointing at
`urlbox upgrade`. Text output to a terminal only: JSON, quiet, `--jq`,
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Canonical: `urlbox-cli-final-spec.md` in the internal monorepo (not mirrored her
Adding new entries is fine; removing or renaming triggers `make surface-check` failure.
After an intentional surface change, run `make surface-snapshot` and commit `SURFACE.txt` with the code.
- **Never commit autonomously.** Show diff + proposed message, wait for approval.
- **Planning docs are public.** Anything under `docs/` ships in a public repo. No internal infrastructure, services, repo names or paths, account details, or personal contact info.

## Workflow when adding a command
1. Write failing tests in `*_test.go` (envelope shape, exit codes, edge cases).
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,18 @@ urlbox status ps_abc123 --wait

Pass `--async` to queue a render and get a `renderId` back immediately. `status` checks it, and `status --wait` polls (every 2s by default) until it reaches a terminal state — `succeeded` or `failed`. Webhooks and long-running renders are covered at [urlbox.com/docs/cli/async-and-webhooks](https://urlbox.com/docs/cli/async-and-webhooks).

### Reporting a bad render

```sh
urlbox report 01a0906a-…_ps # interactive: category picker + comment
urlbox report 01a0906a-…_ps --category bot-detection --comment "page shows a captcha"
```

Every render prints its id in a hint line under the result. `report` files
the problem straight to the Urlbox team (requires `urlbox login`); the team
follows up on your report from the dashboard. `urlbox support` opens the
contact page for anything else.

### Account and context

```sh
Expand All @@ -131,6 +143,8 @@ urlbox usage # render usage for the current period
| Command | Does |
|---------|------|
| `login` / `logout` | Sign in through the browser; sign out and revoke this device's session |
| `report <renderId>` | File a render problem report with the Urlbox team (needs `login`) |
| `support` | Open the support contact page in your browser |
| `whoami` (alias `me`) | Show the signed-in user and active org/project |
| `orgs list` / `orgs select` | List or switch your active organisation (`--project` finishes the switch in one step) |
| `projects list` / `select` / `show` | Browse and switch the active project |
Expand Down
14 changes: 14 additions & 0 deletions SURFACE.txt
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,15 @@ urlbox render [url] --user-agent
urlbox render [url] --wait-until
urlbox render [url] --webhook-url
urlbox render [url] --width
urlbox report <renderId>
urlbox report <renderId> --agent
urlbox report <renderId> --category
urlbox report <renderId> --comment
urlbox report <renderId> --jq
urlbox report <renderId> --max-retries
urlbox report <renderId> --no-retry
urlbox report <renderId> --output-format
urlbox report <renderId> --profile
urlbox schema
urlbox schema --agent
urlbox schema --jq
Expand Down Expand Up @@ -627,6 +636,11 @@ urlbox storage update <name-or-id> --provider
urlbox storage update <name-or-id> --region
urlbox storage update <name-or-id> --sas-token
urlbox storage update <name-or-id> --secret
urlbox support
urlbox support --agent
urlbox support --jq
urlbox support --output-format
urlbox support --profile
urlbox upgrade
urlbox upgrade --agent
urlbox upgrade --jq
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion internal/api/download_client.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// internal/api/download_client.go — v1.0.4 Class 2.1.
// internal/api/download_client.go
//
// Hardened HTTP client for binary render-output fetches. Separate from
// the JSON-API client (NewHTTPClient) because:
Expand Down
2 changes: 1 addition & 1 deletion internal/api/download_client_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// internal/api/download_client_test.go — v1.0.4 Class 2.1.
// internal/api/download_client_test.go
//
// Pins the hardened render-download HTTP client contract: TLS 1.2 min,
// no non-http(s) redirects, no HTTPS→HTTP downgrades, no unbounded
Expand Down
55 changes: 43 additions & 12 deletions internal/api/http_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ import (
"github.com/urlbox/urlbox-cli/internal/version"
)

// Endpoint paths locked from urlbox-mono spec
// (apps/api/src/modules/render/render.routes.ts). Exported so callers like
// Endpoint paths locked to the Urlbox render API. Exported so callers like
// `urlbox render --curl` can reference the same paths the HTTPClient uses.
const (
// PathSync is the synchronous render endpoint.
Expand Down Expand Up @@ -131,7 +130,11 @@ func (c *HTTPClient) do(ctx context.Context, method, path string, body any) (*Re
}

if resp.StatusCode >= 400 {
return nil, mapStatusToCLIError(resp, respBody)
cliErr := mapStatusToCLIError(resp, respBody)
if rid := extractRequestID(respBody); rid != "" {
return nil, &RenderIDError{Err: cliErr, RenderID: rid}
}
return nil, cliErr
}

// Success path: the API returns the response body directly (no envelope
Expand All @@ -143,10 +146,10 @@ func (c *HTTPClient) do(ctx context.Context, method, path string, body any) (*Re
}
}

// If the API surfaced the upstream HTTP status (under data.response per
// urlbox-mono apps/api/src/lib/utils.ts:86-122), promote it to top-level
// agent-friendly fields. statusCodeInitial captures the first request's
// code before redirects — a 401→302→200 chain must mark upstreamOk=false.
// If the API surfaced the upstream HTTP status (under data.response),
// promote it to top-level agent-friendly fields. statusCodeInitial
// captures the first request's code before redirects — a 401→302→200
// chain must mark upstreamOk=false.
if respObj, ok := data["response"].(map[string]any); ok {
if status, ok := respObj["statusCode"].(float64); ok {
data["upstreamStatus"] = status
Expand All @@ -168,7 +171,12 @@ func (c *HTTPClient) do(ctx context.Context, method, path string, body any) (*Re
}
}

return &Response{OK: true, Data: data}, nil
return &Response{
OK: true,
Data: data,
RenderID: resp.Header.Get("x-urlbox-request-id"),
CacheHit: strings.EqualFold(resp.Header.Get("x-urlbox-cache-status"), "HIT"),
}, nil
}

// mapStatusToCLIError maps a non-2xx response to a typed *output.CLIError.
Expand Down Expand Up @@ -265,11 +273,10 @@ func isAuthErrorCode(code string) bool {
// option-validation failure. These come back as 4xx but should map to
// ErrValidation (not the generic ErrUsage default) so the v0.9.0 schema-as-docs
// contract is honored: --json passes through, and when the API rejects, the
// CLI surfaces a validation envelope. Mirrors the ClientError subclasses in
// urlbox-mono apps/api/src/lib/errors.ts (ValidateRequestErrors namespace
// + a few peers used at the same layer).
// CLI surfaces a validation envelope. Mirrors the API's request-validation
// error codes.
//
// Round 5 First-2: target-URL rejection codes (InvalidURLError) also
// Target-URL rejection codes (InvalidURLError) also
// land here. The user passed a syntactically-valid URL the API couldn't
// reach — ErrValidation ("your input was rejected") reads more
// accurately than ErrUsage ("you misused the CLI") for retry-logic and
Expand Down Expand Up @@ -330,3 +337,27 @@ func extractAPIError(body []byte) (msg, code string) {
}
return trimmed, code
}

// extractRequestID reads the requestId field Urlbox error bodies carry
// alongside the error object. Returns "" for non-JSON or absent field.
func extractRequestID(body []byte) string {
var parsed map[string]any
if err := json.Unmarshal(body, &parsed); err != nil {
return ""
}
rid, _ := parsed["requestId"].(string)
return rid
}

// RenderIDError decorates a CLIError from a failed render call with the
// render id the error body carried, so the render command can offer
// `urlbox report <id>` for the failed render. Unwrap keeps errors.As
// resolution to *output.CLIError intact everywhere else.
type RenderIDError struct {
Err *output.CLIError
RenderID string
}

func (e *RenderIDError) Error() string { return e.Err.Error() }

func (e *RenderIDError) Unwrap() error { return e.Err }
9 changes: 4 additions & 5 deletions internal/api/http_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,8 +327,7 @@ func TestHTTPClient_Render_400_ApiKeyNotFound_MapsToAuth(t *testing.T) {

// When the API response includes data.response.statusCode, propagate it
// through Response.Data so the render command can surface it in the
// envelope. The Urlbox API nests upstream status under "response" (see
// urlbox-mono apps/api/src/lib/utils.ts:86-122).
// envelope. The Urlbox API nests upstream status under "response".
func TestHTTPClient_Render_UpstreamStatus_Propagates(t *testing.T) {
m := apitest.New(apitest.SuccessJSON(`{
"renderUrl": "https://renders.urlbox.com/x.png",
Expand Down Expand Up @@ -451,8 +450,8 @@ func TestHTTPClient_Render_NonJSONErrorBody_FallsBackToBodyString(t *testing.T)
}
}

// TestHTTPClient_Render_InvalidURLError_MapsToValidation pins Round 5
// First-2: when the API returns HTTP 400 with apiCode="InvalidURLError"
// TestHTTPClient_Render_InvalidURLError_MapsToValidation pins a regression:
// when the API returns HTTP 400 with apiCode="InvalidURLError"
// (typical for unreachable target URLs like https://nonexistent.invalid),
// the CLI used to map it to ErrUsage (exit 1) — implying the user
// misused the CLI. But the user passed a syntactically-valid URL; the
Expand All @@ -472,7 +471,7 @@ func TestHTTPClient_Render_InvalidURLError_MapsToValidation(t *testing.T) {
t.Fatalf("err=%v, want *output.CLIError", err)
}
if cli.Code != output.ErrValidation {
t.Errorf("Code=%q, want %q (Round 5 First-2)", cli.Code, output.ErrValidation)
t.Errorf("Code=%q, want %q", cli.Code, output.ErrValidation)
}
if !strings.Contains(cli.Message, "Invalid URL") {
t.Errorf("Message=%q should surface the API's text", cli.Message)
Expand Down
94 changes: 94 additions & 0 deletions internal/api/render_id_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package api_test

import (
"context"
"errors"
"net/http"
"testing"

"github.com/urlbox/urlbox-cli/internal/api"
"github.com/urlbox/urlbox-cli/internal/api/apitest"
"github.com/urlbox/urlbox-cli/internal/output"
)

func TestRender_SyncSuccess_CapturesRenderIDHeader(t *testing.T) {
srv := apitest.New(apitest.ScriptedResponse{
Status: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"application/json"},
"X-Urlbox-Request-Id": []string{"01a0906a-fff5-7565-8dc5-02868f11a4fa_ps"},
},
Body: `{"renderUrl":"https://renders.urlbox.com/x.png","size":17752}`,
})
t.Cleanup(srv.Close)
c := api.NewHTTPClient(srv.URL(), "pk", "sk")
resp, err := c.Render(context.Background(), map[string]any{"url": "https://example.com"})
if err != nil {
t.Fatalf("Render: %v", err)
}
if resp.RenderID != "01a0906a-fff5-7565-8dc5-02868f11a4fa_ps" {
t.Fatalf("RenderID=%q", resp.RenderID)
}
if _, ok := resp.Data["renderId"]; ok {
t.Fatalf("renderId must NOT be injected into Data: %v", resp.Data)
}
}

func TestRender_SyncSuccess_NoHeader_RenderIDEmpty(t *testing.T) {
srv := apitest.New(apitest.SuccessJSON(`{"renderUrl":"https://renders.urlbox.com/x.png","size":1}`))
t.Cleanup(srv.Close)
c := api.NewHTTPClient(srv.URL(), "pk", "sk")
resp, err := c.Render(context.Background(), map[string]any{"url": "https://example.com"})
if err != nil {
t.Fatalf("Render: %v", err)
}
if resp.RenderID != "" {
t.Fatalf("RenderID=%q, want empty", resp.RenderID)
}
}

func TestRender_Failure_WrapsRenderIDError(t *testing.T) {
srv := apitest.New(apitest.ScriptedResponse{
Status: http.StatusBadRequest,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: `{"error":{"message":"Invalid URL","code":"InvalidURLError"},"requestId":"01a09082-25cf-74bd-9188-022c4f5bf25f_ps"}`,
})
t.Cleanup(srv.Close)
c := api.NewHTTPClient(srv.URL(), "pk", "sk")
c.Retry = api.NoRetryConfig()
_, err := c.Render(context.Background(), map[string]any{"url": "https://x.invalid"})
var ridErr *api.RenderIDError
if !errors.As(err, &ridErr) {
t.Fatalf("want RenderIDError, got %T: %v", err, err)
}
if ridErr.RenderID != "01a09082-25cf-74bd-9188-022c4f5bf25f_ps" {
t.Fatalf("RenderID=%q", ridErr.RenderID)
}
var cliErr *output.CLIError
if !errors.As(err, &cliErr) {
t.Fatalf("errors.As must still reach the CLIError through Unwrap")
}
if cliErr.Code != output.ErrValidation {
t.Fatalf("code=%q, want validation (InvalidURLError)", cliErr.Code)
}
}

func TestRender_Failure_NoRequestID_PlainCLIError(t *testing.T) {
srv := apitest.New(apitest.ScriptedResponse{
Status: http.StatusUnauthorized,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: `{"error":{"message":"Invalid token","code":"ApiKeyInvalid"}}`,
})
t.Cleanup(srv.Close)
c := api.NewHTTPClient(srv.URL(), "pk", "sk")
c.Retry = api.NoRetryConfig()
_, err := c.Render(context.Background(), map[string]any{"url": "https://example.com"})
var ridErr *api.RenderIDError
if errors.As(err, &ridErr) {
t.Fatalf("no requestId in body must yield a plain CLIError, got RenderIDError")
}
var cliErr *output.CLIError
if !errors.As(err, &cliErr) || cliErr.Code != output.ErrAuth {
t.Fatalf("want auth CLIError, got %v", err)
}
}
10 changes: 5 additions & 5 deletions internal/api/smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,11 @@ func TestSmoke_v090_PassthroughTotallyMadeUp(t *testing.T) {
// - message is non-empty and signals options-level rejection
// - hint is non-empty and points the agent at next steps
//
// What we CAN'T verify here (deferred to a follow-up urlbox-mono PR):
// the API's response body does not include `info.errors` (the Zod tree
// with field names) — only the generic "Invalid options, please check
// errors" message + the `InvalidOptions` code. Field-level detail would
// require an API change to add `info.errors` to the wire response.
// What we CAN'T verify here (would need an API-side change):
// the API's response body does not include field-level validation detail
// — only the generic "Invalid options, please check errors" message + the
// `InvalidOptions` code. Surfacing per-field errors would require the API
// to add them to the wire response.
func TestSmoke_v090_KnownBadType_APIReturnsMeaningfulError(t *testing.T) {
c := smokeClient(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
Expand Down
9 changes: 9 additions & 0 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ type Response struct {
Error string `json:"error,omitempty"`
Code string `json:"code,omitempty"`
Hint string `json:"hint,omitempty"`
// RenderID is the render id the API surfaced OUTSIDE the body — the
// x-urlbox-request-id response header. Sync render bodies carry no id,
// so this is the only place a sync render's id exists. In-memory only:
// json:"-" keeps envelopes unchanged.
RenderID string `json:"-"`
// CacheHit is true when the API served the render from cache
// (x-urlbox-cache-status: HIT). A cached response carries no render
// record of its own, so it cannot be reported.
CacheHit bool `json:"-"`
}

// Breadcrumb is a "next step" hint emitted alongside a successful Response.
Expand Down
2 changes: 1 addition & 1 deletion internal/api/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (

func TestResponse_JSON_RoundTrip(t *testing.T) {
// `data` payload uses camelCase to match the locked Urlbox API wire
// format (renderUrl, renderId — see urlbox-mono apps/api).
// format (renderUrl, renderId).
src := `{
"ok": true,
"command": "render",
Expand Down
2 changes: 1 addition & 1 deletion internal/browser/opener.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ var ErrUnopenableURL = errors.New("refused to open URL: scheme must be http or h
// via a hostile api_host that returned an arbitrary renderUrl) and
// the OS handler would happily launch executables for some of them.
//
// v1.0.4 Class 2.2: pre-1.0.4 Open() forwarded any string, turning
// Pre-1.0.4 Open() forwarded any string, turning
// --open into a remote-launch primitive on Windows (cmd /c start
// happily resolves UNC paths and file: schemes to executables).
func isOpenableURL(raw string) bool {
Expand Down
4 changes: 2 additions & 2 deletions internal/browser/opener_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ func TestNoopOpener_DoesNothing(t *testing.T) {
}
}

// ─── Class 2.2 (v1.0.4) ─── scheme check before OS exec ────────────
// ─── scheme check before OS exec ────────────
//
// Invariant: any URL handed to the OS URL handler is scheme-checked
// (http or https only) before exec.Command. Pre-v1.0.4 Open() forwarded
// any string. With a hostile api_host returning an attacker-controlled
// renderUrl, the Windows `cmd /c start "" <url>` path would launch
// UNC paths and file:// schemes — turning --open into a remote-launch
// primitive. Defense-in-depth: even if Class 1 (validation) closes
// primitive. Defense-in-depth: even if input validation closes
// every overlay/profile path today, this stops a future regression
// from re-arming the primitive.

Expand Down
2 changes: 0 additions & 2 deletions internal/cmd/auth_preflight.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
// internal/cmd/auth_preflight.go — v1.0.4 Class 5.
//
// Client-side pre-flight checks so predictable failures fail fast with
// the CLI's own vocabulary, not the API's. Pre-1.0.4 a missing-secret
// `urlbox render <url>` returned the API's confusing
Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,10 @@ func buildCommandInfo(cmd *cobra.Command) CommandInfo {
info.Flags = append(info.Flags, buildFlagInfo(f))
})

// Round 8 MM: recurse into sub-subcommands so agents reading
// Recurse into sub-subcommands so agents reading
// `urlbox commands --output-format json` see `config get`,
// `config profile create`, etc. — not just the top-level
// parent names. Adv-3 M1 flagged the drift between `commands`
// parent names. There was drift between `commands`
// and `surface`: surface listed all 263 invocations; commands
// listed 14 top-level only.
for _, c := range cmd.Commands() {
Expand Down
Loading
Loading