diff --git a/README.md b/README.md index 403f1fa..c920d7d 100644 --- a/README.md +++ b/README.md @@ -126,11 +126,24 @@ Available Commands: Flags: --as-member string Member ID to perform action as + --output string Output format: text, json (default "text") -v, --verbose Enable verbose logging Use "dbxcli [command] --help" for more information about a command. ``` +### Output formats + +Text output is the default. JSON output is available through the global `--output` flag as commands are migrated: + +```sh +$ dbxcli --output=json +``` + +JSON support is rolling out command by command. Commands that have not been migrated return `structured output is not supported for this command yet` when used with `--output=json`. + +Command results are written to stdout. Status, progress, warnings, diagnostics, errors, and verbose logs are written to stderr. JSON errors are not wrapped in a JSON response object. + ### Authentication By default, `dbxcli` stores OAuth credentials in `~/.config/dbxcli/auth.json`. @@ -314,6 +327,7 @@ Available Commands: Global Flags: --as-member string Member ID to perform action as + --output string Output format: text, json (default "text") -v, --verbose Enable verbose logging Use "dbxcli team [command] --help" for more information about a command. diff --git a/cmd/json_metadata.go b/cmd/json_metadata.go new file mode 100644 index 0000000..2915662 --- /dev/null +++ b/cmd/json_metadata.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "time" + + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" +) + +type jsonMetadata struct { + Type string `json:"type"` + PathDisplay string `json:"path_display,omitempty"` + PathLower string `json:"path_lower,omitempty"` + ID string `json:"id,omitempty"` + Rev string `json:"rev,omitempty"` + Size *uint64 `json:"size,omitempty"` + ServerModified *string `json:"server_modified,omitempty"` + ClientModified *string `json:"client_modified,omitempty"` + Deleted bool `json:"deleted,omitempty"` +} + +func jsonMetadataFromDropbox(metadata files.IsMetadata) jsonMetadata { + switch m := metadata.(type) { + case *files.FileMetadata: + size := m.Size + return jsonMetadata{ + Type: "file", + PathDisplay: m.PathDisplay, + PathLower: m.PathLower, + ID: m.Id, + Rev: m.Rev, + Size: &size, + ServerModified: jsonTime(m.ServerModified), + ClientModified: jsonTime(m.ClientModified), + } + case *files.FolderMetadata: + return jsonMetadata{ + Type: "folder", + PathDisplay: m.PathDisplay, + PathLower: m.PathLower, + ID: m.Id, + } + case *files.DeletedMetadata: + return jsonMetadata{ + Type: "deleted", + PathDisplay: m.PathDisplay, + PathLower: m.PathLower, + Deleted: true, + } + default: + return jsonMetadata{Type: "unknown"} + } +} + +func jsonTime(t time.Time) *string { + if t.IsZero() { + return nil + } + value := t.UTC().Format(time.RFC3339) + return &value +} diff --git a/cmd/json_metadata_test.go b/cmd/json_metadata_test.go new file mode 100644 index 0000000..6651642 --- /dev/null +++ b/cmd/json_metadata_test.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" +) + +func TestJSONMetadataFromDropboxFile(t *testing.T) { + clientModified := time.Date(2026, 6, 21, 10, 30, 0, 0, time.FixedZone("test", -7*60*60)) + serverModified := time.Date(2026, 6, 22, 11, 45, 0, 0, time.UTC) + metadata := &files.FileMetadata{ + Metadata: files.Metadata{ + PathDisplay: "/Reports/File.txt", + PathLower: "/reports/file.txt", + }, + Id: "id:abc", + Rev: "rev123", + Size: 0, + ClientModified: clientModified, + ServerModified: serverModified, + } + + got := jsonMetadataFromDropbox(metadata) + + if got.Type != "file" { + t.Fatalf("Type = %q, want file", got.Type) + } + if got.PathDisplay != "/Reports/File.txt" { + t.Fatalf("PathDisplay = %q", got.PathDisplay) + } + if got.Size == nil || *got.Size != 0 { + t.Fatalf("Size = %v, want pointer to 0", got.Size) + } + if got.ClientModified == nil || *got.ClientModified != "2026-06-21T17:30:00Z" { + t.Fatalf("ClientModified = %v", got.ClientModified) + } + if got.ServerModified == nil || *got.ServerModified != "2026-06-22T11:45:00Z" { + t.Fatalf("ServerModified = %v", got.ServerModified) + } + + encoded, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "is_downloadable") { + t.Fatalf("encoded metadata exposes SDK-specific fields: %s", encoded) + } + if strings.Contains(string(encoded), "deleted") { + t.Fatalf("encoded non-deleted metadata should omit deleted field: %s", encoded) + } +} + +func TestJSONMetadataFromDropboxFolder(t *testing.T) { + metadata := &files.FolderMetadata{ + Metadata: files.Metadata{ + PathDisplay: "/Reports", + PathLower: "/reports", + }, + Id: "id:folder", + } + + got := jsonMetadataFromDropbox(metadata) + + if got.Type != "folder" { + t.Fatalf("Type = %q, want folder", got.Type) + } + if got.ID != "id:folder" { + t.Fatalf("ID = %q, want id:folder", got.ID) + } + if got.Size != nil { + t.Fatalf("Size = %v, want nil for folder", got.Size) + } +} + +func TestJSONMetadataFromDropboxDeleted(t *testing.T) { + metadata := &files.DeletedMetadata{ + Metadata: files.Metadata{ + PathDisplay: "/Reports/Old.txt", + PathLower: "/reports/old.txt", + }, + } + + got := jsonMetadataFromDropbox(metadata) + + if got.Type != "deleted" { + t.Fatalf("Type = %q, want deleted", got.Type) + } + if !got.Deleted { + t.Fatal("Deleted = false, want true") + } +} diff --git a/cmd/login.go b/cmd/login.go index 99aa6a8..62b1025 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -78,7 +78,7 @@ By default, login stores credentials for regular Dropbox user commands. Use "team-access" for --as-member commands or "team-manage" for team commands.`, Args: cobra.MaximumNArgs(1), PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - return nil + return validateOutputFormat(cmd) }, RunE: login, } diff --git a/cmd/logout.go b/cmd/logout.go index 7d6870a..3ed0471 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -70,7 +70,7 @@ var logoutCmd = &cobra.Command{ Use: "logout [flags]", Short: "Log out of the current session", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - return nil + return validateOutputFormat(cmd) }, RunE: logout, } diff --git a/cmd/output.go b/cmd/output.go index 1fd1128..3eb586c 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -1,11 +1,16 @@ package cmd import ( + "fmt" + "github.com/dropbox/dbxcli/internal/output" "github.com/spf13/cobra" ) -const jsonOutputFlag = "json" +const ( + outputFlag = "output" + structuredOutputSupportedAnnotation = "dbxcli.supportsStructuredOutput" +) func commandOutput(cmd *cobra.Command) *output.Renderer { if cmd == nil { @@ -16,17 +21,64 @@ func commandOutput(cmd *cobra.Command) *output.Renderer { } func commandOutputFormat(cmd *cobra.Command) output.Format { - jsonOutput, err := cmd.Flags().GetBool(jsonOutputFlag) + format, err := commandOutputFormatE(cmd) if err != nil { - jsonOutput, err = cmd.InheritedFlags().GetBool(jsonOutputFlag) + return output.FormatText + } + return format +} + +func commandOutputFormatE(cmd *cobra.Command) (output.Format, error) { + value := string(output.FormatText) + if cmd != nil { + value = commandOutputFlagValue(cmd) + } + return parseOutputFormat(value) +} + +func commandOutputFlagValue(cmd *cobra.Command) string { + value, err := cmd.Flags().GetString(outputFlag) + if err == nil { + return value + } + value, err = cmd.InheritedFlags().GetString(outputFlag) + if err == nil { + return value } + return string(output.FormatText) +} + +func parseOutputFormat(value string) (output.Format, error) { + switch output.Format(value) { + case output.FormatText: + return output.FormatText, nil + case output.FormatJSON: + return output.FormatJSON, nil + default: + return "", fmt.Errorf("unsupported output format %q: use text or json", value) + } +} + +func validateOutputFormat(cmd *cobra.Command) error { + format, err := commandOutputFormatE(cmd) if err != nil { - jsonOutput, err = cmd.PersistentFlags().GetBool(jsonOutputFlag) + return err } - if err == nil && jsonOutput { - return output.FormatJSON + if format == output.FormatJSON && !commandSupportsStructuredOutput(cmd) { + return output.ErrStructuredOutputUnsupported + } + return nil +} + +func commandSupportsStructuredOutput(cmd *cobra.Command) bool { + return cmd != nil && cmd.Annotations[structuredOutputSupportedAnnotation] == "true" +} + +func enableStructuredOutput(cmd *cobra.Command) { + if cmd.Annotations == nil { + cmd.Annotations = make(map[string]string) } - return output.FormatText + cmd.Annotations[structuredOutputSupportedAnnotation] = "true" } func commandVerbose(cmd *cobra.Command) bool { diff --git a/cmd/output_test.go b/cmd/output_test.go index bb20cdd..a6facae 100644 --- a/cmd/output_test.go +++ b/cmd/output_test.go @@ -2,9 +2,12 @@ package cmd import ( "bytes" + "errors" "io" + "strings" "testing" + "github.com/dropbox/dbxcli/internal/output" "github.com/spf13/cobra" ) @@ -27,11 +30,11 @@ func TestCommandOutputUsesCobraWriters(t *testing.T) { } } -func TestCommandOutputHonorsJSONFlag(t *testing.T) { +func TestCommandOutputHonorsOutputJSON(t *testing.T) { var stdout bytes.Buffer cmd := &cobra.Command{} cmd.SetOut(&stdout) - cmd.Flags().Bool(jsonOutputFlag, true, "") + cmd.Flags().String(outputFlag, "json", "") out := commandOutput(cmd) err := out.Render(func(w io.Writer) error { @@ -49,10 +52,10 @@ func TestCommandOutputHonorsJSONFlag(t *testing.T) { } } -func TestCommandOutputHonorsInheritedJSONFlag(t *testing.T) { +func TestCommandOutputHonorsInheritedOutputJSON(t *testing.T) { var stdout bytes.Buffer root := &cobra.Command{} - root.PersistentFlags().Bool(jsonOutputFlag, true, "") + root.PersistentFlags().String(outputFlag, "json", "") cmd := &cobra.Command{} cmd.SetOut(&stdout) @@ -74,6 +77,76 @@ func TestCommandOutputHonorsInheritedJSONFlag(t *testing.T) { } } +func TestValidateOutputFormatRejectsInvalidValue(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String(outputFlag, "yaml", "") + + err := validateOutputFormat(cmd) + if err == nil { + t.Fatal("expected invalid output format to fail") + } + if !strings.Contains(err.Error(), `unsupported output format "yaml": use text or json`) { + t.Fatalf("error = %q, want unsupported output format", err.Error()) + } +} + +func TestValidateOutputFormatRejectsUnsupportedJSONCommand(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String(outputFlag, "json", "") + + err := validateOutputFormat(cmd) + if !errors.Is(err, output.ErrStructuredOutputUnsupported) { + t.Fatalf("error = %v, want ErrStructuredOutputUnsupported", err) + } +} + +func TestValidateOutputFormatAllowsSupportedJSONCommand(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String(outputFlag, "json", "") + enableStructuredOutput(cmd) + + if err := validateOutputFormat(cmd); err != nil { + t.Fatalf("validateOutputFormat returned error: %v", err) + } +} + +func TestValidateOutputFormatHonorsInheritedOutput(t *testing.T) { + root := &cobra.Command{} + root.PersistentFlags().String(outputFlag, "json", "") + + cmd := &cobra.Command{} + enableStructuredOutput(cmd) + root.AddCommand(cmd) + + if err := validateOutputFormat(cmd); err != nil { + t.Fatalf("validateOutputFormat returned error: %v", err) + } +} + +func TestStructuredOutputSupportDoesNotInheritFromParent(t *testing.T) { + root := &cobra.Command{} + root.PersistentFlags().String(outputFlag, "json", "") + enableStructuredOutput(root) + + cmd := &cobra.Command{} + root.AddCommand(cmd) + + err := validateOutputFormat(cmd) + if !errors.Is(err, output.ErrStructuredOutputUnsupported) { + t.Fatalf("error = %v, want ErrStructuredOutputUnsupported", err) + } +} + +func TestRootCommandDefinesOutputFlag(t *testing.T) { + flag := RootCmd.PersistentFlags().Lookup(outputFlag) + if flag == nil { + t.Fatal("root command should define --output") + } + if got, want := flag.DefValue, "text"; got != want { + t.Fatalf("--output default = %q, want %q", got, want) + } +} + func TestCommandVerboseHonorsInheritedVerboseFlag(t *testing.T) { root := &cobra.Command{} root.PersistentFlags().BoolP("verbose", "v", false, "") diff --git a/cmd/root.go b/cmd/root.go index 704b6a1..af73018 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -143,6 +143,10 @@ func makeDropboxConfig(token string, verbose bool, asMember string, domain strin } func initDbx(cmd *cobra.Command, args []string) (err error) { + if err := validateOutputFormat(cmd); err != nil { + return err + } + if commandSkipsAuth(cmd) { return nil } @@ -193,6 +197,7 @@ func loadOAuthCredentialsFromEnv() { func init() { RootCmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging") + RootCmd.PersistentFlags().String(outputFlag, "text", "Output format: text, json") RootCmd.PersistentFlags().String("as-member", "", "Member ID to perform action as") // This flag should only be used for testing. Marked hidden so it doesn't clutter usage etc. RootCmd.PersistentFlags().String("domain", "", "Override default Dropbox domain, useful for testing") diff --git a/cmd/root_test.go b/cmd/root_test.go index ac9d799..045eb3c 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" @@ -30,6 +31,7 @@ func TestRootCmdInvalidFlagReturnsError(t *testing.T) { func newAuthTestCommand() *cobra.Command { root := &cobra.Command{Use: "dbxcli"} + root.PersistentFlags().String(outputFlag, "text", "") cmd := &cobra.Command{Use: "ls"} cmd.Flags().BoolP("verbose", "v", false, "") cmd.Flags().String("as-member", "", "") @@ -84,6 +86,42 @@ func TestInitDbxSkipsAuthForLocalCommands(t *testing.T) { } } +func TestInitDbxValidatesOutputBeforeAuth(t *testing.T) { + t.Setenv(envAccessToken, "") + t.Setenv(envAuthFile, filepath.Join(t.TempDir(), "missing-auth.json")) + + cmd := newAuthTestCommand() + if err := cmd.Root().PersistentFlags().Set(outputFlag, "yaml"); err != nil { + t.Fatal(err) + } + + err := initDbx(cmd, nil) + if err == nil { + t.Fatal("expected invalid output format to fail") + } + if !strings.Contains(err.Error(), `unsupported output format "yaml"`) { + t.Fatalf("error = %q, want output format error", err.Error()) + } +} + +func TestInitDbxRejectsUnsupportedStructuredOutputBeforeAuth(t *testing.T) { + t.Setenv(envAccessToken, "") + t.Setenv(envAuthFile, filepath.Join(t.TempDir(), "missing-auth.json")) + + cmd := newAuthTestCommand() + if err := cmd.Root().PersistentFlags().Set(outputFlag, "json"); err != nil { + t.Fatal(err) + } + + err := initDbx(cmd, nil) + if err == nil { + t.Fatal("expected unsupported structured output to fail") + } + if !strings.Contains(err.Error(), "structured output is not supported") { + t.Fatalf("error = %q, want structured output error", err.Error()) + } +} + func TestInitDbxStillRequiresAuthForDropboxCommands(t *testing.T) { t.Setenv(envAccessToken, "") t.Setenv(envAuthFile, filepath.Join(t.TempDir(), "missing-auth.json")) diff --git a/cmd/team.go b/cmd/team.go index cae2520..3eb4901 100644 --- a/cmd/team.go +++ b/cmd/team.go @@ -25,6 +25,9 @@ var teamCmd = &cobra.Command{ Use: "team", Short: "Team management commands", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if err := validateOutputFormat(cmd); err != nil { + return err + } if member, _ := cmd.Flags().GetString("as-member"); member != "" { return fmt.Errorf("Flag `as-member` is invalid for team sub-commands") } diff --git a/internal/output/output.go b/internal/output/output.go index 8f7e10a..209f157 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -2,6 +2,7 @@ package output import ( "encoding/json" + "errors" "fmt" "io" "os" @@ -14,6 +15,8 @@ const ( FormatJSON Format = "json" ) +var ErrStructuredOutputUnsupported = errors.New("structured output is not supported for this command yet") + type Renderer struct { stdout io.Writer stderr io.Writer @@ -37,7 +40,7 @@ func New(stdout, stderr io.Writer, format Format) *Renderer { func (r *Renderer) RenderText(render func(io.Writer) error) error { if r.format != FormatText { - return fmt.Errorf("output format %q requires structured output data", r.format) + return ErrStructuredOutputUnsupported } return r.Render(render, nil) } diff --git a/internal/output/output_test.go b/internal/output/output_test.go index d78d32b..0633bc2 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -79,8 +79,8 @@ func TestRenderTextRequiresTextFormat(t *testing.T) { if err == nil { t.Fatal("expected error for text-only renderer in JSON format") } - if !strings.Contains(err.Error(), "requires structured output data") { - t.Fatalf("error = %q, want structured output data", err.Error()) + if !errors.Is(err, ErrStructuredOutputUnsupported) { + t.Fatalf("error = %q, want ErrStructuredOutputUnsupported", err.Error()) } }