diff --git a/cmd/help_manifest.go b/cmd/help_manifest.go index 703b06a..1733aaa 100644 --- a/cmd/help_manifest.go +++ b/cmd/help_manifest.go @@ -297,6 +297,7 @@ var commandManifestRegistry = map[string]jsonCommandManifestMetadata{ Args: []jsonCommandArg{commandArg("url", true, false, "url", "Shared link URL")}, Examples: []jsonCommandExample{{Description: "Set a shared link audience", Command: "dbxcli share-link update https://www.dropbox.com/s/example/file.txt --audience team"}}, Flags: mergeCommandFlagMetadata(mergeCommandFlagMetadata(sharedLinkSettingsFlagMetadata, sharedLinkPasswordFlagMetadata), map[string]jsonCommandFlagMetadata{ + dryRunFlagName: {ValueKind: "boolean"}, "password": {Conflicts: []string{"password-file", "password-prompt", "remove-password"}, Sensitive: true, ValueKind: "secret"}, "password-file": {Conflicts: []string{"password", "password-prompt", "remove-password"}, ValueKind: "local_file"}, "password-prompt": {Conflicts: []string{"password", "password-file", "remove-password"}, MayPrompt: true, ValueKind: "boolean"}, @@ -364,7 +365,7 @@ var commandContractRegistry = map[string]jsonCommandContractMetadata{ "share-link info": {Statuses: []string{"found"}, Kinds: []string{"file", "folder", "link"}}, "share-link list": {Statuses: []string{"listed"}, Kinds: []string{"file", "folder", "link"}}, "share-link revoke": {Statuses: []string{"revoked", jsonStatusPlanned}, Kinds: []string{"file", "folder", "link", "shared_link"}}, - "share-link update": {Statuses: []string{"updated"}, Kinds: []string{"file", "folder", "link"}}, + "share-link update": {Statuses: []string{"updated", jsonStatusPlanned}, Kinds: []string{"file", "folder", "link"}}, "team add-member": {Statuses: []string{"added", "completed", "started"}, Kinds: []string{"team_member"}}, "team info": {Statuses: []string{"found"}, Kinds: []string{"team"}}, "team list-groups": {Statuses: []string{"listed"}, Kinds: []string{"team_group"}}, diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index e6fed24..c270a71 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -775,8 +775,8 @@ func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput { "share-link revoke": newJSONOperationOutput(shareLinkRevokeInput{Path: "/Reports/old.pdf", DryRun: false}, []jsonOperationResult{ newJSONOperationResult(shareLinkJSONStatusRevoked, sharedLink.Type, shareLinkRevokeResultInput{DryRun: false}, shareLinkRevokeResult{URL: sharedLink.URL, Link: &sharedLink}), }, nil), - "share-link update": newJSONOperationOutput(shareLinkUpdateInput{URL: sharedLink.URL, Audience: "public", Expires: "2026-07-01T00:00:00Z", RemoveExpiration: false, AllowDownload: true, DisallowDownload: false, Password: true, RemovePassword: false}, []jsonOperationResult{ - shareLinkJSONOperationResult(shareLinkJSONStatusUpdated, sharedLink), + "share-link update": newJSONOperationOutput(shareLinkUpdateInput{URL: sharedLink.URL, Audience: "public", Expires: "2026-07-01T00:00:00Z", RemoveExpiration: false, AllowDownload: true, DisallowDownload: false, Password: true, RemovePassword: false, DryRun: false}, []jsonOperationResult{ + shareLinkUpdateOperationResult(shareLinkJSONStatusUpdated, sharedLink, shareLinkUpdateOptions{dryRun: false}), }, nil), "team add-member": newJSONOperationOutput(teamMemberAddInput{Email: "ada@example.com", FirstName: "Ada", LastName: "Lovelace"}, []jsonOperationResult{ newJSONOperationResult(teamJSONStatusAdded, teamJSONKindTeamMember, teamMemberAddInput{Email: "ada@example.com", FirstName: "Ada", LastName: "Lovelace"}, teamMemberMutationJSON{ @@ -1073,6 +1073,7 @@ func jsonContractDefinitions() map[string][]string { "share_link_revoke_result_input": jsonFieldNames[shareLinkRevokeResultInput](), "share_link_revoke_result": jsonFieldNames[shareLinkRevokeResult](), "share_link_update_input": jsonFieldNames[shareLinkUpdateInput](), + "share_link_update_result_input": jsonFieldNames[shareLinkUpdateResultInput](), "team_group": jsonFieldNames[teamGroupJSON](), "team_info": jsonFieldNames[teamInfoJSON](), "team_member": jsonFieldNames[teamMemberJSON](), @@ -1114,7 +1115,7 @@ func jsonCommandSchemas() map[string]jsonGoldenCommandSchema { "share-link info": operationSchema("share_link_info_input", schemaRef("empty"), "share_link_metadata", []string{shareLinkJSONStatusFound}, shareLinkKinds(), nil), "share-link list": operationSchema("share_link_list_input", schemaRef("empty"), "share_link_metadata", []string{shareLinkJSONStatusListed}, shareLinkKinds(), nil), "share-link revoke": operationSchema("share_link_revoke_input", schemaRef("share_link_revoke_result_input"), "share_link_revoke_result", []string{shareLinkJSONStatusRevoked, jsonStatusPlanned}, append(shareLinkKinds(), shareLinkJSONKindSharedLink), nil), - "share-link update": operationSchema("share_link_update_input", schemaRef("empty"), "share_link_metadata", []string{shareLinkJSONStatusUpdated}, shareLinkKinds(), nil), + "share-link update": operationSchema("share_link_update_input", schemaRef("share_link_update_result_input"), "share_link_metadata", []string{shareLinkJSONStatusUpdated, jsonStatusPlanned}, shareLinkKinds(), nil), "team add-member": operationSchema("team_member_add_input", schemaRef("team_member_add_input"), "team_member_mutation", []string{teamJSONStatusAdded, teamJSONStatusCompleted, teamJSONStatusStarted}, []string{teamJSONKindTeamMember}, nil), "team info": operationSchema("empty", schemaRef("empty"), "team_info", []string{teamJSONStatusFound}, []string{teamJSONKindTeam}, nil), "team list-groups": operationSchema("empty", schemaRef("empty"), "team_group", []string{teamJSONStatusListed}, []string{teamJSONKindTeamGroup}, nil), diff --git a/cmd/share_link_json.go b/cmd/share_link_json.go index 4bff948..bc1032a 100644 --- a/cmd/share_link_json.go +++ b/cmd/share_link_json.go @@ -48,6 +48,7 @@ const ( shareLinkJSONStatusRevoked = "revoked" shareLinkJSONStatusUpdated = "updated" + shareLinkJSONKindLink = "link" shareLinkJSONKindSharedLink = "shared_link" ) diff --git a/cmd/share_link_json_test.go b/cmd/share_link_json_test.go index a1088ee..147bf19 100644 --- a/cmd/share_link_json_test.go +++ b/cmd/share_link_json_test.go @@ -274,6 +274,63 @@ func TestShareLinkUpdateJSONOutputsUpdatedMetadata(t *testing.T) { } } +func TestShareLinkUpdateJSONDryRunOutputsPlannedUpdate(t *testing.T) { + stubSharedLinkClient(t, &mockSharedLinkClient{ + modifySharedLinkSettingsFn: func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) { + t.Fatalf("ModifySharedLinkSettings called during dry-run: %v", arg) + return nil, nil + }, + modifySharedLinkSettingsRawFn: func(url string, settings *rawSharedLinkSettings, removeExpiration bool) error { + t.Fatalf("ModifySharedLinkSettingsRaw called during dry-run: %s %#v removeExpiration=%t", url, settings, removeExpiration) + return nil + }, + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + t.Fatalf("GetSharedLinkMetadata called during dry-run: %v", arg) + return nil, nil + }, + }) + + var stdout bytes.Buffer + cmd := newShareLinkUpdateTestCommand(&stdout, nil) + setShareLinkOutputJSON(t, cmd) + if err := cmd.Flags().Set("audience", "team"); err != nil { + t.Fatalf("set audience: %v", err) + } + if err := cmd.Flags().Set(dryRunFlagName, "true"); err != nil { + t.Fatal(err) + } + + if err := shareLinkUpdate(cmd, []string{"https://example.com/report"}); err != nil { + t.Fatalf("shareLinkUpdate error: %v", err) + } + + var got struct { + Input shareLinkUpdateInput `json:"input"` + Results []struct { + Status string `json:"status"` + Kind string `json:"kind"` + Input shareLinkUpdateResultInput `json:"input"` + Result shareLinkJSONMetadata `json:"result"` + } `json:"results"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if got.Input.URL != "https://example.com/report" || got.Input.Audience != "team" || !got.Input.DryRun { + t.Fatalf("input = %+v, want team dry-run update", got.Input) + } + if len(got.Results) != 1 { + t.Fatalf("results len = %d, want 1", len(got.Results)) + } + result := got.Results[0] + if result.Status != jsonStatusPlanned || result.Kind != shareLinkJSONKindLink || !result.Input.DryRun { + t.Fatalf("result = %+v, want planned link update", result) + } + if result.Result.Type != shareLinkJSONKindLink || result.Result.URL != "https://example.com/report" { + t.Fatalf("result metadata = %+v, want planned link URL", result.Result) + } +} + func TestShareLinkRevokeJSONOutputsRevokedURL(t *testing.T) { stubSharedLinkClient(t, &mockSharedLinkClient{ revokeSharedLinkFn: func(arg *sharing.RevokeSharedLinkArg) error { diff --git a/cmd/share_link_update.go b/cmd/share_link_update.go index 5b6e40c..7bf842c 100644 --- a/cmd/share_link_update.go +++ b/cmd/share_link_update.go @@ -16,6 +16,7 @@ package cmd import ( "errors" + "io" "time" "github.com/dropbox/dbxcli/v3/internal/output" @@ -32,6 +33,7 @@ type shareLinkUpdateOptions struct { audience *sharing.LinkAudience password sharedLinkPasswordOptions removePassword bool + dryRun bool } type shareLinkUpdateInput struct { @@ -43,6 +45,11 @@ type shareLinkUpdateInput struct { DisallowDownload bool `json:"disallow_download,omitempty"` Password bool `json:"password,omitempty"` RemovePassword bool `json:"remove_password,omitempty"` + DryRun bool `json:"dry_run,omitempty"` +} + +type shareLinkUpdateResultInput struct { + DryRun bool `json:"dry_run,omitempty"` } func shareLinkUpdate(cmd *cobra.Command, args []string) error { @@ -60,6 +67,10 @@ func shareLinkUpdate(cmd *cobra.Command, args []string) error { return err } + if opts.dryRun { + return renderShareLinkUpdateDryRunOutput(cmd, url, opts) + } + dbx := newSharedLinkClient(config) if opts.usesRawSettings() { if err := dbx.ModifySharedLinkSettingsRawContext(currentContext(), url, rawSharedLinkSettingsFromUpdateOptions(opts), opts.removeExpiration); err != nil { @@ -125,7 +136,7 @@ func renderShareLinkUpdateOutput(cmd *cobra.Command, dbx sharedLinkClient, url s return renderJSONOperationOutput( cmd, newShareLinkUpdateInput(url, opts), - []jsonOperationResult{shareLinkJSONOperationResult(shareLinkJSONStatusUpdated, result)}, + []jsonOperationResult{shareLinkUpdateOperationResult(shareLinkJSONStatusUpdated, result, opts)}, ) } @@ -137,6 +148,7 @@ func newShareLinkUpdateInput(url string, opts shareLinkUpdateOptions) shareLinkU DisallowDownload: opts.disallowDownload, Password: opts.password.set, RemovePassword: opts.removePassword, + DryRun: opts.dryRun, } if opts.audience != nil { input.Audience = opts.audience.Tag @@ -170,6 +182,13 @@ func parseShareLinkUpdateOptions(cmd *cobra.Command) (shareLinkUpdateOptions, er if err != nil { return shareLinkUpdateOptions{}, err } + var dryRun bool + if cmd.Flags().Lookup(dryRunFlagName) != nil { + dryRun, err = dryRunEnabled(cmd) + if err != nil { + return shareLinkUpdateOptions{}, err + } + } if expiresChanged && removeExpiration { return shareLinkUpdateOptions{}, invalidArgumentsErrorWithDetails("`--expires` and `--remove-expiration` cannot be used together", flagsErrorDetails("expires", "remove-expiration")) @@ -214,6 +233,7 @@ func parseShareLinkUpdateOptions(cmd *cobra.Command) (shareLinkUpdateOptions, er audience: audience, password: password, removePassword: removePassword, + dryRun: dryRun, }, nil } @@ -246,6 +266,29 @@ func rawSharedLinkSettingsFromUpdateOptions(opts shareLinkUpdateOptions) *rawSha return settings } +func shareLinkUpdateOperationResult(status string, metadata shareLinkJSONMetadata, opts shareLinkUpdateOptions) jsonOperationResult { + return newJSONOperationResult(plannedStatus(opts.dryRun, status), metadata.Type, shareLinkUpdateResultInput{DryRun: opts.dryRun}, metadata) +} + +func plannedShareLinkUpdateMetadata(url string) shareLinkJSONMetadata { + return shareLinkJSONMetadata{ + Type: shareLinkJSONKindLink, + URL: url, + } +} + +func renderShareLinkUpdateDryRunOutput(cmd *cobra.Command, url string, opts shareLinkUpdateOptions) error { + result := plannedShareLinkUpdateMetadata(url) + return commandOutput(cmd).Render(func(w io.Writer) error { + return writeDryRunLine(w, "update shared link", url) + }, newJSONCommandOperationOutput( + cmd, + newShareLinkUpdateInput(url, opts), + []jsonOperationResult{shareLinkUpdateOperationResult(shareLinkJSONStatusUpdated, result, opts)}, + nil, + )) +} + var shareLinkUpdateCmd = &cobra.Command{ Use: "update ", Short: "Update shared link settings", @@ -267,6 +310,7 @@ func init() { shareLinkUpdateCmd.Flags().Bool("disallow-download", false, "Disallow downloads from the shared link") addSharedLinkPasswordFlags(shareLinkUpdateCmd) shareLinkUpdateCmd.Flags().Bool("remove-password", false, "Remove the shared link password") + addDryRunFlag(shareLinkUpdateCmd) shareLinkCmd.AddCommand(shareLinkUpdateCmd) enableStructuredOutput(shareLinkUpdateCmd) } diff --git a/cmd/share_link_update_test.go b/cmd/share_link_update_test.go index 63ce936..422d4b1 100644 --- a/cmd/share_link_update_test.go +++ b/cmd/share_link_update_test.go @@ -289,6 +289,41 @@ func TestShareLinkUpdateDisallowsDownload(t *testing.T) { } } +func TestShareLinkUpdateDryRunDoesNotCallAPI(t *testing.T) { + stubSharedLinkClient(t, &mockSharedLinkClient{ + modifySharedLinkSettingsFn: func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) { + t.Fatalf("ModifySharedLinkSettings called during dry-run: %v", arg) + return nil, nil + }, + modifySharedLinkSettingsRawFn: func(url string, settings *rawSharedLinkSettings, removeExpiration bool) error { + t.Fatalf("ModifySharedLinkSettingsRaw called during dry-run: %s %#v removeExpiration=%t", url, settings, removeExpiration) + return nil + }, + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + t.Fatalf("GetSharedLinkMetadata called during dry-run: %v", arg) + return nil, nil + }, + }) + + var stdout bytes.Buffer + cmd := newShareLinkUpdateTestCommand(&stdout, nil) + if err := cmd.Flags().Set("disallow-download", "true"); err != nil { + t.Fatalf("set disallow-download: %v", err) + } + if err := cmd.Flags().Set(dryRunFlagName, "true"); err != nil { + t.Fatal(err) + } + + if err := shareLinkUpdate(cmd, []string{"https://example.com/link"}); err != nil { + t.Fatalf("shareLinkUpdate error: %v", err) + } + + const want = "Would update shared link https://example.com/link\n" + if got := stdout.String(); got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } +} + func TestShareLinkUpdateDisallowDownloadCombinesSettingsInOneRawCall(t *testing.T) { expires := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) var rawCalls int @@ -651,6 +686,9 @@ func TestShareLinkUpdateCommandIsRegistered(t *testing.T) { if shareLinkUpdateCmd.Flags().Lookup("disallow-download") == nil { t.Fatal("share-link update should define --disallow-download") } + if shareLinkUpdateCmd.Flags().Lookup(dryRunFlagName) == nil { + t.Fatalf("share-link update should define --%s", dryRunFlagName) + } } func newShareLinkUpdateTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command { @@ -662,6 +700,7 @@ func newShareLinkUpdateTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command cmd.Flags().Bool("disallow-download", false, "") addSharedLinkPasswordFlags(cmd) cmd.Flags().Bool("remove-password", false, "") + addDryRunFlag(cmd) cmd.Flags().Bool("verbose", false, "") if stdout != nil { cmd.SetOut(stdout) diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index 221ff86..4c9b285 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -338,12 +338,16 @@ "allow_download", "audience", "disallow_download", + "dry_run", "expires", "password", "remove_expiration", "remove_password", "url" ], + "share_link_update_result_input": [ + "dry_run" + ], "team_group": [ "group_external_id", "group_id", @@ -755,9 +759,10 @@ "top_level": "operation_output", "result_wrapper": "operation_result", "input": "share_link_update_input", - "result_input": "empty", + "result_input": "share_link_update_result_input", "result": "share_link_metadata", "statuses": [ + "planned", "updated" ], "kinds": [ diff --git a/docs/commands/dbxcli_share-link_update.md b/docs/commands/dbxcli_share-link_update.md index 3c922dd..13f4aa6 100644 --- a/docs/commands/dbxcli_share-link_update.md +++ b/docs/commands/dbxcli_share-link_update.md @@ -29,6 +29,7 @@ dbxcli share-link update [flags] --allow-download Allow downloads from the shared link --audience string Set shared link audience: public, team, members, or no-one --disallow-download Disallow downloads from the shared link + --dry-run Preview intended writes without making changes --expires string Set shared link expiration time as an RFC3339 timestamp -h, --help help for update --password string Password for password-protected shared links @@ -56,7 +57,7 @@ dbxcli share-link update [flags] * Dropbox scopes: `sharing.read`, `sharing.write` * Arguments: `url` (required, url) * Flag metadata: `--allow-download` (conflicts: `disallow-download`), `--audience` (values: `members`, `no-one`, `public`, `team`), `--disallow-download` (conflicts: `allow-download`), `--expires` (conflicts: `remove-expiration`), `--output` (values: `json`, `text`), `--password` (conflicts: `password-file`, `password-prompt`, `remove-password`; sensitive), `--password-file` (conflicts: `password`, `password-prompt`, `remove-password`), `--password-prompt` (conflicts: `password`, `password-file`, `remove-password`; may prompt), `--remove-expiration` (conflicts: `expires`), `--remove-password` (conflicts: `password`, `password-file`, `password-prompt`) -* Result statuses: `updated` +* Result statuses: `planned`, `updated` * Result kinds: `file`, `folder`, `link` * JSON contract: `docs/json-schema/v1/commands.json#/commands/share-link update` * JSON success schema: `docs/json-schema/v1/commands.schema.json#/$defs/command_share_2dlink_20update` diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index 221ff86..4c9b285 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -338,12 +338,16 @@ "allow_download", "audience", "disallow_download", + "dry_run", "expires", "password", "remove_expiration", "remove_password", "url" ], + "share_link_update_result_input": [ + "dry_run" + ], "team_group": [ "group_external_id", "group_id", @@ -755,9 +759,10 @@ "top_level": "operation_output", "result_wrapper": "operation_result", "input": "share_link_update_input", - "result_input": "empty", + "result_input": "share_link_update_result_input", "result": "share_link_metadata", "statuses": [ + "planned", "updated" ], "kinds": [ diff --git a/docs/json-schema/v1/commands.schema.json b/docs/json-schema/v1/commands.schema.json index 639e30c..3dff279 100644 --- a/docs/json-schema/v1/commands.schema.json +++ b/docs/json-schema/v1/commands.schema.json @@ -2571,7 +2571,7 @@ "additionalProperties": false, "properties": { "input": { - "$ref": "#/$defs/empty" + "$ref": "#/$defs/share_link_update_result_input" }, "kind": { "enum": [ @@ -2585,6 +2585,7 @@ }, "status": { "enum": [ + "planned", "updated" ] } @@ -3197,6 +3198,9 @@ "disallow_download": { "type": "boolean" }, + "dry_run": { + "type": "boolean" + }, "expires": { "format": "date-time", "type": "string" @@ -3219,6 +3223,15 @@ ], "type": "object" }, + "share_link_update_result_input": { + "additionalProperties": false, + "properties": { + "dry_run": { + "type": "boolean" + } + }, + "type": "object" + }, "team_group": { "additionalProperties": false, "properties": {