Skip to content
Draft
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
6 changes: 3 additions & 3 deletions internal/commands/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func newAPIPostCmd() *cobra.Command {

resp, err := app.Account().Post(cmd.Context(), path, body)
if err != nil {
return err
return convertSDKError(err)
}

summary := fmt.Sprintf("POST %s: %s", path, apiSummary(resp.Data))
Expand Down Expand Up @@ -147,7 +147,7 @@ func newAPIPutCmd() *cobra.Command {

resp, err := app.Account().Put(cmd.Context(), path, body)
if err != nil {
return err
return convertSDKError(err)
}

summary := fmt.Sprintf("PUT %s: %s", path, apiSummary(resp.Data))
Expand Down Expand Up @@ -179,7 +179,7 @@ func newAPIDeleteCmd() *cobra.Command {
path := parsePath(args[0])
resp, err := app.Account().Delete(cmd.Context(), path)
if err != nil {
return err
return convertSDKError(err)
}

summary := fmt.Sprintf("DELETE %s", path)
Expand Down
23 changes: 23 additions & 0 deletions internal/commands/api_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package commands

import (
"errors"
"testing"

"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/basecamp/basecamp-cli/internal/output"
)

func TestParsePath(t *testing.T) {
Expand Down Expand Up @@ -47,3 +51,22 @@ func TestAPIPathArgs(t *testing.T) {
assert.Contains(t, err.Error(), "path required")
})
}

func TestConvertSDKErrorPreservesRequestID(t *testing.T) {
sdkErr := &basecamp.Error{
Code: basecamp.CodeAPI,
Message: "server error",
HTTPStatus: 500,
RequestID: "req-cli-123",
}

err := convertSDKError(sdkErr)

var outErr *output.Error
require.True(t, errors.As(err, &outErr), "expected *output.Error, got %T", err)
assert.Equal(t, output.CodeAPI, outErr.Code)

var gotSDK *basecamp.Error
require.True(t, errors.As(err, &gotSDK), "expected wrapped *basecamp.Error, got %T", err)
assert.Equal(t, "req-cli-123", gotSDK.RequestID)
}
1 change: 1 addition & 0 deletions internal/commands/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ func convertSDKError(err error) error {
Hint: sdkErr.Hint,
HTTPStatus: sdkErr.HTTPStatus,
Retryable: sdkErr.Retryable,
Cause: sdkErr,
}
Comment thread
robzolkos marked this conversation as resolved.
}
return err
Expand Down
1 change: 1 addition & 0 deletions internal/names/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,7 @@ func convertSDKError(err error) error {
Hint: sdkErr.Hint,
HTTPStatus: sdkErr.HTTPStatus,
Retryable: sdkErr.Retryable,
Cause: sdkErr,
}
}
return err
Expand Down
6 changes: 6 additions & 0 deletions internal/output/envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@ func (w *Writer) Err(err error, opts ...ErrorResponseOption) error {
Code: e.Code,
Hint: e.Hint,
}
if requestID := RequestID(err); requestID != "" {
if resp.Meta == nil {
resp.Meta = make(map[string]any)
}
resp.Meta["request_id"] = requestID
}
for _, opt := range opts {
opt(resp)
}
Expand Down
33 changes: 32 additions & 1 deletion internal/output/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"strings"

"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
clioutput "github.com/basecamp/cli/output"
)

Expand All @@ -29,7 +30,37 @@ func ErrAPI(status int, msg string) *Error { return clioutput.ErrAPI(status, msg
func ErrAmbiguous(resource string, matches []string) *Error {
return clioutput.ErrAmbiguous(resource, matches)
}
func AsError(err error) *Error { return clioutput.AsError(err) }

func AsError(err error) *Error {
var sdkErr *basecamp.Error
if errors.As(err, &sdkErr) {
message := err.Error()
if sdkErr.Hint != "" {
message = strings.TrimSuffix(message, ": "+sdkErr.Hint)
}
if message == "" {
message = sdkErr.Message
}
return &Error{
Code: sdkErr.Code,
Message: message,
Hint: sdkErr.Hint,
HTTPStatus: sdkErr.HTTPStatus,
Retryable: sdkErr.Retryable,
Cause: sdkErr,
}
}
return clioutput.AsError(err)
Comment thread
robzolkos marked this conversation as resolved.
}

// RequestID returns the SDK request ID carried by err, if present.
func RequestID(err error) string {
var sdkErr *basecamp.Error
if errors.As(err, &sdkErr) {
return sdkErr.RequestID
}
return ""
}

// App-specific error constructors with basecamp-cli hints.

Expand Down
83 changes: 83 additions & 0 deletions internal/output/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"

"github.com/basecamp/basecamp-cli/internal/observability"
)

Expand Down Expand Up @@ -323,6 +325,23 @@ func TestAsErrorWithWrappedOutputError(t *testing.T) {
assert.Equal(t, CodeAuth, result.Code)
}

func TestAsErrorWithWrappedSDKErrorPreservesContext(t *testing.T) {
original := &basecamp.Error{
Code: basecamp.CodeForbidden,
Message: "access denied",
Hint: "retry later",
HTTPStatus: 403,
RequestID: "req-cli-123",
}
wrapped := fmt.Errorf("GET /projects.json: %w", original)

result := AsError(wrapped)
assert.Equal(t, CodeForbidden, result.Code)
assert.Equal(t, "GET /projects.json: access denied", result.Message)
assert.Equal(t, "retry later", result.Hint)
assert.Equal(t, original, result.Cause)
}

// Note: AsError(nil) panics because it calls err.Error() on nil.
// This is expected behavior - callers should not pass nil to AsError.

Expand Down Expand Up @@ -422,6 +441,30 @@ func TestWriterErr(t *testing.T) {
assert.Equal(t, CodeNotFound, resp.Code)
}

func TestWriterErrIncludesRequestIDMeta(t *testing.T) {
var buf bytes.Buffer
w := New(Options{
Format: FormatJSON,
Writer: &buf,
})

err := w.Err(&basecamp.Error{
Code: basecamp.CodeAPI,
Message: "server error",
HTTPStatus: 500,
RequestID: "req-cli-123",
})
require.NoError(t, err, "Err() failed")

var resp ErrorResponse
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp), "Failed to unmarshal output")

assert.False(t, resp.OK)
assert.Equal(t, CodeAPI, resp.Code)
require.NotNil(t, resp.Meta)
assert.Equal(t, "req-cli-123", resp.Meta["request_id"])
}

func TestWriterQuietFormat(t *testing.T) {
var buf bytes.Buffer
w := New(Options{
Expand Down Expand Up @@ -2213,6 +2256,46 @@ func TestWriterStyledErrorWithHint(t *testing.T) {
assert.Contains(t, output, "\x1b[", "Expected ANSI escape codes in styled output")
}

func TestWriterStyledErrorIncludesRequestID(t *testing.T) {
var buf bytes.Buffer
w := New(Options{
Format: FormatStyled,
Writer: &buf,
})

writeErr := w.Err(&basecamp.Error{
Code: basecamp.CodeAPI,
Message: "server error",
HTTPStatus: 500,
RequestID: "req-cli-123\x1b[31m\nnext",
})
require.NoError(t, writeErr, "Err() failed")

output := ansi.Strip(buf.String())
assert.Contains(t, output, "Request ID: req-cli-123 next")
assert.NotContains(t, output, "[31m")
}

func TestWriterMarkdownErrorIncludesRequestID(t *testing.T) {
var buf bytes.Buffer
w := New(Options{
Format: FormatMarkdown,
Writer: &buf,
})

writeErr := w.Err(&basecamp.Error{
Code: basecamp.CodeAPI,
Message: "server error",
HTTPStatus: 500,
RequestID: "req*cli`123\x1b[31m",
})
require.NoError(t, writeErr, "Err() failed")

output := buf.String()
assert.Contains(t, output, "Request ID: req\\*cli\\`123")
assert.NotContains(t, output, "\x1b")
}

// =============================================================================
// Terminal Escape Injection Defense Tests
// =============================================================================
Expand Down
82 changes: 82 additions & 0 deletions internal/output/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"sort"
"strings"
"time"
"unicode"

"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
Expand Down Expand Up @@ -189,6 +190,12 @@ func (r *Renderer) RenderError(w io.Writer, resp *ErrorResponse) error {
}
}
}
if requestID := errorRequestID(resp); requestID != "" {
contentLines = append(contentLines, "")
for _, line := range wrappedRequestIDLines(requestID, maxWidth) {
contentLines = append(contentLines, r.Hint.Render(line))
}
}

// Create bordered box with error color border
boxStyle := lipgloss.NewStyle().
Expand All @@ -208,12 +215,84 @@ func (r *Renderer) RenderError(w io.Writer, resp *ErrorResponse) error {
b.WriteString("Hint: " + resp.Hint)
b.WriteString("\n")
}
if requestID := errorRequestID(resp); requestID != "" {
for _, line := range wrappedRequestIDLines(requestID, r.width) {
b.WriteString(line)
b.WriteString("\n")
}
}
Comment thread
robzolkos marked this conversation as resolved.
}

_, err := io.WriteString(w, b.String())
return err
}

func errorRequestID(resp *ErrorResponse) string {
if resp == nil || resp.Meta == nil {
return ""
}
requestID, _ := resp.Meta["request_id"].(string)
return requestID
}

func wrappedRequestIDLines(requestID string, width int) []string {
const prefix = "Request ID: "
sanitized := sanitizeRequestID(requestID)
if sanitized == "" {
return nil
}
wrapped := wrapText(sanitized, max(width-len(prefix), 1))
lines := strings.Split(wrapped, "\n")
if len(lines) == 0 {
return nil
}
out := make([]string, 0, len(lines))
for i, line := range lines {
if i == 0 {
out = append(out, prefix+line)
continue
}
out = append(out, strings.Repeat(" ", len(prefix))+line)
}
return out
}

func sanitizeRequestID(requestID string) string {
requestID = ansi.Strip(requestID)
requestID = strings.Map(func(r rune) rune {
switch {
case r == '\n' || r == '\r' || r == '\t':
return ' '
case unicode.IsControl(r):
return -1
default:
return r
}
}, requestID)
return strings.Join(strings.Fields(requestID), " ")
}

func escapeMarkdownText(s string) string {
replacer := strings.NewReplacer(
`\\`, `\\`,
"`", "\\`",
"*", "\\*",
"_", "\\_",
"{", "\\{",
"}", "\\}",
"[", "\\[",
"]", "\\]",
"(", "\\(",
")", "\\)",
"#", "\\#",
"+", "\\+",
"-", "\\-",
"!", "\\!",
"|", "\\|",
)
return replacer.Replace(s)
}

// wrapText wraps text to fit within maxWidth, preserving words and newlines.
// Uses display-cell width for proper Unicode support.
func wrapText(text string, maxWidth int) string {
Expand Down Expand Up @@ -1164,6 +1243,9 @@ func (r *MarkdownRenderer) RenderError(w io.Writer, resp *ErrorResponse) error {
if resp.Hint != "" {
b.WriteString("\n*Hint: " + resp.Hint + "*\n")
}
if requestID := sanitizeRequestID(errorRequestID(resp)); requestID != "" {
b.WriteString("\nRequest ID: " + escapeMarkdownText(requestID) + "\n")
}

_, err := io.WriteString(w, b.String())
return err
Expand Down
Loading