diff --git a/internal/pkg/cli/command/auth/login.go b/internal/pkg/cli/command/auth/login.go index 449a5f40..c305821a 100644 --- a/internal/pkg/cli/command/auth/login.go +++ b/internal/pkg/cli/command/auth/login.go @@ -2,7 +2,6 @@ package auth import ( _ "embed" - "io" "github.com/pinecone-io/cli/internal/pkg/utils/help" "github.com/pinecone-io/cli/internal/pkg/utils/login" @@ -28,6 +27,8 @@ var ( ) func NewLoginCmd() *cobra.Command { + var jsonOutput bool + cmd := &cobra.Command{ Use: "login", Short: "Authenticate with Pinecone via user login in a web browser", @@ -37,21 +38,11 @@ func NewLoginCmd() *cobra.Command { `), GroupID: help.GROUP_AUTH.ID, Run: func(cmd *cobra.Command, args []string) { - out := cmd.OutOrStdout() - if quiet, _ := cmd.Flags().GetBool("quiet"); quiet { - out = io.Discard - } - - login.Run(cmd.Context(), - login.IO{ - In: cmd.InOrStdin(), - Out: out, - Err: cmd.ErrOrStderr(), - }, - login.Options{}, - ) + login.Run(cmd.Context(), login.Options{Json: jsonOutput}) }, } + cmd.Flags().BoolVar(&jsonOutput, "json", false, "emit JSON output") + return cmd } diff --git a/internal/pkg/cli/command/login/login.go b/internal/pkg/cli/command/login/login.go index e5648d56..1b7bbb84 100644 --- a/internal/pkg/cli/command/login/login.go +++ b/internal/pkg/cli/command/login/login.go @@ -2,7 +2,6 @@ package login import ( _ "embed" - "io" "github.com/pinecone-io/cli/internal/pkg/utils/help" "github.com/pinecone-io/cli/internal/pkg/utils/login" @@ -20,6 +19,8 @@ var ( ) func NewLoginCmd() *cobra.Command { + var jsonOutput bool + cmd := &cobra.Command{ Use: "login", Short: "Authenticate with Pinecone via user login in a web browser", @@ -29,21 +30,11 @@ func NewLoginCmd() *cobra.Command { `), GroupID: help.GROUP_AUTH.ID, Run: func(cmd *cobra.Command, args []string) { - out := cmd.OutOrStdout() - if quiet, _ := cmd.Flags().GetBool("quiet"); quiet { - out = io.Discard - } - - login.Run(cmd.Context(), - login.IO{ - In: cmd.InOrStdin(), - Out: out, - Err: cmd.ErrOrStderr(), - }, - login.Options{}, - ) + login.Run(cmd.Context(), login.Options{Json: jsonOutput}) }, } + cmd.Flags().BoolVar(&jsonOutput, "json", false, "emit JSON output") + return cmd } diff --git a/internal/pkg/cli/command/target/target.go b/internal/pkg/cli/command/target/target.go index 2a66ae01..219d96f5 100644 --- a/internal/pkg/cli/command/target/target.go +++ b/internal/pkg/cli/command/target/target.go @@ -158,7 +158,7 @@ func NewTargetCmd() *cobra.Command { // If the org chosen differs from the current orgId in the token, we need to login again if currentTokenOrgId != "" && currentTokenOrgId != targetOrg.Id { oauth.Logout() - err = login.GetAndSetAccessToken(ctx, &targetOrg.Id) + err = login.GetAndSetAccessToken(ctx, &targetOrg.Id, login.Options{}) if err != nil { msg.FailMsg("Failed to get access token: %s", err) exit.Error(err, "Error getting access token") @@ -204,7 +204,7 @@ func NewTargetCmd() *cobra.Command { // If the org chosen differs from the current orgId in the token, we need to login again if currentTokenOrgId != org.Id { oauth.Logout() - err = login.GetAndSetAccessToken(ctx, &org.Id) + err = login.GetAndSetAccessToken(ctx, &org.Id, login.Options{}) if err != nil { msg.FailMsg("Failed to get access token: %s", err) exit.Error(err, "Error getting access token") diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index 73d91c6f..99c08284 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -9,11 +9,12 @@ import ( "errors" "fmt" "html/template" - "io" "net/http" "os" "time" + "golang.org/x/term" + "github.com/pinecone-io/cli/internal/pkg/utils/browser" "github.com/pinecone-io/cli/internal/pkg/utils/configuration/secrets" "github.com/pinecone-io/cli/internal/pkg/utils/configuration/state" @@ -23,6 +24,7 @@ import ( "github.com/pinecone-io/cli/internal/pkg/utils/oauth" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/style" + "github.com/pinecone-io/cli/internal/pkg/utils/text" "github.com/pinecone-io/go-pinecone/v5/pinecone" ) @@ -35,15 +37,15 @@ var errorHTML string //go:embed assets/pinecone_logo.svg var logoSVG string -type IO struct { - In io.Reader - Out io.Writer - Err io.Writer +type Options struct { + Json bool } -type Options struct{} +func Run(ctx context.Context, opts Options) { + // Resolve output format once at the top level: explicit --json flag or auto-detected non-TTY stdout. + // Normalizing opts.Json here means GetAndSetAccessToken and other helpers use opts.Json directly. + opts.Json = opts.Json || !term.IsTerminal(int(os.Stdout.Fd())) -func Run(ctx context.Context, io IO, opts Options) { // Check if the user is currently logged in token, err := oauth.Token(ctx) @@ -55,12 +57,27 @@ func Run(ctx context.Context, io IO, opts Options) { } if !expired && token != nil && token.AccessToken != "" { - msg.WarnMsg("You are already logged in. Please log out first using %s.", style.Code("pc auth logout")) + if opts.Json { + claims, err := oauth.ParseClaimsUnverified(token) + if err == nil { + fmt.Fprintln(os.Stdout, text.IndentJSON(struct { + Status string `json:"status"` + Email string `json:"email"` + OrgId string `json:"org_id"` + }{Status: "already_authenticated", Email: claims.Email, OrgId: claims.OrgId})) + } else { + fmt.Fprintln(os.Stdout, text.IndentJSON(struct { + Status string `json:"status"` + }{Status: "already_authenticated"})) + } + } else { + msg.WarnMsg("You are already logged in. Please log out first using %s.", style.Code("pc auth logout")) + } return } // Initiate login flow - err = GetAndSetAccessToken(ctx, nil) + err = GetAndSetAccessToken(ctx, nil, opts) if err != nil { msg.FailMsg("Error acquiring access token while logging in: %s", err) exit.Error(err, "Error acquiring access token while logging in") @@ -77,7 +94,10 @@ func Run(ctx context.Context, io IO, opts Options) { msg.FailMsg("An auth token was fetched but an error occurred while parsing the token's claims: %s", err) exit.Error(err, "Error parsing claims from access token") } - msg.SuccessMsg("Logged in as " + style.Emphasis(claims.Email) + ". Defaulted to organization ID: " + style.Emphasis(claims.OrgId)) + if !opts.Json { + msg.Blank() + msg.SuccessMsg("Logged in as " + style.Emphasis(claims.Email) + ". Defaulted to organization ID: " + style.Emphasis(claims.OrgId)) + } ac := sdk.NewPineconeAdminClient(ctx) if err != nil { @@ -111,34 +131,50 @@ func Run(ctx context.Context, io IO, opts Options) { Name: targetOrg.Name, Id: targetOrg.Id, }) - fmt.Println() - fmt.Printf(style.InfoMsg("Target org set to %s.\n"), style.Emphasis(targetOrg.Name)) - if projects != nil { - if len(projects) == 0 { - fmt.Printf(style.InfoMsg("No projects found for organization %s.\n"), style.Emphasis(targetOrg.Name)) - fmt.Println(style.InfoMsg("Please create a project for this organization to work with project resources.")) - } else { + if opts.Json { + projectId := "" + if len(projects) > 0 { targetProj := projects[0] state.TargetProj.Set(state.TargetProject{ Name: targetProj.Name, Id: targetProj.Id, }) - - fmt.Printf(style.InfoMsg("Target project set %s.\n"), style.Emphasis(targetProj.Name)) + projectId = targetProj.Id + } + fmt.Fprintln(os.Stdout, text.IndentJSON(struct { + Status string `json:"status"` + Email string `json:"email"` + OrgId string `json:"org_id"` + ProjectId string `json:"project_id"` + }{Status: "authenticated", Email: claims.Email, OrgId: targetOrg.Id, ProjectId: projectId})) + } else { + msg.InfoMsg("Target org set to %s.", style.Emphasis(targetOrg.Name)) + + if projects != nil { + if len(projects) == 0 { + msg.InfoMsg("No projects found for organization %s.", style.Emphasis(targetOrg.Name)) + msg.InfoMsg("Please create a project for this organization to work with project resources.") + } else { + targetProj := projects[0] + state.TargetProj.Set(state.TargetProject{ + Name: targetProj.Name, + Id: targetProj.Id, + }) + + msg.InfoMsg("Target project set %s.", style.Emphasis(targetProj.Name)) + } } - } - - fmt.Println() - fmt.Println(style.CodeHint("Run %s to change the target context.", style.Code("pc target"))) - fmt.Println() - fmt.Printf("Now try %s to learn about index operations.\n", style.Code("pc index -h")) + msg.Blank() + msg.HintMsg("Run %s to change the target context.", style.Code("pc target")) + msg.HintMsg("Now try %s to learn about index operations.", style.Code("pc index -h")) + } } // Takes an optional orgId, and attempts to acquire an access token scoped to the orgId if provided. // If a token is successfully acquired it's set in the secrets store, and the user is considered logged in with state.AuthUserToken. -func GetAndSetAccessToken(ctx context.Context, orgId *string) error { +func GetAndSetAccessToken(ctx context.Context, orgId *string, opts Options) error { a := oauth.Auth{} // CSRF state @@ -170,40 +206,51 @@ func GetAndSetAccessToken(ctx context.Context, orgId *string) error { codeCh <- code }() - fmt.Printf("Visit %s to authorize the CLI.\n", style.Underline(authURL)) - fmt.Println() - fmt.Printf("Press %s to open the browser, or manually paste the URL above.\n", style.Code("[Enter]")) - - // spawn a goroutine to optionally wait for [Enter] as input - go func(ctx context.Context) { - // inner channel to signal that [Enter] was pressed - inputCh := make(chan struct{}, 1) + if opts.Json { + fmt.Fprintln(os.Stdout, text.IndentJSON(struct { + Status string `json:"status"` + URL string `json:"url"` + }{Status: "pending", URL: authURL})) + } else { + fmt.Fprintf(os.Stderr, "Visit %s to authorize the CLI.\n", style.Underline(authURL)) + } - // spawn inner goroutine to read stdin (blocking) - go func() { - _, err := bufio.NewReader(os.Stdin).ReadBytes('\n') - if err != nil { - log.Error().Err(err).Msg("stdin error: unable to open browser") + // Prompt for [Enter] and spawn stdin reader whenever stdin is an interactive TTY, + // regardless of output format. JSON mode only affects what goes to stdout — a user + // running --json in a terminal is still at a keyboard and benefits from browser-open. + // The prompt goes to stderr so it never corrupts the stdout JSON stream. + if term.IsTerminal(int(os.Stdin.Fd())) { + msg.Blank() + fmt.Fprintf(os.Stderr, "Press %s to open the browser, or manually paste the URL above.\n", style.Code("[Enter]")) + + go func(ctx context.Context) { + // inner channel to signal that [Enter] was pressed + inputCh := make(chan struct{}, 1) + + // spawn inner goroutine to read stdin (blocking) + go func() { + _, err := bufio.NewReader(os.Stdin).ReadBytes('\n') + if err != nil { + log.Error().Err(err).Msg("stdin error: unable to open browser") + return + } + close(inputCh) + }() + + // wait for [Enter], auth code, or timeout + select { + case <-ctx.Done(): return - } - close(inputCh) - }() - - // wait for [Enter], auth code, or timeout - select { - case <-ctx.Done(): - return - case <-inputCh: - err = browser.OpenBrowser(authURL) - if err != nil { - log.Error().Err(err).Msg("error opening browser") + case <-inputCh: + if err := browser.OpenBrowser(authURL); err != nil { + log.Error().Err(err).Msg("error opening browser") + } + case <-time.After(5 * time.Minute): + // extra precaution to prevent hanging indefinitely on stdin return } - case <-time.After(5 * time.Minute): - // extra precaution to prevent hanging indefinitely on stdin - return - } - }(serverCtx) + }(serverCtx) + } // Wait for auth code and exchange for access token code := <-codeCh diff --git a/internal/pkg/utils/msg/message.go b/internal/pkg/utils/msg/message.go index 290846cc..2311830d 100644 --- a/internal/pkg/utils/msg/message.go +++ b/internal/pkg/utils/msg/message.go @@ -31,3 +31,7 @@ func HintMsg(format string, a ...any) { formatted := fmt.Sprintf(format, a...) fmt.Fprintln(os.Stderr, style.Hint(formatted)) } + +func Blank() { + fmt.Fprintln(os.Stderr, "") +} diff --git a/internal/pkg/utils/text/json.go b/internal/pkg/utils/text/json.go index ed17b3f8..a2a95a09 100644 --- a/internal/pkg/utils/text/json.go +++ b/internal/pkg/utils/text/json.go @@ -1,21 +1,33 @@ package text import ( + "bytes" "encoding/json" + "strings" ) -func InlineJSON(data any) string { - jsonData, err := json.Marshal(data) - if err != nil { +// encode marshals data to JSON with HTML escaping disabled. +// json.Marshal and json.MarshalIndent escape &, <, > as \uXXXX by default — +// a safety measure for embedding JSON in HTML that is incorrect for CLI output. +func encode(data any, indent bool) string { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if indent { + enc.SetIndent("", " ") + } + if err := enc.Encode(data); err != nil { return "" } - return string(jsonData) + // json.Encoder.Encode appends a trailing newline; trim it so callers + // control their own newlines (consistent with the old MarshalIndent behavior). + return strings.TrimRight(buf.String(), "\n") +} + +func InlineJSON(data any) string { + return encode(data, false) } func IndentJSON(data any) string { - jsonData, err := json.MarshalIndent(data, "", " ") - if err != nil { - return "" - } - return string(jsonData) + return encode(data, true) } diff --git a/internal/pkg/utils/text/json_test.go b/internal/pkg/utils/text/json_test.go new file mode 100644 index 00000000..dca02348 --- /dev/null +++ b/internal/pkg/utils/text/json_test.go @@ -0,0 +1,74 @@ +package text + +import ( + "strings" + "testing" +) + +func TestIndentJSON_DoesNotEscapeHTMLChars(t *testing.T) { + input := struct { + URL string `json:"url"` + }{URL: "https://example.com/auth?foo=1&bar=2"} + + result := IndentJSON(input) + + if strings.Contains(result, `\u0026`) { + t.Errorf("IndentJSON escaped & as \\u0026; got: %s", result) + } + if !strings.Contains(result, "&") { + t.Errorf("IndentJSON did not preserve literal &; got: %s", result) + } +} + +func TestInlineJSON_DoesNotEscapeHTMLChars(t *testing.T) { + input := struct { + URL string `json:"url"` + }{URL: "https://example.com/auth?foo=1&bar=2"} + + result := InlineJSON(input) + + if strings.Contains(result, `\u0026`) { + t.Errorf("InlineJSON escaped & as \\u0026; got: %s", result) + } + if !strings.Contains(result, "&") { + t.Errorf("InlineJSON did not preserve literal &; got: %s", result) + } +} + +func TestIndentJSON_IsIndented(t *testing.T) { + input := struct { + Key string `json:"key"` + }{Key: "value"} + + result := IndentJSON(input) + + if !strings.Contains(result, "\n ") { + t.Errorf("IndentJSON output is not indented; got: %s", result) + } +} + +func TestInlineJSON_IsCompact(t *testing.T) { + input := struct { + Key string `json:"key"` + }{Key: "value"} + + result := InlineJSON(input) + + if strings.Contains(result, "\n") { + t.Errorf("InlineJSON output contains newlines; got: %s", result) + } +} + +func TestIndentJSON_NoTrailingNewline(t *testing.T) { + result := IndentJSON(struct{ K string }{K: "v"}) + if strings.HasSuffix(result, "\n") { + t.Errorf("IndentJSON has trailing newline") + } +} + +func TestInlineJSON_NoTrailingNewline(t *testing.T) { + result := InlineJSON(struct{ K string }{K: "v"}) + if strings.HasSuffix(result, "\n") { + t.Errorf("InlineJSON has trailing newline") + } +}