diff --git a/Makefile b/Makefile index ec6e3b4..be011d3 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/cmd/gog/main.go b/cmd/gog/main.go index 7fca347..154cc3c 100644 --- a/cmd/gog/main.go +++ b/cmd/gog/main.go @@ -8,6 +8,6 @@ import ( func main() { if err := cmd.Execute(os.Args[1:]); err != nil { - os.Exit(1) + os.Exit(cmd.ExitCode(err)) } } diff --git a/internal/cmd/account.go b/internal/cmd/account.go index 649ae3b..13b24fc 100644 --- a/internal/cmd/account.go +++ b/internal/cmd/account.go @@ -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)") } diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 1774d6f..1441cf5 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -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 ", + Use: "credentials ", 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 ", @@ -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 ", + Use: "import ", 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 ", 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 diff --git a/internal/cmd/auth_cmd_test.go b/internal/cmd/auth_cmd_test.go index 80aa150..300569d 100644 --- a/internal/cmd/auth_cmd_test.go +++ b/internal/cmd/auth_cmd_test.go @@ -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) } }) diff --git a/internal/cmd/calendar.go b/internal/cmd/calendar.go index 5a4f1e7..2f3c160 100644 --- a/internal/cmd/calendar.go +++ b/internal/cmd/calendar.go @@ -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 }, } diff --git a/internal/cmd/confirm.go b/internal/cmd/confirm.go new file mode 100644 index 0000000..b248084 --- /dev/null +++ b/internal/cmd/confirm.go @@ -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")} +} diff --git a/internal/cmd/confirm_test.go b/internal/cmd/confirm_test.go new file mode 100644 index 0000000..c8cacac --- /dev/null +++ b/internal/cmd/confirm_test.go @@ -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) + } + }) +} diff --git a/internal/cmd/contacts.go b/internal/cmd/contacts.go index 42bfd47..9f26243 100644 --- a/internal/cmd/contacts.go +++ b/internal/cmd/contacts.go @@ -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 }, } diff --git a/internal/cmd/contacts_crud.go b/internal/cmd/contacts_crud.go index 328af91..f99e845 100644 --- a/internal/cmd/contacts_crud.go +++ b/internal/cmd/contacts_crud.go @@ -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) diff --git a/internal/cmd/contacts_directory.go b/internal/cmd/contacts_directory.go index b54a5b8..bdd3186 100644 --- a/internal/cmd/contacts_directory.go +++ b/internal/cmd/contacts_directory.go @@ -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 }, } diff --git a/internal/cmd/drive.go b/internal/cmd/drive.go index 7033c05..0da69ee 100644 --- a/internal/cmd/drive.go +++ b/internal/cmd/drive.go @@ -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) diff --git a/internal/cmd/drive_ls_cmd_test.go b/internal/cmd/drive_ls_cmd_test.go index f74a9c8..0b7a6a0 100644 --- a/internal/cmd/drive_ls_cmd_test.go +++ b/internal/cmd/drive_ls_cmd_test.go @@ -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) + } } diff --git a/internal/cmd/drive_permissions_cmd_test.go b/internal/cmd/drive_permissions_cmd_test.go index 6c93165..5b1713c 100644 --- a/internal/cmd/drive_permissions_cmd_test.go +++ b/internal/cmd/drive_permissions_cmd_test.go @@ -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) diff --git a/internal/cmd/drive_url_cmd_test.go b/internal/cmd/drive_url_cmd_test.go index e91e37c..9fbc3cb 100644 --- a/internal/cmd/drive_url_cmd_test.go +++ b/internal/cmd/drive_url_cmd_test.go @@ -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) diff --git a/internal/cmd/execute_auth_add_test.go b/internal/cmd/execute_auth_add_test.go index a57a33e..676ab9f 100644 --- a/internal/cmd/execute_auth_add_test.go +++ b/internal/cmd/execute_auth_add_test.go @@ -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) } }) diff --git a/internal/cmd/execute_auth_credentials_test.go b/internal/cmd/execute_auth_credentials_test.go index 797eee9..6c78e19 100644 --- a/internal/cmd/execute_auth_credentials_test.go +++ b/internal/cmd/execute_auth_credentials_test.go @@ -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) + } +} diff --git a/internal/cmd/execute_calendar_more_commands_test.go b/internal/cmd/execute_calendar_more_commands_test.go index 6cb3b88..8ddf095 100644 --- a/internal/cmd/execute_calendar_more_commands_test.go +++ b/internal/cmd/execute_calendar_more_commands_test.go @@ -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) } }) diff --git a/internal/cmd/execute_calendar_paging_test.go b/internal/cmd/execute_calendar_paging_test.go index 441b830..af648df 100644 --- a/internal/cmd/execute_calendar_paging_test.go +++ b/internal/cmd/execute_calendar_paging_test.go @@ -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", diff --git a/internal/cmd/execute_calendar_respond_test.go b/internal/cmd/execute_calendar_respond_test.go index 187c217..cf15247 100644 --- a/internal/cmd/execute_calendar_respond_test.go +++ b/internal/cmd/execute_calendar_respond_test.go @@ -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, diff --git a/internal/cmd/execute_calendar_test.go b/internal/cmd/execute_calendar_test.go index 5d4a632..2a1dc38 100644 --- a/internal/cmd/execute_calendar_test.go +++ b/internal/cmd/execute_calendar_test.go @@ -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) } }) diff --git a/internal/cmd/execute_calendar_text_test.go b/internal/cmd/execute_calendar_text_test.go index dfca3d2..dc5edcc 100644 --- a/internal/cmd/execute_calendar_text_test.go +++ b/internal/cmd/execute_calendar_text_test.go @@ -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) } }) diff --git a/internal/cmd/execute_contacts_directory_text_test.go b/internal/cmd/execute_contacts_directory_text_test.go index aa08a9b..2a29282 100644 --- a/internal/cmd/execute_contacts_directory_text_test.go +++ b/internal/cmd/execute_contacts_directory_text_test.go @@ -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) } }) diff --git a/internal/cmd/execute_contacts_more_commands_test.go b/internal/cmd/execute_contacts_more_commands_test.go index a65a2ea..3eec9a8 100644 --- a/internal/cmd/execute_contacts_more_commands_test.go +++ b/internal/cmd/execute_contacts_more_commands_test.go @@ -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) } }) diff --git a/internal/cmd/execute_contacts_test.go b/internal/cmd/execute_contacts_test.go index ee9c515..702d4f8 100644 --- a/internal/cmd/execute_contacts_test.go +++ b/internal/cmd/execute_contacts_test.go @@ -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) } }) diff --git a/internal/cmd/execute_drive_download_test.go b/internal/cmd/execute_drive_download_test.go index 3e711c2..513aaf7 100644 --- a/internal/cmd/execute_drive_download_test.go +++ b/internal/cmd/execute_drive_download_test.go @@ -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, diff --git a/internal/cmd/execute_drive_more_commands_test.go b/internal/cmd/execute_drive_more_commands_test.go index 8b8f91f..982b4df 100644 --- a/internal/cmd/execute_drive_more_commands_test.go +++ b/internal/cmd/execute_drive_more_commands_test.go @@ -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) } }) diff --git a/internal/cmd/execute_drive_test.go b/internal/cmd/execute_drive_test.go index 75a1b64..36008aa 100644 --- a/internal/cmd/execute_drive_test.go +++ b/internal/cmd/execute_drive_test.go @@ -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) diff --git a/internal/cmd/execute_drive_text_test.go b/internal/cmd/execute_drive_text_test.go index 82b6d79..b30d38e 100644 --- a/internal/cmd/execute_drive_text_test.go +++ b/internal/cmd/execute_drive_text_test.go @@ -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) } }) diff --git a/internal/cmd/execute_gmail_attachment_test.go b/internal/cmd/execute_gmail_attachment_test.go index 43e72e5..e483c06 100644 --- a/internal/cmd/execute_gmail_attachment_test.go +++ b/internal/cmd/execute_gmail_attachment_test.go @@ -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, diff --git a/internal/cmd/execute_gmail_get_test.go b/internal/cmd/execute_gmail_get_test.go index a2e88d5..7b36a18 100644 --- a/internal/cmd/execute_gmail_get_test.go +++ b/internal/cmd/execute_gmail_get_test.go @@ -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", diff --git a/internal/cmd/execute_gmail_labels_text_test.go b/internal/cmd/execute_gmail_labels_text_test.go index 98cc53d..95d8a4c 100644 --- a/internal/cmd/execute_gmail_labels_text_test.go +++ b/internal/cmd/execute_gmail_labels_text_test.go @@ -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) } }) diff --git a/internal/cmd/execute_gmail_more_commands_test.go b/internal/cmd/execute_gmail_more_commands_test.go index 7d97556..0ab5aa9 100644 --- a/internal/cmd/execute_gmail_more_commands_test.go +++ b/internal/cmd/execute_gmail_more_commands_test.go @@ -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) } }) diff --git a/internal/cmd/execute_gmail_send_reply_test.go b/internal/cmd/execute_gmail_send_reply_test.go index 1247934..816d5fd 100644 --- a/internal/cmd/execute_gmail_send_reply_test.go +++ b/internal/cmd/execute_gmail_send_reply_test.go @@ -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", diff --git a/internal/cmd/execute_gmail_test.go b/internal/cmd/execute_gmail_test.go index 094ec5e..9d30888 100644 --- a/internal/cmd/execute_gmail_test.go +++ b/internal/cmd/execute_gmail_test.go @@ -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) } }) diff --git a/internal/cmd/execute_gmail_text_test.go b/internal/cmd/execute_gmail_text_test.go index e8bf233..8fa3eb1 100644 --- a/internal/cmd/execute_gmail_text_test.go +++ b/internal/cmd/execute_gmail_text_test.go @@ -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) } }) diff --git a/internal/cmd/execute_more_text_coverage_test.go b/internal/cmd/execute_more_text_coverage_test.go index 0b28a57..8bb91e0 100644 --- a/internal/cmd/execute_more_text_coverage_test.go +++ b/internal/cmd/execute_more_text_coverage_test.go @@ -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) } }) diff --git a/internal/cmd/execute_people_me_test.go b/internal/cmd/execute_people_me_test.go index 0e241fe..38461cc 100644 --- a/internal/cmd/execute_people_me_test.go +++ b/internal/cmd/execute_people_me_test.go @@ -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) } }) diff --git a/internal/cmd/execute_tasks_test.go b/internal/cmd/execute_tasks_test.go index 55fc63c..99a25c8 100644 --- a/internal/cmd/execute_tasks_test.go +++ b/internal/cmd/execute_tasks_test.go @@ -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) } }) diff --git a/internal/cmd/execute_version_exitcodes_test.go b/internal/cmd/execute_version_exitcodes_test.go new file mode 100644 index 0000000..0e9d8b9 --- /dev/null +++ b/internal/cmd/execute_version_exitcodes_test.go @@ -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) + } +} diff --git a/internal/cmd/exit.go b/internal/cmd/exit.go new file mode 100644 index 0000000..b22d7cc --- /dev/null +++ b/internal/cmd/exit.go @@ -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 +} diff --git a/internal/cmd/gmail.go b/internal/cmd/gmail.go index 64fd5bc..cb959cc 100644 --- a/internal/cmd/gmail.go +++ b/internal/cmd/gmail.go @@ -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) diff --git a/internal/cmd/gmail_attachment.go b/internal/cmd/gmail_attachment.go index 82865f4..a8802b7 100644 --- a/internal/cmd/gmail_attachment.go +++ b/internal/cmd/gmail_attachment.go @@ -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) diff --git a/internal/cmd/gmail_drafts.go b/internal/cmd/gmail_drafts.go index df27743..4df5fac 100644 --- a/internal/cmd/gmail_drafts.go +++ b/internal/cmd/gmail_drafts.go @@ -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) diff --git a/internal/cmd/gmail_get.go b/internal/cmd/gmail_get.go index a197244..dd87891 100644 --- a/internal/cmd/gmail_get.go +++ b/internal/cmd/gmail_get.go @@ -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) diff --git a/internal/cmd/gmail_history.go b/internal/cmd/gmail_history.go index ef9ea1e..f5775b1 100644 --- a/internal/cmd/gmail_history.go +++ b/internal/cmd/gmail_history.go @@ -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 { diff --git a/internal/cmd/gmail_labels.go b/internal/cmd/gmail_labels.go index c474a23..fec8f76 100644 --- a/internal/cmd/gmail_labels.go +++ b/internal/cmd/gmail_labels.go @@ -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) diff --git a/internal/cmd/gmail_labels_cmd_test.go b/internal/cmd/gmail_labels_cmd_test.go index e334821..47b32c9 100644 --- a/internal/cmd/gmail_labels_cmd_test.go +++ b/internal/cmd/gmail_labels_cmd_test.go @@ -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) diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go index 611e301..4872907 100644 --- a/internal/cmd/gmail_send.go +++ b/internal/cmd/gmail_send.go @@ -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) diff --git a/internal/cmd/gmail_watch_cmds.go b/internal/cmd/gmail_watch_cmds.go index 68d0f7f..0f720cb 100644 --- a/internal/cmd/gmail_watch_cmds.go +++ b/internal/cmd/gmail_watch_cmds.go @@ -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{ diff --git a/internal/cmd/gmail_watch_test.go b/internal/cmd/gmail_watch_test.go index 5406ed4..1f10101 100644 --- a/internal/cmd/gmail_watch_test.go +++ b/internal/cmd/gmail_watch_test.go @@ -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) diff --git a/internal/cmd/gmail_watch_types.go b/internal/cmd/gmail_watch_types.go index 662d605..fb286dc 100644 --- a/internal/cmd/gmail_watch_types.go +++ b/internal/cmd/gmail_watch_types.go @@ -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 { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index d69b816..61e78ef 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -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 - gog gmail get --format metadata - gog gmail attachment --out ./attachment.bin - gog gmail labels get INBOX --output=json + # Gmail + gog gmail search 'newer_than:7d' --max 10 + gog gmail thread + gog gmail get --format metadata + gog gmail attachment --out ./attachment.bin + gog gmail labels get INBOX --json - # Calendar - gog calendar calendars - gog calendar events --from 2025-01-01T00:00:00Z --to 2025-01-08T00:00:00Z --max 50 - gog calendar respond --status accepted + # Calendar + gog calendar calendars + gog calendar events --from 2025-01-01T00:00:00Z --to 2025-01-08T00:00:00Z --max 50 + gog calendar respond --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 --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 + } +} diff --git a/internal/cmd/tasks_items.go b/internal/cmd/tasks_items.go index dfc74b0..227a53a 100644 --- a/internal/cmd/tasks_items.go +++ b/internal/cmd/tasks_items.go @@ -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) diff --git a/internal/cmd/tasks_lists.go b/internal/cmd/tasks_lists.go index 8bf0326..c756f56 100644 --- a/internal/cmd/tasks_lists.go +++ b/internal/cmd/tasks_lists.go @@ -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) diff --git a/internal/cmd/testutil_test.go b/internal/cmd/testutil_test.go index 626870d..0d1c999 100644 --- a/internal/cmd/testutil_test.go +++ b/internal/cmd/testutil_test.go @@ -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 +} diff --git a/internal/cmd/usage.go b/internal/cmd/usage.go new file mode 100644 index 0000000..66b3509 --- /dev/null +++ b/internal/cmd/usage.go @@ -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...)} +} diff --git a/internal/cmd/version.go b/internal/cmd/version.go new file mode 100644 index 0000000..3ace759 --- /dev/null +++ b/internal/cmd/version.go @@ -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 + }, + } +} diff --git a/internal/outfmt/outfmt.go b/internal/outfmt/outfmt.go index 1c74208..5a818c8 100644 --- a/internal/outfmt/outfmt.go +++ b/internal/outfmt/outfmt.go @@ -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 + } +} diff --git a/internal/outfmt/outfmt_test.go b/internal/outfmt/outfmt_test.go index 50f6553..4a07ccb 100644 --- a/internal/outfmt/outfmt_test.go +++ b/internal/outfmt/outfmt_test.go @@ -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") } } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 026ec0a..939c482 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -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())) diff --git a/scripts/gog.mjs b/scripts/gog.mjs index d939adc..893c901 100644 --- a/scripts/gog.mjs +++ b/scripts/gog.mjs @@ -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;