From 1aa1e950a41597211af2407da23153f702c45fac Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Thu, 9 Jul 2026 12:52:43 -0700 Subject: [PATCH] Add --dry-run flag to share-link create Preview shared-link creation without calling the Dropbox API. Text output prints "Would create shared link "; JSON reports status "planned" with dry_run=true in the input. The dry-run branch runs after path and flag validation, so bad input and conflicting flags still error. The planned result reports a synthetic "link" kind with path_lower (known from input) and an empty url (not knowable without a call). Adds shareLinkCreateResultInput to carry dry_run in the per-result input, which was previously empty for this command. --- cmd/help_manifest.go | 4 +- cmd/json_contract_test.go | 7 +- cmd/share_create_link_test.go | 62 ++++++++++++++++++ cmd/share_link_create.go | 46 ++++++++++++- cmd/share_link_json_test.go | 65 +++++++++++++++++++ .../json_contract/success_schemas.json | 9 ++- docs/commands/dbxcli_share-link_create.md | 3 +- docs/json-schema/v1/commands.json | 9 ++- docs/json-schema/v1/commands.schema.json | 17 ++++- 9 files changed, 209 insertions(+), 13 deletions(-) diff --git a/cmd/help_manifest.go b/cmd/help_manifest.go index 1733aaa3..d7f6b264 100644 --- a/cmd/help_manifest.go +++ b/cmd/help_manifest.go @@ -255,7 +255,7 @@ var commandManifestRegistry = map[string]jsonCommandManifestMetadata{ "share-link create": { Args: []jsonCommandArg{commandArg("path", true, false, "dropbox_path", "Dropbox path to share")}, Examples: []jsonCommandExample{{Description: "Create a shared link", Command: "dbxcli share-link create /Reports/report.pdf"}}, - Flags: mergeCommandFlagMetadata(mergeCommandFlagMetadata(sharedLinkSettingsFlagMetadata, sharedLinkPasswordFlagMetadata), map[string]jsonCommandFlagMetadata{"access": {EnumValues: []string{"viewer", "editor", "max"}, ValueKind: "enum"}}), + Flags: mergeCommandFlagMetadata(mergeCommandFlagMetadata(sharedLinkSettingsFlagMetadata, sharedLinkPasswordFlagMetadata), map[string]jsonCommandFlagMetadata{dryRunFlagName: {ValueKind: "boolean"}, "access": {EnumValues: []string{"viewer", "editor", "max"}, ValueKind: "enum"}}), DropboxScopes: []string{"sharing.write", "sharing.read"}, Known: true, }, @@ -360,7 +360,7 @@ var commandContractRegistry = map[string]jsonCommandContractMetadata{ "search": {Statuses: []string{"found"}, Kinds: []string{"deleted", "file", "folder"}}, "share list folder": {Statuses: []string{"listed"}, Kinds: []string{"shared_folder"}}, "share list link": {Statuses: []string{"listed"}, Kinds: []string{"file", "folder", "link"}, Warnings: []string{jsonWarningCodeDeprecatedCommand}}, - "share-link create": {Statuses: []string{"created", "existing"}, Kinds: []string{"file", "folder", "link"}}, + "share-link create": {Statuses: []string{"created", "existing", jsonStatusPlanned}, Kinds: []string{"file", "folder", "link"}}, "share-link download": {Statuses: []string{"downloaded"}, Kinds: []string{"file", "folder", "link"}}, "share-link info": {Statuses: []string{"found"}, Kinds: []string{"file", "folder", "link"}}, "share-link list": {Statuses: []string{"listed"}, Kinds: []string{"file", "folder", "link"}}, diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index c270a710..72b4bdad 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -760,8 +760,8 @@ func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput { "share list link": newJSONOperationOutput(shareLinkListInput{Path: "/Reports/old.pdf", DirectOnly: true}, []jsonOperationResult{ shareLinkJSONOperationResult(shareLinkJSONStatusListed, sharedLink), }, []jsonWarning{{Code: jsonWarningCodeDeprecatedCommand, Message: "use `dbxcli share-link list` instead"}}), - "share-link create": newJSONOperationOutput(shareLinkCreateInput{Path: "/Reports/old.pdf", Access: "max", Audience: "public", Expires: "2026-07-01T00:00:00Z", RemoveExpiration: false, AllowDownload: true, DisallowDownload: false, Password: true}, []jsonOperationResult{ - shareLinkJSONOperationResult(shareLinkJSONStatusCreated, sharedLink), + "share-link create": newJSONOperationOutput(shareLinkCreateInput{Path: "/Reports/old.pdf", Access: "max", Audience: "public", Expires: "2026-07-01T00:00:00Z", RemoveExpiration: false, AllowDownload: true, DisallowDownload: false, Password: true, DryRun: false}, []jsonOperationResult{ + shareLinkCreateOperationResult(shareLinkJSONStatusCreated, sharedLink, shareLinkCreateOptions{dryRun: false}), }, nil), "share-link download": newJSONOperationOutput(shareLinkDownloadInput{URL: sharedLink.URL, Target: "old.pdf", Path: "/old.pdf", Recursive: false, Password: true}, []jsonOperationResult{ newJSONOperationResult(shareLinkJSONStatusDownloaded, sharedLink.Type, nil, shareLinkDownloadResult{Target: "old.pdf", Link: sharedLink}), @@ -1063,6 +1063,7 @@ func jsonContractDefinitions() map[string][]string { "search_input": jsonFieldNames[searchInput](), "share_folder": jsonFieldNames[shareFolderJSONMetadata](), "share_link_create_input": jsonFieldNames[shareLinkCreateInput](), + "share_link_create_result_input": jsonFieldNames[shareLinkCreateResultInput](), "share_link_download_input": jsonFieldNames[shareLinkDownloadInput](), "share_link_download_result": jsonFieldNames[shareLinkDownloadResult](), "share_link_info_input": jsonFieldNames[shareLinkInfoInput](), @@ -1103,7 +1104,7 @@ func jsonCommandSchemas() map[string]jsonGoldenCommandSchema { "search": operationSchema("search_input", schemaRef("empty"), "metadata", []string{searchJSONStatusFound}, metadataKinds(), nil), "share list folder": operationSchema("empty", schemaRef("empty"), "share_folder", []string{shareFolderJSONStatusListed}, []string{shareFolderJSONKindFolder}, nil), "share list link": operationSchema("share_link_list_input", schemaRef("empty"), "share_link_metadata", []string{shareLinkJSONStatusListed}, shareLinkKinds(), []string{jsonWarningCodeDeprecatedCommand}), - "share-link create": operationSchema("share_link_create_input", schemaRef("empty"), "share_link_metadata", []string{shareLinkJSONStatusCreated, shareLinkJSONStatusExisting}, shareLinkKinds(), nil), + "share-link create": operationSchema("share_link_create_input", schemaRef("share_link_create_result_input"), "share_link_metadata", []string{shareLinkJSONStatusCreated, shareLinkJSONStatusExisting, jsonStatusPlanned}, shareLinkKinds(), nil), "share-link download": operationSchema( "share_link_download_input", schemaRef("empty"), diff --git a/cmd/share_create_link_test.go b/cmd/share_create_link_test.go index eeb4f0d5..d7c9e1bc 100644 --- a/cmd/share_create_link_test.go +++ b/cmd/share_create_link_test.go @@ -212,6 +212,49 @@ func TestSharedLinkCreatePrintsURLAndUsesDefaultSettings(t *testing.T) { } } +func TestSharedLinkCreateDryRunDoesNotCallAPI(t *testing.T) { + stubSharedLinkClient(t, &mockSharedLinkClient{ + createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { + t.Fatalf("CreateSharedLinkWithSettings called during dry-run: %v", arg) + return nil, nil + }, + createSharedLinkWithRawSettingsFn: func(path string, settings *rawSharedLinkSettings) (sharing.IsSharedLinkMetadata, error) { + t.Fatalf("CreateSharedLinkWithRawSettings called during dry-run: %s %#v", path, settings) + return nil, nil + }, + listSharedLinksFn: func(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) { + t.Fatalf("ListSharedLinks called during dry-run: %v", arg) + return nil, nil + }, + 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 + }, + }) + + var stdout bytes.Buffer + cmd := newShareLinkCreateTestCommand(&stdout) + 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 := shareLinkCreate(cmd, []string{"/file.txt"}); err != nil { + t.Fatalf("shareLinkCreate error: %v", err) + } + + const want = "Would create shared link /file.txt\n" + if got := stdout.String(); got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } +} + func TestSharedLinkCreateWithExpiresSetsExpiration(t *testing.T) { wantExpires := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) mock := &mockSharedLinkClient{ @@ -1283,6 +1326,9 @@ func TestShareLinkCreateDoesNotBreakShareListLinkCommand(t *testing.T) { if shareLinkCreateCmd.Flags().Lookup("password-file") == nil { t.Fatal("share-link create should define --password-file") } + if shareLinkCreateCmd.Flags().Lookup(dryRunFlagName) == nil { + t.Fatalf("share-link create should define --%s", dryRunFlagName) + } cmd, _, err = RootCmd.Find([]string{"share", "list", "link"}) if err != nil { @@ -1299,6 +1345,22 @@ func TestShareLinkCreateDoesNotBreakShareListLinkCommand(t *testing.T) { } } +func newShareLinkCreateTestCommand(stdout *bytes.Buffer) *cobra.Command { + cmd := &cobra.Command{} + cmd.Flags().String("access", "", "") + cmd.Flags().String("audience", "", "") + cmd.Flags().Bool("allow-download", false, "") + cmd.Flags().Bool("disallow-download", false, "") + cmd.Flags().String("expires", "", "") + cmd.Flags().Bool("remove-expiration", false, "") + addSharedLinkPasswordFlags(cmd) + addDryRunFlag(cmd) + if stdout != nil { + cmd.SetOut(stdout) + } + return cmd +} + func TestShareLinkListListsAllLinks(t *testing.T) { var listArg *sharing.ListSharedLinksArg stubSharedLinkClient(t, &mockSharedLinkClient{ diff --git a/cmd/share_link_create.go b/cmd/share_link_create.go index 4be3334f..e204e7ff 100644 --- a/cmd/share_link_create.go +++ b/cmd/share_link_create.go @@ -18,6 +18,7 @@ import ( "errors" "fmt" "io" + "strings" "time" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" @@ -33,6 +34,7 @@ type shareLinkCreateOptions struct { access *sharing.RequestedLinkAccessLevel audience *sharing.LinkAudience password sharedLinkPasswordOptions + dryRun bool } type shareLinkCreateInput struct { @@ -44,6 +46,11 @@ type shareLinkCreateInput struct { AllowDownload bool `json:"allow_download,omitempty"` DisallowDownload bool `json:"disallow_download,omitempty"` Password bool `json:"password,omitempty"` + DryRun bool `json:"dry_run,omitempty"` +} + +type shareLinkCreateResultInput struct { + DryRun bool `json:"dry_run,omitempty"` } func shareLinkCreate(cmd *cobra.Command, args []string) error { @@ -64,6 +71,10 @@ func shareLinkCreate(cmd *cobra.Command, args []string) error { return err } + if opts.dryRun { + return renderShareLinkCreateDryRunOutput(cmd, path, opts) + } + dbx := newSharedLinkClient(config) link, err := createSharedLink(dbx, path, opts) usedExisting := false @@ -107,7 +118,7 @@ func shareLinkCreate(cmd *cobra.Command, args []string) error { }, newJSONCommandOperationOutput( cmd, newShareLinkCreateInput(path, opts), - []jsonOperationResult{shareLinkJSONOperationResult(status, result)}, + []jsonOperationResult{shareLinkCreateOperationResult(status, result, opts)}, nil, )) } @@ -119,6 +130,7 @@ func newShareLinkCreateInput(path string, opts shareLinkCreateOptions) shareLink AllowDownload: opts.allowDownload, DisallowDownload: opts.disallowDownload, Password: opts.password.set, + DryRun: opts.dryRun, } if opts.access != nil { input.Access = opts.access.Tag @@ -201,6 +213,13 @@ func parseShareLinkCreateOptions(cmd *cobra.Command) (shareLinkCreateOptions, er return opts, err } opts.password = password + if cmd.Flags().Lookup(dryRunFlagName) != nil { + dryRun, err := dryRunEnabled(cmd) + if err != nil { + return opts, err + } + opts.dryRun = dryRun + } if opts.expires != nil && opts.removeExpiration { return opts, invalidArgumentsErrorWithDetails("`--expires` and `--remove-expiration` cannot be used together", flagsErrorDetails("expires", "remove-expiration")) @@ -212,6 +231,30 @@ func parseShareLinkCreateOptions(cmd *cobra.Command) (shareLinkCreateOptions, er return opts, nil } +func shareLinkCreateOperationResult(status string, metadata shareLinkJSONMetadata, opts shareLinkCreateOptions) jsonOperationResult { + return newJSONOperationResult(plannedStatus(opts.dryRun, status), metadata.Type, shareLinkCreateResultInput{DryRun: opts.dryRun}, metadata) +} + +func plannedShareLinkCreateMetadata(path string) shareLinkJSONMetadata { + return shareLinkJSONMetadata{ + Type: shareLinkJSONKindLink, + URL: "", + PathLower: strings.ToLower(path), + } +} + +func renderShareLinkCreateDryRunOutput(cmd *cobra.Command, path string, opts shareLinkCreateOptions) error { + result := plannedShareLinkCreateMetadata(path) + return commandOutput(cmd).Render(func(w io.Writer) error { + return writeDryRunLine(w, "create shared link", path) + }, newJSONCommandOperationOutput( + cmd, + newShareLinkCreateInput(path, opts), + []jsonOperationResult{shareLinkCreateOperationResult(shareLinkJSONStatusCreated, result, opts)}, + nil, + )) +} + func applyExistingSharedLinkCreateOptions(dbx sharedLinkClient, link sharing.IsSharedLinkMetadata, opts shareLinkCreateOptions) (sharing.IsSharedLinkMetadata, error) { if opts.access != nil { return nil, invalidArgumentsErrorWithDetails("cannot apply `--access` because the shared link already exists", flagErrorDetails("access")) @@ -477,6 +520,7 @@ func init() { shareLinkCreateCmd.Flags().String("expires", "", "Set shared link expiration time as an RFC3339 timestamp") shareLinkCreateCmd.Flags().Bool("remove-expiration", false, "Remove expiration when returning an existing shared link") addSharedLinkPasswordFlags(shareLinkCreateCmd) + addDryRunFlag(shareLinkCreateCmd) shareLinkCmd.AddCommand(shareLinkCreateCmd) enableStructuredOutput(shareLinkCreateCmd) } diff --git a/cmd/share_link_json_test.go b/cmd/share_link_json_test.go index 147bf199..06631776 100644 --- a/cmd/share_link_json_test.go +++ b/cmd/share_link_json_test.go @@ -100,6 +100,71 @@ func TestShareLinkCreateJSONReportsExistingAsStatus(t *testing.T) { assertNoTopLevelJSONField(t, stdout.Bytes(), "existing") } +func TestShareLinkCreateJSONDryRunOutputsPlannedLink(t *testing.T) { + stubSharedLinkClient(t, &mockSharedLinkClient{ + createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { + t.Fatalf("CreateSharedLinkWithSettings called during dry-run: %v", arg) + return nil, nil + }, + createSharedLinkWithRawSettingsFn: func(path string, settings *rawSharedLinkSettings) (sharing.IsSharedLinkMetadata, error) { + t.Fatalf("CreateSharedLinkWithRawSettings called during dry-run: %s %#v", path, settings) + return nil, nil + }, + listSharedLinksFn: func(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) { + t.Fatalf("ListSharedLinks called during dry-run: %v", arg) + return nil, nil + }, + 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 + }, + }) + + var stdout bytes.Buffer + cmd := newShareLinkCreateTestCommand(&stdout) + 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 := shareLinkCreate(cmd, []string{"/Reports/Old.pdf"}); err != nil { + t.Fatalf("shareLinkCreate error: %v", err) + } + + var got struct { + Input shareLinkCreateInput `json:"input"` + Results []struct { + Status string `json:"status"` + Kind string `json:"kind"` + Input shareLinkCreateResultInput `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.Path != "/Reports/Old.pdf" || got.Input.Audience != "team" || !got.Input.DryRun { + t.Fatalf("input = %+v, want planned create input", 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 create", result) + } + if result.Result.Type != shareLinkJSONKindLink || result.Result.PathLower != "/reports/old.pdf" { + t.Fatalf("result metadata = %+v, want planned link metadata", result.Result) + } +} + func TestShareLinkListJSONOutputsResultsAndInput(t *testing.T) { stubSharedLinkClient(t, &mockSharedLinkClient{ listSharedLinksFn: func(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) { diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index 4c9b285f..22379831 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -267,11 +267,15 @@ "allow_download", "audience", "disallow_download", + "dry_run", "expires", "password", "path", "remove_expiration" ], + "share_link_create_result_input": [ + "dry_run" + ], "share_link_download_input": [ "password", "path", @@ -676,11 +680,12 @@ "top_level": "operation_output", "result_wrapper": "operation_result", "input": "share_link_create_input", - "result_input": "empty", + "result_input": "share_link_create_result_input", "result": "share_link_metadata", "statuses": [ "created", - "existing" + "existing", + "planned" ], "kinds": [ "file", diff --git a/docs/commands/dbxcli_share-link_create.md b/docs/commands/dbxcli_share-link_create.md index d7bdfb37..c61c974b 100644 --- a/docs/commands/dbxcli_share-link_create.md +++ b/docs/commands/dbxcli_share-link_create.md @@ -31,6 +31,7 @@ dbxcli share-link create [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 create --password string Password for password-protected shared links @@ -57,7 +58,7 @@ dbxcli share-link create [flags] * Dropbox scopes: `sharing.read`, `sharing.write` * Arguments: `path` (required, dropbox_path) * Flag metadata: `--access` (values: `editor`, `max`, `viewer`), `--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`; sensitive), `--password-file` (conflicts: `password`, `password-prompt`), `--password-prompt` (conflicts: `password`, `password-file`; may prompt), `--remove-expiration` (conflicts: `expires`) -* Result statuses: `created`, `existing` +* Result statuses: `created`, `existing`, `planned` * Result kinds: `file`, `folder`, `link` * JSON contract: `docs/json-schema/v1/commands.json#/commands/share-link create` * JSON success schema: `docs/json-schema/v1/commands.schema.json#/$defs/command_share_2dlink_20create` diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index 4c9b285f..22379831 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -267,11 +267,15 @@ "allow_download", "audience", "disallow_download", + "dry_run", "expires", "password", "path", "remove_expiration" ], + "share_link_create_result_input": [ + "dry_run" + ], "share_link_download_input": [ "password", "path", @@ -676,11 +680,12 @@ "top_level": "operation_output", "result_wrapper": "operation_result", "input": "share_link_create_input", - "result_input": "empty", + "result_input": "share_link_create_result_input", "result": "share_link_metadata", "statuses": [ "created", - "existing" + "existing", + "planned" ], "kinds": [ "file", diff --git a/docs/json-schema/v1/commands.schema.json b/docs/json-schema/v1/commands.schema.json index 3dff279f..793a518a 100644 --- a/docs/json-schema/v1/commands.schema.json +++ b/docs/json-schema/v1/commands.schema.json @@ -2418,7 +2418,7 @@ "additionalProperties": false, "properties": { "input": { - "$ref": "#/$defs/empty" + "$ref": "#/$defs/share_link_create_result_input" }, "kind": { "enum": [ @@ -2433,7 +2433,8 @@ "status": { "enum": [ "created", - "existing" + "existing", + "planned" ] } }, @@ -2938,6 +2939,9 @@ "disallow_download": { "type": "boolean" }, + "dry_run": { + "type": "boolean" + }, "expires": { "format": "date-time", "type": "string" @@ -2957,6 +2961,15 @@ ], "type": "object" }, + "share_link_create_result_input": { + "additionalProperties": false, + "properties": { + "dry_run": { + "type": "boolean" + } + }, + "type": "object" + }, "share_link_download_input": { "additionalProperties": false, "properties": {