feat(cli)!: switch to --json/--plain

This commit is contained in:
Peter Steinberger 2025-12-26 10:15:12 +00:00
parent 65b5ccb303
commit 18f2a9fd4f
62 changed files with 928 additions and 323 deletions

View File

@ -9,6 +9,11 @@ BIN_DIR := $(CURDIR)/bin
BIN := $(BIN_DIR)/gog
CMD := ./cmd/gog
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT := $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo "")
DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X github.com/steipete/gogcli/internal/cmd.version=$(VERSION) -X github.com/steipete/gogcli/internal/cmd.commit=$(COMMIT) -X github.com/steipete/gogcli/internal/cmd.date=$(DATE)
TOOLS_DIR := $(CURDIR)/.tools
GOFUMPT := $(TOOLS_DIR)/gofumpt
GOIMPORTS := $(TOOLS_DIR)/goimports
@ -16,7 +21,7 @@ GOLANGCI_LINT := $(TOOLS_DIR)/golangci-lint
build:
@mkdir -p $(BIN_DIR)
@go build -o $(BIN) $(CMD)
@go build -ldflags "$(LDFLAGS)" -o $(BIN) $(CMD)
tools:
@mkdir -p $(TOOLS_DIR)

View File

@ -8,6 +8,6 @@ import (
func main() {
if err := cmd.Execute(os.Args[1:]); err != nil {
os.Exit(1)
os.Exit(cmd.ExitCode(err))
}
}

View File

@ -1,7 +1,6 @@
package cmd
import (
"errors"
"os"
"strings"
)
@ -13,5 +12,5 @@ func requireAccount(flags *rootFlags) (string, error) {
if v := strings.TrimSpace(os.Getenv("GOG_ACCOUNT")); v != "" {
return v, nil
}
return "", errors.New("missing --account (or set GOG_ACCOUNT)")
return "", usage("missing --account (or set GOG_ACCOUNT)")
}

View File

@ -2,7 +2,8 @@ package cmd
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
@ -22,7 +23,7 @@ var (
authorizeGoogle = googleauth.Authorize
)
func newAuthCmd() *cobra.Command {
func newAuthCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "Authentication and accounts",
@ -31,20 +32,26 @@ func newAuthCmd() *cobra.Command {
cmd.AddCommand(newAuthCredentialsCmd())
cmd.AddCommand(newAuthAddCmd())
cmd.AddCommand(newAuthListCmd())
cmd.AddCommand(newAuthRemoveCmd())
cmd.AddCommand(newAuthTokensCmd())
cmd.AddCommand(newAuthRemoveCmd(flags))
cmd.AddCommand(newAuthTokensCmd(flags))
return cmd
}
func newAuthCredentialsCmd() *cobra.Command {
return &cobra.Command{
Use: "credentials <credentials.json>",
Use: "credentials <credentials.json|->",
Short: "Store OAuth client credentials",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
inPath := args[0]
b, err := os.ReadFile(inPath)
var b []byte
var err error
if inPath == "-" {
b, err = io.ReadAll(os.Stdin)
} else {
b, err = os.ReadFile(inPath)
}
if err != nil {
return err
}
@ -71,7 +78,7 @@ func newAuthCredentialsCmd() *cobra.Command {
}
}
func newAuthTokensCmd() *cobra.Command {
func newAuthTokensCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "tokens",
Short: "Manage stored refresh tokens",
@ -117,11 +124,19 @@ func newAuthTokensCmd() *cobra.Command {
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
email := strings.TrimSpace(args[0])
if email == "" {
return usage("empty email")
}
if err := confirmDestructive(cmd, flags, fmt.Sprintf("delete stored token for %s", email)); err != nil {
return err
}
store, err := openSecretsStore()
if err != nil {
return err
}
email := args[0]
if err := store.DeleteToken(email); err != nil {
return err
}
@ -142,7 +157,7 @@ func newAuthTokensCmd() *cobra.Command {
func newAuthTokensExportCmd() *cobra.Command {
var outPath string
var force bool
var overwrite bool
cmd := &cobra.Command{
Use: "export <email>",
@ -152,11 +167,11 @@ func newAuthTokensExportCmd() *cobra.Command {
u := ui.FromContext(cmd.Context())
email := strings.TrimSpace(args[0])
if email == "" {
return errors.New("empty email")
return usage("empty email")
}
outPath = strings.TrimSpace(outPath)
if outPath == "" {
return errors.New("empty outPath")
return usage("empty outPath")
}
store, err := openSecretsStore()
@ -173,7 +188,7 @@ func newAuthTokensExportCmd() *cobra.Command {
}
flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC
if !force {
if !overwrite {
flags = os.O_WRONLY | os.O_CREATE | os.O_EXCL
}
f, openErr := os.OpenFile(outPath, flags, 0o600)
@ -223,19 +238,25 @@ func newAuthTokensExportCmd() *cobra.Command {
}
cmd.Flags().StringVar(&outPath, "out", "", "Output file path (required)")
cmd.Flags().BoolVar(&force, "force", false, "Overwrite output file if it exists")
cmd.Flags().BoolVar(&overwrite, "overwrite", false, "Overwrite output file if it exists")
return cmd
}
func newAuthTokensImportCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "import <inPath>",
Use: "import <inPath|->",
Short: "Import a refresh token file into keyring (contains secrets)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
inPath := args[0]
b, err := os.ReadFile(inPath)
var b []byte
var err error
if inPath == "-" {
b, err = io.ReadAll(os.Stdin)
} else {
b, err = os.ReadFile(inPath)
}
if err != nil {
return err
}
@ -253,10 +274,10 @@ func newAuthTokensImportCmd() *cobra.Command {
}
ex.Email = strings.TrimSpace(ex.Email)
if ex.Email == "" {
return errors.New("missing email in token file")
return usage("missing email in token file")
}
if strings.TrimSpace(ex.RefreshToken) == "" {
return errors.New("missing refresh_token in token file")
return usage("missing refresh_token in token file")
}
var createdAt time.Time
if strings.TrimSpace(ex.CreatedAt) != "" {
@ -438,14 +459,21 @@ func newAuthListCmd() *cobra.Command {
}
}
func newAuthRemoveCmd() *cobra.Command {
func newAuthRemoveCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "remove <email>",
Short: "Remove a stored refresh token",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
email := args[0]
email := strings.TrimSpace(args[0])
if email == "" {
return usage("empty email")
}
if err := confirmDestructive(cmd, flags, fmt.Sprintf("remove stored token for %s", email)); err != nil {
return err
}
store, err := openSecretsStore()
if err != nil {
return err

View File

@ -100,7 +100,7 @@ func TestAuthTokens_ExportImportRoundtrip_JSON(t *testing.T) {
stdout := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "auth", "tokens", "export", "a@b.com", "--out", outPath}); err != nil {
if err := Execute([]string{"--json", "auth", "tokens", "export", "a@b.com", "--out", outPath}); err != nil {
t.Fatalf("Execute export: %v", err)
}
})
@ -132,7 +132,7 @@ func TestAuthTokens_ExportImportRoundtrip_JSON(t *testing.T) {
importOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "auth", "tokens", "import", outPath}); err != nil {
if err := Execute([]string{"--json", "auth", "tokens", "import", outPath}); err != nil {
t.Fatalf("Execute import: %v", err)
}
})
@ -153,7 +153,7 @@ func TestAuthTokens_ExportImportRoundtrip_JSON(t *testing.T) {
}
func TestAuthTokensExport_RequiresOut(t *testing.T) {
err := Execute([]string{"--output", "json", "auth", "tokens", "export", "a@b.com"})
err := Execute([]string{"--json", "auth", "tokens", "export", "a@b.com"})
if err == nil {
t.Fatalf("expected error")
}
@ -174,7 +174,7 @@ func TestAuthListRemoveTokensListDelete_JSON(t *testing.T) {
listOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "auth", "list"}); err != nil {
if err := Execute([]string{"--json", "auth", "list"}); err != nil {
t.Fatalf("Execute list: %v", err)
}
})
@ -194,7 +194,7 @@ func TestAuthListRemoveTokensListDelete_JSON(t *testing.T) {
// Tokens list (keys).
keysOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "auth", "tokens", "list"}); err != nil {
if err := Execute([]string{"--json", "auth", "tokens", "list"}); err != nil {
t.Fatalf("Execute tokens list: %v", err)
}
})
@ -212,7 +212,7 @@ func TestAuthListRemoveTokensListDelete_JSON(t *testing.T) {
// Remove (auth remove)
rmOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "auth", "remove", "b@b.com"}); err != nil {
if err := Execute([]string{"--json", "--force", "auth", "remove", "b@b.com"}); err != nil {
t.Fatalf("Execute remove: %v", err)
}
})
@ -231,7 +231,7 @@ func TestAuthListRemoveTokensListDelete_JSON(t *testing.T) {
// Tokens delete (auth tokens delete)
delOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "auth", "tokens", "delete", "a@b.com"}); err != nil {
if err := Execute([]string{"--json", "--force", "auth", "tokens", "delete", "a@b.com"}); err != nil {
t.Fatalf("Execute tokens delete: %v", err)
}
})
@ -250,7 +250,7 @@ func TestAuthListRemoveTokensListDelete_JSON(t *testing.T) {
// Now empty.
emptyKeysOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "auth", "tokens", "list"}); err != nil {
if err := Execute([]string{"--json", "auth", "tokens", "list"}); err != nil {
t.Fatalf("Execute tokens list: %v", err)
}
})

View File

@ -1,8 +1,8 @@
package cmd
import (
"errors"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
@ -69,12 +69,19 @@ func newCalendarCalendarsCmd(flags *rootFlags) *cobra.Command {
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tROLE")
for _, c := range resp.Items {
fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Id, c.Summary, c.AccessRole)
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "ID\tNAME\tROLE")
for _, c := range resp.Items {
fmt.Fprintf(w, "%s\t%s\t%s\n", c.Id, c.Summary, c.AccessRole)
}
if tw != nil {
_ = tw.Flush()
}
_ = tw.Flush()
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
}
@ -123,8 +130,13 @@ func newCalendarAclCmd(flags *rootFlags) *cobra.Command {
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "SCOPE_TYPE\tSCOPE_VALUE\tROLE")
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "SCOPE_TYPE\tSCOPE_VALUE\tROLE")
for _, rule := range resp.Items {
scopeType := ""
scopeValue := ""
@ -132,9 +144,11 @@ func newCalendarAclCmd(flags *rootFlags) *cobra.Command {
scopeType = rule.Scope.Type
scopeValue = rule.Scope.Value
}
fmt.Fprintf(tw, "%s\t%s\t%s\n", scopeType, scopeValue, rule.Role)
fmt.Fprintf(w, "%s\t%s\t%s\n", scopeType, scopeValue, rule.Role)
}
if tw != nil {
_ = tw.Flush()
}
_ = tw.Flush()
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
}
@ -206,12 +220,19 @@ func newCalendarEventsCmd(flags *rootFlags) *cobra.Command {
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tSTART\tEND\tSUMMARY")
for _, e := range resp.Items {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", e.Id, eventStart(e), eventEnd(e), e.Summary)
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "ID\tSTART\tEND\tSUMMARY")
for _, e := range resp.Items {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.Id, eventStart(e), eventEnd(e), e.Summary)
}
if tw != nil {
_ = tw.Flush()
}
_ = tw.Flush()
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
@ -242,6 +263,10 @@ func newCalendarEventCmd(flags *rootFlags) *cobra.Command {
calendarID := args[0]
eventID := args[1]
if err := confirmDestructive(cmd, flags, fmt.Sprintf("delete calendar event %s/%s", calendarID, eventID)); err != nil {
return err
}
svc, err := newCalendarService(cmd.Context(), account)
if err != nil {
return err
@ -309,7 +334,7 @@ func newCalendarCreateCmd(flags *rootFlags) *cobra.Command {
calendarID := args[0]
if strings.TrimSpace(summary) == "" || strings.TrimSpace(from) == "" || strings.TrimSpace(to) == "" {
return errors.New("required: --summary, --from, --to")
return usage("required: --summary, --from, --to")
}
svc, err := newCalendarService(cmd.Context(), account)
@ -388,7 +413,7 @@ func newCalendarUpdateCmd(flags *rootFlags) *cobra.Command {
targetAllDay = allDay
// Converting between all-day and timed needs explicit start/end.
if !cmd.Flags().Changed("from") || !cmd.Flags().Changed("to") {
return errors.New("when changing --all-day, also provide --from and --to")
return usage("when changing --all-day, also provide --from and --to")
}
}
@ -422,7 +447,7 @@ func newCalendarUpdateCmd(flags *rootFlags) *cobra.Command {
}
if !changed {
return errors.New("no updates provided")
return usage("no updates provided")
}
updated, err := svc.Events.Update(calendarID, eventID, existing).Do()
@ -503,10 +528,10 @@ func newCalendarFreeBusyCmd(flags *rootFlags) *cobra.Command {
}
calendarIDs := splitCSV(args[0])
if len(calendarIDs) == 0 {
return errors.New("no calendar IDs provided")
return usage("no calendar IDs provided")
}
if strings.TrimSpace(from) == "" || strings.TrimSpace(to) == "" {
return errors.New("required: --from and --to")
return usage("required: --from and --to")
}
svc, err := newCalendarService(cmd.Context(), account)
@ -535,14 +560,21 @@ func newCalendarFreeBusyCmd(flags *rootFlags) *cobra.Command {
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "CALENDAR\tSTART\tEND")
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "CALENDAR\tSTART\tEND")
for id, data := range resp.Calendars {
for _, b := range data.Busy {
fmt.Fprintf(tw, "%s\t%s\t%s\n", id, b.Start, b.End)
fmt.Fprintf(w, "%s\t%s\t%s\n", id, b.Start, b.End)
}
}
_ = tw.Flush()
if tw != nil {
_ = tw.Flush()
}
return nil
},
}

41
internal/cmd/confirm.go Normal file
View File

@ -0,0 +1,41 @@
package cmd
import (
"bufio"
"errors"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/ui"
"golang.org/x/term"
)
func confirmDestructive(cmd *cobra.Command, flags *rootFlags, action string) error {
if flags.Force {
return nil
}
// Never prompt in non-interactive contexts.
if flags.NoInput || !term.IsTerminal(int(os.Stdin.Fd())) {
return usagef("refusing to %s without --force (non-interactive)", action)
}
prompt := fmt.Sprintf("Proceed to %s? [y/N]: ", action)
if u := ui.FromContext(cmd.Context()); u != nil {
u.Err().Println(prompt)
} else {
_, _ = fmt.Fprintln(os.Stderr, prompt)
}
line, readErr := bufio.NewReader(os.Stdin).ReadString('\n')
if readErr != nil && !errors.Is(readErr, os.ErrClosed) {
return readErr
}
ans := strings.TrimSpace(strings.ToLower(line))
if ans == "y" || ans == "yes" {
return nil
}
return &ExitError{Code: 1, Err: errors.New("cancelled")}
}

View File

@ -0,0 +1,36 @@
package cmd
import (
"context"
"testing"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/ui"
)
func TestConfirmDestructive_NonInteractiveRequiresForce(t *testing.T) {
cmd := &cobra.Command{}
u, err := ui.New(ui.Options{Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
cmd.SetContext(ui.WithUI(context.Background(), u))
withStdin(t, "y\n", func() {
flags := &rootFlags{Force: false, NoInput: false}
err := confirmDestructive(cmd, flags, "delete something")
if err == nil {
t.Fatalf("expected error")
}
if ExitCode(err) != 2 {
t.Fatalf("expected exit code 2, got %d (err=%v)", ExitCode(err), err)
}
})
withStdin(t, "", func() {
flags := &rootFlags{Force: true, NoInput: false}
if err := confirmDestructive(cmd, flags, "delete something"); err != nil {
t.Fatalf("expected no error, got %v", err)
}
})
}

View File

@ -2,6 +2,7 @@ package cmd
import (
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
@ -83,15 +84,20 @@ func newContactsSearchCmd(flags *rootFlags) *cobra.Command {
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL\tPHONE")
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "RESOURCE\tNAME\tEMAIL\tPHONE")
for _, r := range resp.Results {
p := r.Person
if p == nil {
continue
}
fmt.Fprintf(
tw,
w,
"%s\t%s\t%s\t%s\n",
p.ResourceName,
sanitizeTab(primaryName(p)),
@ -99,7 +105,9 @@ func newContactsSearchCmd(flags *rootFlags) *cobra.Command {
sanitizeTab(primaryPhone(p)),
)
}
_ = tw.Flush()
if tw != nil {
_ = tw.Flush()
}
return nil
},
}

View File

@ -1,8 +1,8 @@
package cmd
import (
"errors"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
@ -72,20 +72,27 @@ func newContactsListCmd(flags *rootFlags) *cobra.Command {
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL\tPHONE")
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "RESOURCE\tNAME\tEMAIL\tPHONE")
for _, p := range resp.Connections {
if p == nil {
continue
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
p.ResourceName,
sanitizeTab(primaryName(p)),
sanitizeTab(primaryEmail(p)),
sanitizeTab(primaryPhone(p)),
)
}
_ = tw.Flush()
if tw != nil {
_ = tw.Flush()
}
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
@ -112,7 +119,7 @@ func newContactsGetCmd(flags *rootFlags) *cobra.Command {
}
identifier := strings.TrimSpace(args[0])
if identifier == "" {
return errors.New("empty identifier")
return usage("empty identifier")
}
svc, err := newPeopleContactsService(cmd.Context(), account)
@ -191,7 +198,7 @@ func newContactsCreateCmd(flags *rootFlags) *cobra.Command {
return err
}
if strings.TrimSpace(given) == "" {
return errors.New("required: --given")
return usage("required: --given")
}
svc, err := newPeopleContactsService(cmd.Context(), account)
@ -249,7 +256,11 @@ func newContactsUpdateCmd(flags *rootFlags) *cobra.Command {
}
resourceName := strings.TrimSpace(args[0])
if !strings.HasPrefix(resourceName, "people/") {
return errors.New("resourceName must start with people/")
return usage("resourceName must start with people/")
}
if err := confirmDestructive(cmd, flags, fmt.Sprintf("delete contact %s", resourceName)); err != nil {
return err
}
svc, err := newPeopleContactsService(cmd.Context(), account)
@ -299,7 +310,7 @@ func newContactsUpdateCmd(flags *rootFlags) *cobra.Command {
}
if len(updateFields) == 0 {
return errors.New("no updates provided")
return usage("no updates provided")
}
updated, err := svc.People.UpdateContact(resourceName, existing).
@ -336,7 +347,7 @@ func newContactsDeleteCmd(flags *rootFlags) *cobra.Command {
}
resourceName := strings.TrimSpace(args[0])
if !strings.HasPrefix(resourceName, "people/") {
return errors.New("resourceName must start with people/")
return usage("resourceName must start with people/")
}
svc, err := newPeopleContactsService(cmd.Context(), account)

View File

@ -3,6 +3,7 @@ package cmd
import (
"context"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
@ -89,19 +90,26 @@ func newContactsDirectoryListCmd(flags *rootFlags) *cobra.Command {
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL")
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "RESOURCE\tNAME\tEMAIL")
for _, p := range resp.People {
if p == nil {
continue
}
fmt.Fprintf(tw, "%s\t%s\t%s\n",
fmt.Fprintf(w, "%s\t%s\t%s\n",
p.ResourceName,
sanitizeTab(primaryName(p)),
sanitizeTab(primaryEmail(p)),
)
}
_ = tw.Flush()
if tw != nil {
_ = tw.Flush()
}
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
@ -178,19 +186,26 @@ func newContactsDirectorySearchCmd(flags *rootFlags) *cobra.Command {
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL")
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "RESOURCE\tNAME\tEMAIL")
for _, p := range resp.People {
if p == nil {
continue
}
fmt.Fprintf(tw, "%s\t%s\t%s\n",
fmt.Fprintf(w, "%s\t%s\t%s\n",
p.ResourceName,
sanitizeTab(primaryName(p)),
sanitizeTab(primaryEmail(p)),
)
}
_ = tw.Flush()
if tw != nil {
_ = tw.Flush()
}
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
@ -272,20 +287,27 @@ func newContactsOtherListCmd(flags *rootFlags) *cobra.Command {
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL\tPHONE")
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "RESOURCE\tNAME\tEMAIL\tPHONE")
for _, p := range resp.OtherContacts {
if p == nil {
continue
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
p.ResourceName,
sanitizeTab(primaryName(p)),
sanitizeTab(primaryEmail(p)),
sanitizeTab(primaryPhone(p)),
)
}
_ = tw.Flush()
if tw != nil {
_ = tw.Flush()
}
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
@ -355,21 +377,28 @@ func newContactsOtherSearchCmd(flags *rootFlags) *cobra.Command {
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL\tPHONE")
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "RESOURCE\tNAME\tEMAIL\tPHONE")
for _, r := range resp.Results {
p := r.Person
if p == nil {
continue
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
p.ResourceName,
sanitizeTab(primaryName(p)),
sanitizeTab(primaryEmail(p)),
sanitizeTab(primaryPhone(p)),
)
}
_ = tw.Flush()
if tw != nil {
_ = tw.Flush()
}
return nil
},
}

View File

@ -98,11 +98,16 @@ func newDriveLsCmd(flags *rootFlags) *cobra.Command {
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tTYPE\tSIZE\tMODIFIED")
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "ID\tNAME\tTYPE\tSIZE\tMODIFIED")
for _, f := range resp.Files {
fmt.Fprintf(
tw,
w,
"%s\t%s\t%s\t%s\t%s\n",
f.Id,
f.Name,
@ -111,7 +116,9 @@ func newDriveLsCmd(flags *rootFlags) *cobra.Command {
formatDateTime(f.ModifiedTime),
)
}
_ = tw.Flush()
if tw != nil {
_ = tw.Flush()
}
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
@ -172,11 +179,16 @@ func newDriveSearchCmd(flags *rootFlags) *cobra.Command {
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tTYPE\tSIZE\tMODIFIED")
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "ID\tNAME\tTYPE\tSIZE\tMODIFIED")
for _, f := range resp.Files {
fmt.Fprintf(
tw,
w,
"%s\t%s\t%s\t%s\t%s\n",
f.Id,
f.Name,
@ -185,7 +197,9 @@ func newDriveSearchCmd(flags *rootFlags) *cobra.Command {
formatDateTime(f.ModifiedTime),
)
}
_ = tw.Flush()
if tw != nil {
_ = tw.Flush()
}
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
@ -212,6 +226,10 @@ func newDriveGetCmd(flags *rootFlags) *cobra.Command {
}
fileID := args[0]
if err := confirmDestructive(cmd, flags, fmt.Sprintf("delete drive file %s", fileID)); err != nil {
return err
}
svc, err := newDriveService(cmd.Context(), account)
if err != nil {
return err
@ -573,13 +591,13 @@ func newDriveShareCmd(flags *rootFlags) *cobra.Command {
fileID := args[0]
if !anyone && email == "" {
return errors.New("must specify --anyone or --email")
return usage("must specify --anyone or --email")
}
if role == "" {
role = "reader"
}
if role != "reader" && role != "writer" {
return errors.New("invalid --role (expected reader|writer)")
return usage("invalid --role (expected reader|writer)")
}
svc, err := newDriveService(cmd.Context(), account)
@ -645,6 +663,10 @@ func newDriveUnshareCmd(flags *rootFlags) *cobra.Command {
fileID := args[0]
permissionID := args[1]
if err := confirmDestructive(cmd, flags, fmt.Sprintf("remove permission %s from drive file %s", permissionID, fileID)); err != nil {
return err
}
svc, err := newDriveService(cmd.Context(), account)
if err != nil {
return err
@ -713,16 +735,23 @@ func newDrivePermissionsCmd(flags *rootFlags) *cobra.Command {
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tTYPE\tROLE\tEMAIL")
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "ID\tTYPE\tROLE\tEMAIL")
for _, p := range resp.Permissions {
email := p.EmailAddress
if email == "" {
email = "-"
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", p.Id, p.Type, p.Role, email)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", p.Id, p.Type, p.Role, email)
}
if tw != nil {
_ = tw.Flush()
}
_ = tw.Flush()
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)

View File

@ -70,7 +70,7 @@ func TestDriveLsCmd_TextAndJSON(t *testing.T) {
t.Fatalf("ui.New: %v", err)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.ModeText)
ctx = outfmt.WithMode(ctx, outfmt.Mode{})
textOut := captureStdout(t, func() {
cmd := newDriveLsCmd(flags)
@ -101,7 +101,7 @@ func TestDriveLsCmd_TextAndJSON(t *testing.T) {
t.Fatalf("ui.New: %v", err)
}
ctx2 := ui.WithUI(context.Background(), u2)
ctx2 = outfmt.WithMode(ctx2, outfmt.ModeJSON)
ctx2 = outfmt.WithMode(ctx2, outfmt.Mode{JSON: true})
jsonOut := captureStdout(t, func() {
cmd := newDriveLsCmd(flags)
@ -125,4 +125,25 @@ func TestDriveLsCmd_TextAndJSON(t *testing.T) {
if parsed.NextPageToken != "npt" || len(parsed.Files) != 2 {
t.Fatalf("unexpected json: %#v", parsed)
}
// Plain mode: stable TSV (tabs preserved).
var errBuf3 bytes.Buffer
u3, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: &errBuf3, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
ctx3 := ui.WithUI(context.Background(), u3)
ctx3 = outfmt.WithMode(ctx3, outfmt.Mode{Plain: true})
plainOut := captureStdout(t, func() {
cmd := newDriveLsCmd(flags)
cmd.SetContext(ctx3)
cmd.SetArgs([]string{})
if execErr := cmd.Execute(); execErr != nil {
t.Fatalf("execute: %v", execErr)
}
})
if !strings.Contains(plainOut, "ID\tNAME\tTYPE\tSIZE\tMODIFIED") {
t.Fatalf("expected TSV header, got: %q", plainOut)
}
}

View File

@ -63,7 +63,7 @@ func TestDrivePermissionsCmd_TextAndJSON(t *testing.T) {
t.Fatalf("ui.New: %v", err)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.ModeText)
ctx = outfmt.WithMode(ctx, outfmt.Mode{})
textOut := captureStdout(t, func() {
cmd := newDrivePermissionsCmd(flags)
@ -90,7 +90,7 @@ func TestDrivePermissionsCmd_TextAndJSON(t *testing.T) {
t.Fatalf("ui.New: %v", err)
}
ctx2 := ui.WithUI(context.Background(), u2)
ctx2 = outfmt.WithMode(ctx2, outfmt.ModeJSON)
ctx2 = outfmt.WithMode(ctx2, outfmt.Mode{JSON: true})
jsonOut := captureStdout(t, func() {
cmd := newDrivePermissionsCmd(flags)

View File

@ -71,7 +71,7 @@ func TestDriveURLCmd_TextAndJSON(t *testing.T) {
t.Fatalf("ui.New: %v", err)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.ModeText)
ctx = outfmt.WithMode(ctx, outfmt.Mode{})
cmd := newDriveURLCmd(flags)
cmd.SetContext(ctx)
@ -94,7 +94,7 @@ func TestDriveURLCmd_TextAndJSON(t *testing.T) {
t.Fatalf("ui.New: %v", uiErr)
}
ctx2 := ui.WithUI(context.Background(), u2)
ctx2 = outfmt.WithMode(ctx2, outfmt.ModeJSON)
ctx2 = outfmt.WithMode(ctx2, outfmt.Mode{JSON: true})
cmd2 := newDriveURLCmd(flags)
cmd2.SetContext(ctx2)

View File

@ -30,7 +30,7 @@ func TestExecute_AuthAdd_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "auth", "add", "a@b.com", "--services", "calendar,gmail"}); err != nil {
if err := Execute([]string{"--json", "auth", "add", "a@b.com", "--services", "calendar,gmail"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})

View File

@ -20,7 +20,7 @@ func TestExecute_AuthCredentials_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "auth", "credentials", in}); err != nil {
if err := Execute([]string{"--json", "auth", "credentials", in}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -47,3 +47,29 @@ func TestExecute_AuthCredentials_JSON(t *testing.T) {
t.Fatalf("stat: %v size=%d", err, st.Size())
}
}
func TestExecute_AuthCredentials_Stdin_JSON(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
withStdin(t, `{"installed":{"client_id":"id","client_secret":"sec"}}`, func() {
if err := Execute([]string{"--json", "auth", "credentials", "-"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
})
var parsed struct {
Saved bool `json:"saved"`
Path string `json:"path"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if !parsed.Saved || parsed.Path == "" {
t.Fatalf("unexpected: %#v", parsed)
}
}

View File

@ -94,37 +94,37 @@ func TestExecute_CalendarMoreCommands_JSON(t *testing.T) {
_ = captureStderr(t, func() {
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "calendar", "acl", calendarID}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "acl", calendarID}); err != nil {
t.Fatalf("acl: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "calendar", "events", calendarID, "--from", "2025-12-17T00:00:00Z", "--to", "2025-12-18T00:00:00Z", "--query", "hello"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "events", calendarID, "--from", "2025-12-17T00:00:00Z", "--to", "2025-12-18T00:00:00Z", "--query", "hello"}); err != nil {
t.Fatalf("events: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "calendar", "event", calendarID, eventID}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "event", calendarID, eventID}); err != nil {
t.Fatalf("event: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "calendar", "create", calendarID, "--summary", "S", "--from", "2025-12-17T10:00:00Z", "--to", "2025-12-17T11:00:00Z"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "create", calendarID, "--summary", "S", "--from", "2025-12-17T10:00:00Z", "--to", "2025-12-17T11:00:00Z"}); err != nil {
t.Fatalf("create: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "calendar", "update", calendarID, eventID, "--summary", "S2"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "update", calendarID, eventID, "--summary", "S2"}); err != nil {
t.Fatalf("update: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "calendar", "delete", calendarID, eventID}); err != nil {
if err := Execute([]string{"--json", "--force", "--account", "a@b.com", "calendar", "delete", calendarID, eventID}); err != nil {
t.Fatalf("delete: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "calendar", "freebusy", "c1", "--from", "2025-12-17T00:00:00Z", "--to", "2025-12-18T00:00:00Z"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "freebusy", "c1", "--from", "2025-12-17T00:00:00Z", "--to", "2025-12-18T00:00:00Z"}); err != nil {
t.Fatalf("freebusy: %v", err)
}
})

View File

@ -50,7 +50,7 @@ func TestExecute_CalendarCalendars_MaxAndPage_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--output", "json",
"--json",
"--account", "a@b.com",
"calendar", "calendars",
"--max", "1",
@ -113,7 +113,7 @@ func TestExecute_CalendarAcl_MaxAndPage_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--output", "json",
"--json",
"--account", "a@b.com",
"calendar", "acl", "c1",
"--max", "2",

View File

@ -73,7 +73,7 @@ func TestExecute_CalendarRespond_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--output", "json",
"--json",
"--account", "a@b.com",
"calendar", "respond",
calendarID, eventID,

View File

@ -43,7 +43,7 @@ func TestExecute_CalendarCalendars_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "calendar", "calendars"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "calendars"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})

View File

@ -51,7 +51,7 @@ func TestExecute_CalendarEvent_Text(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "text", "--account", "a@b.com", "calendar", "event", "c1", "e1"}); err != nil {
if err := Execute([]string{"--account", "a@b.com", "calendar", "event", "c1", "e1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -91,7 +91,7 @@ func TestExecute_CalendarAcl_Text(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "text", "--account", "a@b.com", "calendar", "acl", "c1"}); err != nil {
if err := Execute([]string{"--account", "a@b.com", "calendar", "acl", "c1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -132,7 +132,7 @@ func TestExecute_CalendarCalendars_Text(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "text", "--account", "a@b.com", "calendar", "calendars"}); err != nil {
if err := Execute([]string{"--account", "a@b.com", "calendar", "calendars"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})

View File

@ -49,7 +49,7 @@ func TestExecute_ContactsDirectoryList_Text(t *testing.T) {
out := captureStdout(t, func() {
errOut := captureStderr(t, func() {
if err := Execute([]string{"--output", "text", "--account", "a@b.com", "contacts", "directory", "list", "--max", "1"}); err != nil {
if err := Execute([]string{"--account", "a@b.com", "contacts", "directory", "list", "--max", "1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})

View File

@ -124,42 +124,42 @@ func TestExecute_ContactsMoreCommands_JSON(t *testing.T) {
_ = captureStderr(t, func() {
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "contacts", "search", "Ada"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "contacts", "search", "Ada"}); err != nil {
t.Fatalf("search: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "contacts", "create", "--given", "Ada", "--email", "ada@example.com", "--phone", "+1"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "contacts", "create", "--given", "Ada", "--email", "ada@example.com", "--phone", "+1"}); err != nil {
t.Fatalf("create: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "contacts", "update", "people/c1", "--given", "Ada", "--family", "Updated"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "contacts", "update", "people/c1", "--given", "Ada", "--family", "Updated"}); err != nil {
t.Fatalf("update: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "contacts", "delete", "people/c1"}); err != nil {
if err := Execute([]string{"--json", "--force", "--account", "a@b.com", "contacts", "delete", "people/c1"}); err != nil {
t.Fatalf("delete: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "contacts", "directory", "list", "--max", "1"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "contacts", "directory", "list", "--max", "1"}); err != nil {
t.Fatalf("dir list: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "contacts", "directory", "search", "Dir", "--max", "1"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "contacts", "directory", "search", "Dir", "--max", "1"}); err != nil {
t.Fatalf("dir search: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "contacts", "other", "list", "--max", "1"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "contacts", "other", "list", "--max", "1"}); err != nil {
t.Fatalf("other list: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "contacts", "other", "search", "Other"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "contacts", "other", "search", "Other"}); err != nil {
t.Fatalf("other search: %v", err)
}
})

View File

@ -49,7 +49,7 @@ func TestExecute_ContactsList_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "contacts", "list", "--max", "1"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "contacts", "list", "--max", "1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -112,7 +112,7 @@ func TestExecute_ContactsGet_ByEmail_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "contacts", "get", "ada@example.com"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "contacts", "get", "ada@example.com"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})

View File

@ -60,7 +60,7 @@ func TestExecute_DriveDownload_WithOutFile_JSON(t *testing.T) {
stdout := captureStdout(t, func() {
_ = captureStderr(t, func() {
if execErr := Execute([]string{
"--output", "json",
"--json",
"--account", "a@b.com",
"drive", "download", "id1",
"--out", outPath,
@ -135,7 +135,7 @@ func TestExecute_DriveDownload_WithOutDir_JSON(t *testing.T) {
stdout := captureStdout(t, func() {
_ = captureStderr(t, func() {
if execErr := Execute([]string{
"--output", "json",
"--json",
"--account", "a@b.com",
"drive", "download", "id1",
"--out", outDir,

View File

@ -109,47 +109,47 @@ func TestExecute_DriveMoreCommands_JSON(t *testing.T) {
_ = captureStderr(t, func() {
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "drive", "search", "hello"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "search", "hello"}); err != nil {
t.Fatalf("search: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "drive", "upload", tmpFile, "--name", "upload.bin", "--folder", "np"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "upload", tmpFile, "--name", "upload.bin", "--folder", "np"}); err != nil {
t.Fatalf("upload: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "drive", "mkdir", "Folder", "--parent", "np"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "mkdir", "Folder", "--parent", "np"}); err != nil {
t.Fatalf("mkdir: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "drive", "rename", "id1", "New"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "rename", "id1", "New"}); err != nil {
t.Fatalf("rename: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "drive", "move", "id1", "np"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "move", "id1", "np"}); err != nil {
t.Fatalf("move: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "drive", "share", "id1", "--anyone", "--role", "reader"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "share", "id1", "--anyone", "--role", "reader"}); err != nil {
t.Fatalf("share: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "drive", "permissions", "id1"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "permissions", "id1"}); err != nil {
t.Fatalf("permissions: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "drive", "unshare", "id1", "p1"}); err != nil {
if err := Execute([]string{"--json", "--force", "--account", "a@b.com", "drive", "unshare", "id1", "p1"}); err != nil {
t.Fatalf("unshare: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "drive", "delete", "id1"}); err != nil {
if err := Execute([]string{"--json", "--force", "--account", "a@b.com", "drive", "delete", "id1"}); err != nil {
t.Fatalf("delete: %v", err)
}
})

View File

@ -55,7 +55,7 @@ func TestExecute_DriveGet_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "drive", "get", "id1"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "get", "id1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -123,7 +123,7 @@ func TestExecute_DriveDownload_JSON(t *testing.T) {
dest := filepath.Join(t.TempDir(), "out.bin")
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "drive", "download", "id1", "--out", dest}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "download", "id1", "--out", dest}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -184,7 +184,7 @@ func TestDriveDownloadCmd_FileHasNoName(t *testing.T) {
t.Fatalf("ui.New: %v", err)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.ModeText)
ctx = outfmt.WithMode(ctx, outfmt.Mode{})
cmd := newDriveDownloadCmd(flags)
cmd.SetContext(ctx)

View File

@ -47,7 +47,7 @@ func TestExecute_DriveGet_Text(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "text", "--account", "a@b.com", "drive", "get", "id1"}); err != nil {
if err := Execute([]string{"--account", "a@b.com", "drive", "get", "id1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -83,7 +83,7 @@ func TestExecute_DrivePermissions_Text_NoPermissions(t *testing.T) {
errOut := captureStderr(t, func() {
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "text", "--account", "a@b.com", "drive", "permissions", "id1"}); err != nil {
if err := Execute([]string{"--account", "a@b.com", "drive", "permissions", "id1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -124,7 +124,7 @@ func TestExecute_DrivePermissions_Text_WithPermissions(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "text", "--account", "a@b.com", "drive", "permissions", "id1"}); err != nil {
if err := Execute([]string{"--account", "a@b.com", "drive", "permissions", "id1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -165,7 +165,7 @@ func TestExecute_DriveSearch_Text(t *testing.T) {
out := captureStdout(t, func() {
errOut := captureStderr(t, func() {
if err := Execute([]string{"--output", "text", "--account", "a@b.com", "drive", "search", "Doc", "--max", "1"}); err != nil {
if err := Execute([]string{"--account", "a@b.com", "drive", "search", "Doc", "--max", "1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})

View File

@ -75,7 +75,7 @@ func TestExecute_GmailAttachment_OutPath_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if execErr := Execute([]string{
"--output", "json",
"--json",
"--account", "a@b.com",
"gmail", "attachment", "m1", "a1",
"--out", outPath,
@ -173,7 +173,7 @@ func TestExecute_GmailAttachment_NameOverride_ConfigDir_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if execErr := Execute([]string{
"--output", "json",
"--json",
"--account", "a@b.com",
"gmail", "attachment", "m1", "a1",
"--name", "override.bin",
@ -241,7 +241,7 @@ func TestExecute_GmailAttachment_NotFound(t *testing.T) {
outPath := filepath.Join(t.TempDir(), "a.bin")
err = Execute([]string{
"--output", "json",
"--json",
"--account", "a@b.com",
"gmail", "attachment", "m1", "a1",
"--out", outPath,

View File

@ -57,7 +57,7 @@ func TestExecute_GmailGet_Metadata_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--output", "json",
"--json",
"--account", "a@b.com",
"gmail", "get", "m1",
"--format", "metadata",
@ -132,7 +132,7 @@ func TestExecute_GmailGet_Raw_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--output", "json",
"--json",
"--account", "a@b.com",
"gmail", "get", "m1",
"--format", "raw",
@ -212,7 +212,6 @@ func TestExecute_GmailGet_Metadata_Text(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--output", "text",
"--account", "a@b.com",
"gmail", "get", "m1",
"--format", "metadata",

View File

@ -57,7 +57,7 @@ func TestExecute_GmailLabelsGet_Text(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "text", "--account", "a@b.com", "gmail", "labels", "get", "INBOX"}); err != nil {
if err := Execute([]string{"--account", "a@b.com", "gmail", "labels", "get", "INBOX"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})

View File

@ -129,7 +129,7 @@ func TestExecute_GmailThreadDraftsSend_JSON(t *testing.T) {
_ = captureStderr(t, func() {
out := captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "gmail", "thread", "t1", "--download"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "thread", "t1", "--download"}); err != nil {
t.Fatalf("thread: %v", err)
}
})
@ -148,32 +148,32 @@ func TestExecute_GmailThreadDraftsSend_JSON(t *testing.T) {
}
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "gmail", "drafts", "list"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "drafts", "list"}); err != nil {
t.Fatalf("drafts list: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "gmail", "drafts", "get", "d1", "--download"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "drafts", "get", "d1", "--download"}); err != nil {
t.Fatalf("drafts get: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "gmail", "drafts", "create", "--to", "x@y.com", "--subject", "S", "--body", "B"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "drafts", "create", "--to", "x@y.com", "--subject", "S", "--body", "B"}); err != nil {
t.Fatalf("drafts create: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "gmail", "drafts", "send", "d1"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "drafts", "send", "d1"}); err != nil {
t.Fatalf("drafts send: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "gmail", "drafts", "delete", "d1"}); err != nil {
if err := Execute([]string{"--json", "--force", "--account", "a@b.com", "gmail", "drafts", "delete", "d1"}); err != nil {
t.Fatalf("drafts delete: %v", err)
}
})
_ = captureStdout(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "gmail", "send", "--to", "x@y.com", "--subject", "S", "--body", "B"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "send", "--to", "x@y.com", "--subject", "S", "--body", "B"}); err != nil {
t.Fatalf("send: %v", err)
}
})

View File

@ -59,7 +59,7 @@ func TestExecute_GmailSend_ReplyToHeader(t *testing.T) {
_ = captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--output", "json",
"--json",
"--account", "a@b.com",
"gmail", "send",
"--to", "x@y.com",
@ -141,7 +141,7 @@ func TestExecute_GmailSend_ReplyToMessageID(t *testing.T) {
_ = captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--output", "json",
"--json",
"--account", "a@b.com",
"gmail", "send",
"--to", "x@y.com",
@ -226,7 +226,7 @@ func TestExecute_GmailDraftsCreate_ReplyToMessageID(t *testing.T) {
_ = captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--output", "json",
"--json",
"--account", "a@b.com",
"gmail", "drafts", "create",
"--to", "x@y.com",

View File

@ -75,7 +75,7 @@ func TestExecute_GmailSearch_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "gmail", "search", "newer_than:7d", "--max", "1"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "search", "newer_than:7d", "--max", "1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -111,7 +111,7 @@ func TestExecute_GmailSearch_JSON(t *testing.T) {
func TestExecute_GmailURL_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "gmail", "url", "t1"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "url", "t1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})

View File

@ -78,7 +78,7 @@ func TestExecute_GmailThread_Text_Download(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "text", "--account", "a@b.com", "gmail", "thread", "t-thread-1", "--download"}); err != nil {
if err := Execute([]string{"--account", "a@b.com", "gmail", "thread", "t-thread-1", "--download"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -144,7 +144,7 @@ func TestExecute_GmailDraftsGet_Text_Download(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "text", "--account", "a@b.com", "gmail", "drafts", "get", "d1", "--download"}); err != nil {
if err := Execute([]string{"--account", "a@b.com", "gmail", "drafts", "get", "d1", "--download"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})

View File

@ -50,7 +50,7 @@ func TestExecute_ContactsList_Text(t *testing.T) {
out := captureStdout(t, func() {
errOut := captureStderr(t, func() {
if err := Execute([]string{"--output", "text", "--account", "a@b.com", "contacts", "list", "--max", "1"}); err != nil {
if err := Execute([]string{"--account", "a@b.com", "contacts", "list", "--max", "1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -96,7 +96,7 @@ func TestExecute_ContactsGet_ByResource_Text(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "text", "--account", "a@b.com", "contacts", "get", "people/c1"}); err != nil {
if err := Execute([]string{"--account", "a@b.com", "contacts", "get", "people/c1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -138,7 +138,7 @@ func TestExecute_CalendarFreeBusy_Text(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "text", "--account", "a@b.com", "calendar", "freebusy", "c1", "--from", "2025-12-17T00:00:00Z", "--to", "2025-12-18T00:00:00Z"}); err != nil {
if err := Execute([]string{"--account", "a@b.com", "calendar", "freebusy", "c1", "--from", "2025-12-17T00:00:00Z", "--to", "2025-12-18T00:00:00Z"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})

View File

@ -44,7 +44,7 @@ func TestExecute_PeopleMe_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "people", "me"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "people", "me"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -98,7 +98,7 @@ func TestExecute_PeopleMe_Text(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "text", "--account", "a@b.com", "people", "me"}); err != nil {
if err := Execute([]string{"--account", "a@b.com", "people", "me"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})

View File

@ -43,7 +43,7 @@ func TestExecute_TasksLists_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "tasks", "lists", "--max", "10"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "tasks", "lists", "--max", "10"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -101,7 +101,7 @@ func TestExecute_TasksListsCreate_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "tasks", "lists", "create", "Teaching"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "tasks", "lists", "create", "Teaching"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -152,7 +152,7 @@ func TestExecute_TasksList_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "tasks", "list", "l1"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "tasks", "list", "l1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -212,7 +212,7 @@ func TestExecute_TasksAdd_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "tasks", "add", "l1", "--title", "Hello"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "tasks", "add", "l1", "--title", "Hello"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -272,7 +272,7 @@ func TestExecute_TasksDone_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "tasks", "done", "l1", "t1"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "tasks", "done", "l1", "t1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -317,7 +317,7 @@ func TestExecute_TasksDelete_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "tasks", "delete", "l1", "t1"}); err != nil {
if err := Execute([]string{"--json", "--force", "--account", "a@b.com", "tasks", "delete", "l1", "t1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -374,7 +374,7 @@ func TestExecute_TasksUpdate_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "tasks", "update", "l1", "t1", "--title", "New title"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "tasks", "update", "l1", "t1", "--title", "New title"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -433,7 +433,7 @@ func TestExecute_TasksUndo_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "tasks", "undo", "l1", "t1"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "tasks", "undo", "l1", "t1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
@ -479,7 +479,7 @@ func TestExecute_TasksClear_JSON(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "tasks", "clear", "l1"}); err != nil {
if err := Execute([]string{"--json", "--force", "--account", "a@b.com", "tasks", "clear", "l1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})

View File

@ -0,0 +1,76 @@
package cmd
import (
"encoding/json"
"strings"
"testing"
)
func TestExecute_VersionFlag(t *testing.T) {
origV, origC, origD := version, commit, date
t.Cleanup(func() {
version = origV
commit = origC
date = origD
})
version = "1.2.3"
commit = "abc123"
date = ""
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--version"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !strings.Contains(out, "1.2.3") {
t.Fatalf("unexpected out=%q", out)
}
}
func TestExecute_VersionCommand_JSON(t *testing.T) {
origV, origC, origD := version, commit, date
t.Cleanup(func() {
version = origV
commit = origC
date = origD
})
version = "1.2.3"
commit = "abc123"
date = "2025-12-26T00:00:00Z"
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "version"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed map[string]any
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if parsed["version"] != "1.2.3" || parsed["commit"] != "abc123" || parsed["date"] != "2025-12-26T00:00:00Z" {
t.Fatalf("unexpected json: %#v", parsed)
}
}
func TestExecute_ExitCodes(t *testing.T) {
err := Execute([]string{"--nope"})
if err == nil {
t.Fatalf("expected error")
}
if ExitCode(err) != 2 {
t.Fatalf("expected exit code 2, got %d (err=%v)", ExitCode(err), err)
}
err = Execute([]string{"drive", "get"})
if err == nil {
t.Fatalf("expected error")
}
if ExitCode(err) != 2 {
t.Fatalf("expected exit code 2, got %d (err=%v)", ExitCode(err), err)
}
}

36
internal/cmd/exit.go Normal file
View File

@ -0,0 +1,36 @@
package cmd
import "errors"
type ExitError struct {
Code int
Err error
}
func (e *ExitError) Error() string {
if e == nil || e.Err == nil {
return ""
}
return e.Err.Error()
}
func (e *ExitError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
func ExitCode(err error) int {
if err == nil {
return 0
}
var ee *ExitError
if errors.As(err, &ee) && ee != nil {
if ee.Code < 0 {
return 1
}
return ee.Code
}
return 1
}

View File

@ -2,6 +2,7 @@ package cmd
import (
"fmt"
"io"
"net/mail"
"os"
"strings"
@ -79,7 +80,6 @@ func newGmailSearchCmd(flags *rootFlags) *cobra.Command {
}
items := make([]item, 0, len(resp.Threads))
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
for _, t := range resp.Threads {
if t.Id == "" {
continue
@ -134,11 +134,20 @@ func newGmailSearchCmd(flags *rootFlags) *cobra.Command {
return nil
}
fmt.Fprintln(tw, "ID\tDATE\tFROM\tSUBJECT\tLABELS")
for _, it := range items {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", it.ID, it.Date, it.From, it.Subject, strings.Join(it.Labels, ","))
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "ID\tDATE\tFROM\tSUBJECT\tLABELS")
for _, it := range items {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", it.ID, it.Date, it.From, it.Subject, strings.Join(it.Labels, ","))
}
if tw != nil {
_ = tw.Flush()
}
_ = tw.Flush()
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)

View File

@ -31,7 +31,7 @@ func newGmailAttachmentCmd(flags *rootFlags) *cobra.Command {
messageID := strings.TrimSpace(args[0])
attachmentID := strings.TrimSpace(args[1])
if messageID == "" || attachmentID == "" {
return errors.New("messageId/attachmentId required")
return usage("messageId/attachmentId required")
}
svc, err := newGmailService(cmd.Context(), account)

View File

@ -2,8 +2,8 @@ package cmd
import (
"encoding/base64"
"errors"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
@ -80,16 +80,23 @@ func newGmailDraftsListCmd(flags *rootFlags) *cobra.Command {
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tMESSAGE_ID")
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "ID\tMESSAGE_ID")
for _, d := range resp.Drafts {
msgID := ""
if d.Message != nil {
msgID = d.Message.Id
}
fmt.Fprintf(tw, "%s\t%s\n", d.Id, msgID)
fmt.Fprintf(w, "%s\t%s\n", d.Id, msgID)
}
if tw != nil {
_ = tw.Flush()
}
_ = tw.Flush()
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
@ -118,6 +125,10 @@ func newGmailDraftsGetCmd(flags *rootFlags) *cobra.Command {
}
draftID := args[0]
if err := confirmDestructive(cmd, flags, fmt.Sprintf("delete gmail draft %s", draftID)); err != nil {
return err
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
@ -307,10 +318,10 @@ func newGmailDraftsCreateCmd(flags *rootFlags) *cobra.Command {
return err
}
if strings.TrimSpace(to) == "" || strings.TrimSpace(subject) == "" {
return errors.New("required: --to, --subject")
return usage("required: --to, --subject")
}
if strings.TrimSpace(body) == "" && strings.TrimSpace(bodyHTML) == "" {
return errors.New("required: --body or --body-html")
return usage("required: --body or --body-html")
}
svc, err := newGmailService(cmd.Context(), account)

View File

@ -2,7 +2,6 @@ package cmd
import (
"encoding/base64"
"errors"
"fmt"
"os"
"strings"
@ -28,7 +27,7 @@ func newGmailGetCmd(flags *rootFlags) *cobra.Command {
}
messageID := strings.TrimSpace(args[0])
if messageID == "" {
return errors.New("empty messageId")
return usage("empty messageId")
}
format = strings.TrimSpace(format)

View File

@ -1,7 +1,6 @@
package cmd
import (
"errors"
"os"
"strings"
@ -26,7 +25,7 @@ func newGmailHistoryCmd(flags *rootFlags) *cobra.Command {
return err
}
if strings.TrimSpace(since) == "" {
return errors.New("--since is required")
return usage("--since is required")
}
startID, err := parseHistoryID(since)
if err != nil {

View File

@ -1,8 +1,8 @@
package cmd
import (
"errors"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
@ -47,7 +47,7 @@ func newGmailLabelsGetCmd(flags *rootFlags) *cobra.Command {
}
raw := strings.TrimSpace(args[0])
if raw == "" {
return errors.New("empty label")
return usage("empty label")
}
id := raw
if v, ok := idMap[strings.ToLower(raw)]; ok {
@ -103,12 +103,19 @@ func newGmailLabelsListCmd(flags *rootFlags) *cobra.Command {
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tTYPE")
for _, l := range resp.Labels {
fmt.Fprintf(tw, "%s\t%s\t%s\n", l.Id, l.Name, l.Type)
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "ID\tNAME\tTYPE")
for _, l := range resp.Labels {
fmt.Fprintf(w, "%s\t%s\t%s\n", l.Id, l.Name, l.Type)
}
if tw != nil {
_ = tw.Flush()
}
_ = tw.Flush()
return nil
},
}
@ -132,7 +139,7 @@ func newGmailLabelsModifyCmd(flags *rootFlags) *cobra.Command {
addLabels := splitCSV(add)
removeLabels := splitCSV(remove)
if len(addLabels) == 0 && len(removeLabels) == 0 {
return errors.New("must specify --add and/or --remove")
return usage("must specify --add and/or --remove")
}
svc, err := newGmailService(cmd.Context(), account)

View File

@ -78,7 +78,7 @@ func TestGmailLabelsGetCmd_JSON(t *testing.T) {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.ModeJSON)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
cmd := newGmailLabelsGetCmd(flags)
cmd.SetContext(ctx)
@ -148,7 +148,7 @@ func TestGmailLabelsListCmd_TextAndJSON(t *testing.T) {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.ModeText)
ctx = outfmt.WithMode(ctx, outfmt.Mode{})
cmd := newGmailLabelsListCmd(flags)
cmd.SetContext(ctx)
@ -170,7 +170,7 @@ func TestGmailLabelsListCmd_TextAndJSON(t *testing.T) {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.ModeJSON)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
cmd := newGmailLabelsListCmd(flags)
cmd.SetContext(ctx)
@ -261,7 +261,7 @@ func TestGmailLabelsModifyCmd_JSON(t *testing.T) {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.ModeJSON)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
cmd := newGmailLabelsModifyCmd(flags)
cmd.SetContext(ctx)

View File

@ -2,7 +2,6 @@ package cmd
import (
"encoding/base64"
"errors"
"os"
"strings"
@ -34,10 +33,10 @@ func newGmailSendCmd(flags *rootFlags) *cobra.Command {
return err
}
if strings.TrimSpace(to) == "" || strings.TrimSpace(subject) == "" {
return errors.New("required: --to, --subject")
return usage("required: --to, --subject")
}
if strings.TrimSpace(body) == "" && strings.TrimSpace(bodyHTML) == "" {
return errors.New("required: --body or --body-html")
return usage("required: --body or --body-html")
}
svc, err := newGmailService(cmd.Context(), account)

View File

@ -52,7 +52,7 @@ func newGmailWatchStartCmd(flags *rootFlags) *cobra.Command {
return err
}
if strings.TrimSpace(topic) == "" {
return errors.New("--topic is required")
return usage("--topic is required")
}
ttl, err := parseDurationSeconds(ttlRaw)
if err != nil {
@ -194,6 +194,11 @@ func newGmailWatchStopCmd(flags *rootFlags) *cobra.Command {
if err != nil {
return err
}
if err := confirmDestructive(cmd, flags, "stop gmail watch and clear stored state"); err != nil {
return err
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
@ -239,19 +244,19 @@ func newGmailWatchServeCmd(flags *rootFlags) *cobra.Command {
return err
}
if !strings.HasPrefix(path, "/") {
return errors.New("--path must start with '/'")
return usage("--path must start with '/'")
}
if port <= 0 {
return errors.New("--port must be > 0")
return usage("--port must be > 0")
}
if !verifyOIDC && sharedToken == "" && !isLoopbackHost(bind) {
return errors.New("--verify-oidc or --token required when binding non-loopback")
return usage("--verify-oidc or --token required when binding non-loopback")
}
if oidcEmail != "" && !verifyOIDC {
return errors.New("--oidc-email requires --verify-oidc")
return usage("--oidc-email requires --verify-oidc")
}
if oidcAudience != "" && !verifyOIDC {
return errors.New("--oidc-audience requires --verify-oidc")
return usage("--oidc-audience requires --verify-oidc")
}
store, err := loadGmailWatchStore(account)
@ -444,10 +449,10 @@ func requestGmailWatch(ctx context.Context, svc *gmail.Service, topic string, la
func hookFromFlags(url, token string, includeBody bool, maxBytes int, maxBytesChanged bool, allowNoHook bool) (*gmailWatchHook, error) {
if strings.TrimSpace(url) == "" {
if token != "" {
return nil, errors.New("--hook-url required when using --hook-token")
return nil, usage("--hook-url required when using --hook-token")
}
if !allowNoHook && (includeBody || maxBytesChanged) {
return nil, errors.New("--hook-url required when setting hook options")
return nil, usage("--hook-url required when setting hook options")
}
return nil, nil
}
@ -455,7 +460,7 @@ func hookFromFlags(url, token string, includeBody bool, maxBytes int, maxBytesCh
if includeBody {
maxBytes = defaultHookMaxBytes
} else if maxBytesChanged {
return nil, errors.New("--max-bytes must be > 0")
return nil, usage("--max-bytes must be > 0")
}
}
return &gmailWatchHook{

View File

@ -74,7 +74,7 @@ func TestGmailWatchStartCmd_JSON(t *testing.T) {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.ModeJSON)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
cmd := newGmailWatchStartCmd(flags)
cmd.SetContext(ctx)

View File

@ -2,7 +2,6 @@ package cmd
import (
"encoding/json"
"errors"
"strings"
"time"
)
@ -100,7 +99,7 @@ func (p *gmailPushPayload) UnmarshalJSON(data []byte) error {
return nil
}
}
return errors.New("historyId must be string or number")
return usage("historyId must be string or number")
}
type gmailHookMessage struct {

View File

@ -1,11 +1,13 @@
package cmd
import (
"errors"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/steipete/gogcli/internal/errfmt"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
@ -14,12 +16,25 @@ import (
type rootFlags struct {
Color string
Account string
Output string
JSON bool
Plain bool
Force bool
NoInput bool
}
func Execute(args []string) error {
flags := rootFlags{Color: envOr("GOG_COLOR", "auto")}
flags.Output = envOr("GOG_OUTPUT", "text")
envMode := outfmt.FromEnv()
flags.JSON = envMode.JSON
flags.Plain = envMode.Plain
// Avoid dangerous prefix-matching for commands (future-proofing).
cobra.EnablePrefixMatching = false
if hasExactArg(args, "--version") {
fmt.Fprintln(os.Stdout, VersionString())
return nil
}
root := &cobra.Command{
Use: "gog",
@ -37,17 +52,17 @@ func Execute(args []string) error {
# Avoid repeating --account
export GOG_ACCOUNT=you@gmail.com
# Gmail
gog gmail search 'newer_than:7d' --max 10
gog gmail thread <threadId>
gog gmail get <messageId> --format metadata
gog gmail attachment <messageId> <attachmentId> --out ./attachment.bin
gog gmail labels get INBOX --output=json
# Gmail
gog gmail search 'newer_than:7d' --max 10
gog gmail thread <threadId>
gog gmail get <messageId> --format metadata
gog gmail attachment <messageId> <attachmentId> --out ./attachment.bin
gog gmail labels get INBOX --json
# Calendar
gog calendar calendars
gog calendar events <calendarId> --from 2025-01-01T00:00:00Z --to 2025-01-08T00:00:00Z --max 50
gog calendar respond <calendarId> <eventId> --status accepted
# Calendar
gog calendar calendars
gog calendar events <calendarId> --from 2025-01-01T00:00:00Z --to 2025-01-08T00:00:00Z --max 50
gog calendar respond <calendarId> <eventId> --status accepted
# Contacts
gog contacts list --max 50
@ -58,14 +73,14 @@ func Execute(args []string) error {
gog tasks lists --max 50
gog tasks list <tasklistId> --max 50
# People
gog people me
# People
gog people me
# Parseable output
gog --output=json drive ls --max 5 | jq .
`),
# Parseable output
gog --json drive ls --max 5 | jq .
`),
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
mode, err := outfmt.Parse(flags.Output)
mode, err := outfmt.FromFlags(flags.JSON, flags.Plain)
if err != nil {
return err
}
@ -75,7 +90,7 @@ func Execute(args []string) error {
Stdout: os.Stdout,
Stderr: os.Stderr,
Color: func() string {
if outfmt.IsJSON(cmd.Context()) {
if outfmt.IsJSON(cmd.Context()) || outfmt.IsPlain(cmd.Context()) {
return "never"
}
return flags.Color
@ -92,20 +107,36 @@ func Execute(args []string) error {
root.SetArgs(args)
root.PersistentFlags().StringVar(&flags.Color, "color", flags.Color, "Color output: auto|always|never")
root.PersistentFlags().StringVar(&flags.Account, "account", "", "Account email for API commands (gmail/calendar/drive/contacts/tasks/people)")
root.PersistentFlags().StringVar(&flags.Output, "output", flags.Output, "Output format: text|json")
root.PersistentFlags().BoolVar(&flags.JSON, "json", flags.JSON, "Output JSON to stdout (best for scripting)")
root.PersistentFlags().BoolVar(&flags.Plain, "plain", flags.Plain, "Output stable, parseable text to stdout (TSV; no colors)")
root.PersistentFlags().BoolVar(&flags.Force, "force", false, "Skip confirmations for destructive commands")
root.PersistentFlags().BoolVar(&flags.NoInput, "no-input", false, "Never prompt; fail instead (useful for CI)")
root.AddCommand(newAuthCmd())
root.AddCommand(newAuthCmd(&flags))
root.AddCommand(newDriveCmd(&flags))
root.AddCommand(newCalendarCmd(&flags))
root.AddCommand(newGmailCmd(&flags))
root.AddCommand(newContactsCmd(&flags))
root.AddCommand(newTasksCmd(&flags))
root.AddCommand(newPeopleCmd(&flags))
root.AddCommand(newVersionCmd())
root.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
// pflag already includes helpful context ("unknown flag", "invalid argument", ...).
return newUsageError(err)
})
err := root.Execute()
if err == nil {
return nil
}
if errors.Is(err, pflag.ErrHelp) {
return nil
}
if ExitCode(err) == 1 && isUsageError(err) {
err = &ExitError{Code: 2, Err: err}
}
if u := ui.FromContext(root.Context()); u != nil {
u.Err().Error(errfmt.Format(err))
@ -121,3 +152,47 @@ func envOr(key, fallback string) string {
}
return fallback
}
func hasExactArg(args []string, target string) bool {
for _, a := range args {
if a == target {
return true
}
}
return false
}
// newUsageError wraps errors in a way main() can map to exit code 2.
func newUsageError(err error) error {
if err == nil {
return nil
}
// Preserve pflag.ErrHelp (should not be treated as failure).
if errors.Is(err, pflag.ErrHelp) {
return err
}
return &ExitError{Code: 2, Err: err}
}
func isUsageError(err error) bool {
var outErr *outfmt.ParseError
if errors.As(err, &outErr) {
return true
}
var uiErr *ui.ParseError
if errors.As(err, &uiErr) {
return true
}
msg := strings.TrimSpace(err.Error())
switch {
case strings.HasPrefix(msg, "accepts "),
strings.HasPrefix(msg, "requires "),
strings.HasPrefix(msg, "unknown command"),
strings.HasPrefix(msg, "invalid argument"),
strings.HasPrefix(msg, "unknown flag"),
strings.HasPrefix(msg, "unknown shorthand flag"):
return true
default:
return false
}
}

View File

@ -1,8 +1,8 @@
package cmd
import (
"errors"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
@ -38,7 +38,7 @@ func newTasksListCmd(flags *rootFlags) *cobra.Command {
}
tasklistID := strings.TrimSpace(args[0])
if tasklistID == "" {
return errors.New("empty tasklistId")
return usage("empty tasklistId")
}
svc, err := newTasksService(cmd.Context(), account)
@ -86,16 +86,23 @@ func newTasksListCmd(flags *rootFlags) *cobra.Command {
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tTITLE\tSTATUS\tDUE\tUPDATED")
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "ID\tTITLE\tSTATUS\tDUE\tUPDATED")
for _, t := range resp.Items {
status := strings.TrimSpace(t.Status)
if status == "" {
status = "needsAction"
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", t.Id, t.Title, status, strings.TrimSpace(t.Due), strings.TrimSpace(t.Updated))
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", t.Id, t.Title, status, strings.TrimSpace(t.Due), strings.TrimSpace(t.Updated))
}
if tw != nil {
_ = tw.Flush()
}
_ = tw.Flush()
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
@ -140,10 +147,10 @@ func newTasksAddCmd(flags *rootFlags) *cobra.Command {
}
tasklistID := strings.TrimSpace(args[0])
if tasklistID == "" {
return errors.New("empty tasklistId")
return usage("empty tasklistId")
}
if strings.TrimSpace(title) == "" {
return errors.New("required: --title")
return usage("required: --title")
}
svc, err := newTasksService(cmd.Context(), account)
@ -213,10 +220,10 @@ func newTasksUpdateCmd(flags *rootFlags) *cobra.Command {
tasklistID := strings.TrimSpace(args[0])
taskID := strings.TrimSpace(args[1])
if tasklistID == "" {
return errors.New("empty tasklistId")
return usage("empty tasklistId")
}
if taskID == "" {
return errors.New("empty taskId")
return usage("empty taskId")
}
patch := &tasks.Task{}
@ -238,11 +245,11 @@ func newTasksUpdateCmd(flags *rootFlags) *cobra.Command {
changed = true
}
if !changed {
return errors.New("no fields to update (set at least one of: --title, --notes, --due, --status)")
return usage("no fields to update (set at least one of: --title, --notes, --due, --status)")
}
if cmd.Flags().Changed("status") && patch.Status != "" && patch.Status != "needsAction" && patch.Status != "completed" {
return errors.New("invalid --status (expected needsAction or completed)")
return usage("invalid --status (expected needsAction or completed)")
}
svc, err := newTasksService(cmd.Context(), account)
@ -295,10 +302,14 @@ func newTasksDoneCmd(flags *rootFlags) *cobra.Command {
tasklistID := strings.TrimSpace(args[0])
taskID := strings.TrimSpace(args[1])
if tasklistID == "" {
return errors.New("empty tasklistId")
return usage("empty tasklistId")
}
if taskID == "" {
return errors.New("empty taskId")
return usage("empty taskId")
}
if err := confirmDestructive(cmd, flags, fmt.Sprintf("delete task %s from list %s", taskID, tasklistID)); err != nil {
return err
}
svc, err := newTasksService(cmd.Context(), account)
@ -336,10 +347,10 @@ func newTasksUndoCmd(flags *rootFlags) *cobra.Command {
tasklistID := strings.TrimSpace(args[0])
taskID := strings.TrimSpace(args[1])
if tasklistID == "" {
return errors.New("empty tasklistId")
return usage("empty tasklistId")
}
if taskID == "" {
return errors.New("empty taskId")
return usage("empty taskId")
}
svc, err := newTasksService(cmd.Context(), account)
@ -377,10 +388,10 @@ func newTasksDeleteCmd(flags *rootFlags) *cobra.Command {
tasklistID := strings.TrimSpace(args[0])
taskID := strings.TrimSpace(args[1])
if tasklistID == "" {
return errors.New("empty tasklistId")
return usage("empty tasklistId")
}
if taskID == "" {
return errors.New("empty taskId")
return usage("empty taskId")
}
svc, err := newTasksService(cmd.Context(), account)
@ -418,7 +429,11 @@ func newTasksClearCmd(flags *rootFlags) *cobra.Command {
}
tasklistID := strings.TrimSpace(args[0])
if tasklistID == "" {
return errors.New("empty tasklistId")
return usage("empty tasklistId")
}
if err := confirmDestructive(cmd, flags, fmt.Sprintf("clear completed tasks from list %s", tasklistID)); err != nil {
return err
}
svc, err := newTasksService(cmd.Context(), account)

View File

@ -1,8 +1,8 @@
package cmd
import (
"errors"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
@ -51,12 +51,19 @@ func newTasksListsCmd(flags *rootFlags) *cobra.Command {
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tTITLE")
for _, tl := range resp.Items {
fmt.Fprintf(tw, "%s\t%s\n", tl.Id, tl.Title)
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "ID\tTITLE")
for _, tl := range resp.Items {
fmt.Fprintf(w, "%s\t%s\n", tl.Id, tl.Title)
}
if tw != nil {
_ = tw.Flush()
}
_ = tw.Flush()
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
}
@ -84,7 +91,7 @@ func newTasksListsCreateCmd(flags *rootFlags) *cobra.Command {
}
title := strings.TrimSpace(strings.Join(args, " "))
if title == "" {
return errors.New("empty title")
return usage("empty title")
}
svc, err := newTasksService(cmd.Context(), account)

View File

@ -43,3 +43,22 @@ func captureStderr(t *testing.T, fn func()) string {
_ = r.Close()
return string(b)
}
func withStdin(t *testing.T, input string, fn func()) {
t.Helper()
orig := os.Stdin
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
os.Stdin = r
_, _ = io.WriteString(w, input)
_ = w.Close()
fn()
_ = r.Close()
os.Stdin = orig
}

14
internal/cmd/usage.go Normal file
View File

@ -0,0 +1,14 @@
package cmd
import (
"errors"
"fmt"
)
func usage(msg string) error {
return &ExitError{Code: 2, Err: errors.New(msg)}
}
func usagef(format string, args ...any) error {
return &ExitError{Code: 2, Err: fmt.Errorf(format, args...)}
}

52
internal/cmd/version.go Normal file
View File

@ -0,0 +1,52 @@
package cmd
import (
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/outfmt"
)
var (
version = "dev"
commit = ""
date = ""
)
func VersionString() string {
v := strings.TrimSpace(version)
if v == "" {
v = "dev"
}
if strings.TrimSpace(commit) == "" && strings.TrimSpace(date) == "" {
return v
}
if strings.TrimSpace(commit) == "" {
return fmt.Sprintf("%s (%s)", v, strings.TrimSpace(date))
}
if strings.TrimSpace(date) == "" {
return fmt.Sprintf("%s (%s)", v, strings.TrimSpace(commit))
}
return fmt.Sprintf("%s (%s %s)", v, strings.TrimSpace(commit), strings.TrimSpace(date))
}
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print version",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"version": strings.TrimSpace(version),
"commit": strings.TrimSpace(commit),
"date": strings.TrimSpace(date),
})
}
fmt.Fprintln(os.Stdout, VersionString())
return nil
},
}
}

View File

@ -3,26 +3,31 @@ package outfmt
import (
"context"
"encoding/json"
"errors"
"io"
"os"
"strings"
)
type Mode string
type Mode struct {
JSON bool
Plain bool
}
const (
ModeText Mode = "text"
ModeJSON Mode = "json"
)
type ParseError struct{ msg string }
func Parse(s string) (Mode, error) {
switch Mode(strings.ToLower(strings.TrimSpace(s))) {
case ModeText, "":
return ModeText, nil
case ModeJSON:
return ModeJSON, nil
default:
return "", errors.New("invalid --output (expected text|json)")
func (e *ParseError) Error() string { return e.msg }
func FromFlags(jsonOut bool, plainOut bool) (Mode, error) {
if jsonOut && plainOut {
return Mode{}, &ParseError{msg: "invalid output mode (cannot combine --json and --plain)"}
}
return Mode{JSON: jsonOut, Plain: plainOut}, nil
}
func FromEnv() Mode {
return Mode{
JSON: envBool("GOG_JSON"),
Plain: envBool("GOG_PLAIN"),
}
}
@ -38,12 +43,11 @@ func FromContext(ctx context.Context) Mode {
return m
}
}
return ModeText
return Mode{}
}
func IsJSON(ctx context.Context) bool {
return FromContext(ctx) == ModeJSON
}
func IsJSON(ctx context.Context) bool { return FromContext(ctx).JSON }
func IsPlain(ctx context.Context) bool { return FromContext(ctx).Plain }
func WriteJSON(w io.Writer, v any) error {
enc := json.NewEncoder(w)
@ -51,3 +55,13 @@ func WriteJSON(w io.Writer, v any) error {
enc.SetIndent("", " ")
return enc.Encode(v)
}
func envBool(key string) bool {
v := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
switch v {
case "1", "true", "yes", "y", "on":
return true
default:
return false
}
}

View File

@ -6,40 +6,27 @@ import (
"testing"
)
func TestParse(t *testing.T) {
tests := []struct {
in string
want Mode
wantErr bool
}{
{"", ModeText, false},
{"text", ModeText, false},
{"json", ModeJSON, false},
{" JSON ", ModeJSON, false},
{"nope", "", true},
func TestFromFlags(t *testing.T) {
if _, err := FromFlags(true, true); err == nil {
t.Fatalf("expected error when combining --json and --plain")
}
for _, tt := range tests {
got, err := Parse(tt.in)
if tt.wantErr && err == nil {
t.Fatalf("Parse(%q): expected error", tt.in)
}
if !tt.wantErr && err != nil {
t.Fatalf("Parse(%q): %v", tt.in, err)
}
if got != tt.want {
t.Fatalf("Parse(%q)=%q want %q", tt.in, got, tt.want)
}
got, err := FromFlags(true, false)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !got.JSON || got.Plain {
t.Fatalf("unexpected mode: %#v", got)
}
}
func TestContextMode(t *testing.T) {
ctx := context.Background()
if FromContext(ctx) != ModeText {
if IsJSON(ctx) || IsPlain(ctx) {
t.Fatalf("expected default text")
}
ctx = WithMode(ctx, ModeJSON)
if !IsJSON(ctx) {
t.Fatalf("expected json")
ctx = WithMode(ctx, Mode{JSON: true})
if !IsJSON(ctx) || IsPlain(ctx) {
t.Fatalf("expected json-only")
}
}

View File

@ -2,7 +2,6 @@ package ui
import (
"context"
"errors"
"fmt"
"io"
"os"
@ -22,6 +21,10 @@ type UI struct {
err *Printer
}
type ParseError struct{ msg string }
func (e *ParseError) Error() string { return e.msg }
func New(opts Options) (*UI, error) {
if opts.Stdout == nil {
opts.Stdout = os.Stdout
@ -35,7 +38,7 @@ func New(opts Options) (*UI, error) {
colorMode = "auto"
}
if colorMode != "auto" && colorMode != "always" && colorMode != "never" {
return nil, errors.New("invalid --color (expected auto|always|never)")
return nil, &ParseError{msg: "invalid --color (expected auto|always|never)"}
}
out := termenv.NewOutput(opts.Stdout, termenv.WithProfile(termenv.EnvColorProfile()))

View File

@ -11,6 +11,12 @@ function run(cmd, args) {
return res.status;
}
function runCapture(cmd, args) {
const res = spawnSync(cmd, args, { stdio: ["ignore", "pipe", "ignore"] });
if (res.error || res.status !== 0) return "";
return String(res.stdout || "").trim();
}
const repoRoot = process.cwd();
const binDir = join(repoRoot, "bin");
mkdirSync(binDir, { recursive: true });
@ -18,7 +24,16 @@ mkdirSync(binDir, { recursive: true });
const exe = process.platform === "win32" ? "gog.exe" : "gog";
const binPath = join(binDir, exe);
run("go", ["build", "-o", binPath, "./cmd/gog"]);
const version = runCapture("git", ["describe", "--tags", "--always", "--dirty"]) || "dev";
const commit = runCapture("git", ["rev-parse", "--short=12", "HEAD"]) || "";
const date = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
const ldflags = [
`-X github.com/steipete/gogcli/internal/cmd.version=${version}`,
`-X github.com/steipete/gogcli/internal/cmd.commit=${commit}`,
`-X github.com/steipete/gogcli/internal/cmd.date=${date}`,
].join(" ");
run("go", ["build", "-ldflags", ldflags, "-o", binPath, "./cmd/gog"]);
const final = spawnSync(binPath, process.argv.slice(2), { stdio: "inherit" });
if (final.error) throw final.error;