diff --git a/src/cli/backup/backup.go b/src/cli/backup/backup.go index 9debea9d6..42abccc64 100644 --- a/src/cli/backup/backup.go +++ b/src/cli/backup/backup.go @@ -75,7 +75,7 @@ func backupCmd() *cobra.Command { Short: "Backup your service data", Long: `Backup the data stored in one of your M365 services.`, RunE: handleBackupCmd, - Args: cobra.NoArgs, + Args: utils.SubcommandsRequiredWithSuggestions, } } @@ -94,7 +94,7 @@ func createCmd() *cobra.Command { Use: createCommand, Short: "Backup an M365 Service", RunE: handleCreateCmd, - Args: cobra.NoArgs, + Args: utils.SubcommandsRequiredWithSuggestions, } } @@ -113,7 +113,7 @@ func listCmd() *cobra.Command { Use: listCommand, Short: "List the history of backups", RunE: handleListCmd, - Args: cobra.NoArgs, + Args: utils.SubcommandsRequiredWithSuggestions, } } @@ -132,7 +132,7 @@ func detailsCmd() *cobra.Command { Use: detailsCommand, Short: "Shows the details of a backup", RunE: handleDetailsCmd, - Args: cobra.NoArgs, + Args: utils.SubcommandsRequiredWithSuggestions, } } @@ -151,7 +151,7 @@ func deleteCmd() *cobra.Command { Use: deleteCommand, Short: "Deletes a backup", RunE: handleDeleteCmd, - Args: cobra.NoArgs, + Args: utils.SubcommandsRequiredWithSuggestions, } } diff --git a/src/cli/backup/exchange.go b/src/cli/backup/exchange.go index 72d6ab857..c3fc138fa 100644 --- a/src/cli/backup/exchange.go +++ b/src/cli/backup/exchange.go @@ -77,7 +77,7 @@ func addExchangeCommands(cmd *cobra.Command) *cobra.Command { // Flags addition ordering should follow the order we want them to appear in help and docs: // More generic (ex: --user) and more frequently used flags take precedence. - flags.AddMailBoxFlag(c) + flags.AddMailBoxFlag(c, utils.MailboxCompletionFunc(path.ExchangeService)) flags.AddDataFlag(c, []string{dataEmail, dataContacts, dataEvents}, false) flags.AddFetchParallelismFlag(c) flags.AddDisableDeltaFlag(c) @@ -90,7 +90,7 @@ func addExchangeCommands(cmd *cobra.Command) *cobra.Command { c, fs = utils.AddCommand(cmd, exchangeListCmd()) fs.SortFlags = false - flags.AddBackupIDFlag(c, false) + flags.AddBackupIDFlag(c, false, utils.BackupIDCompletionFunc(path.ExchangeService)) flags.AddAllBackupListFlags(c) case detailsCommand: @@ -104,7 +104,7 @@ func addExchangeCommands(cmd *cobra.Command) *cobra.Command { // Flags addition ordering should follow the order we want them to appear in help and docs: // More generic (ex: --user) and more frequently used flags take precedence. - flags.AddBackupIDFlag(c, true) + flags.AddBackupIDFlag(c, true, utils.BackupIDCompletionFunc(path.ExchangeService)) flags.AddExchangeDetailsAndRestoreFlags(c, false) case deleteCommand: @@ -115,7 +115,7 @@ func addExchangeCommands(cmd *cobra.Command) *cobra.Command { c.Example = exchangeServiceCommandDeleteExamples flags.AddMultipleBackupIDsFlag(c, false) - flags.AddBackupIDFlag(c, false) + flags.AddBackupIDFlag(c, false, utils.BackupIDCompletionFunc(path.ExchangeService)) } return c diff --git a/src/cli/backup/groups.go b/src/cli/backup/groups.go index 75b22c161..47a5ab4e3 100644 --- a/src/cli/backup/groups.go +++ b/src/cli/backup/groups.go @@ -70,7 +70,7 @@ func addGroupsCommands(cmd *cobra.Command) *cobra.Command { c.Example = groupsServiceCommandCreateExamples // Flags addition ordering should follow the order we want them to appear in help and docs: - flags.AddGroupFlag(c) + flags.AddGroupFlag(c, utils.GroupsCompletionFunc()) flags.AddDataFlag(c, []string{flags.DataLibraries, flags.DataMessages}, false) flags.AddFetchParallelismFlag(c) flags.AddDisableDeltaFlag(c) @@ -80,7 +80,7 @@ func addGroupsCommands(cmd *cobra.Command) *cobra.Command { c, fs = utils.AddCommand(cmd, groupsListCmd(), utils.MarkPreviewCommand()) fs.SortFlags = false - flags.AddBackupIDFlag(c, false) + flags.AddBackupIDFlag(c, false, utils.BackupIDCompletionFunc(path.GroupsService)) flags.AddAllBackupListFlags(c) case detailsCommand: @@ -94,7 +94,7 @@ func addGroupsCommands(cmd *cobra.Command) *cobra.Command { // Flags addition ordering should follow the order we want them to appear in help and docs: // More generic (ex: --user) and more frequently used flags take precedence. - flags.AddBackupIDFlag(c, true) + flags.AddBackupIDFlag(c, true, utils.BackupIDCompletionFunc(path.GroupsService)) flags.AddGroupDetailsAndRestoreFlags(c) flags.AddSharePointDetailsAndRestoreFlags(c) @@ -106,7 +106,7 @@ func addGroupsCommands(cmd *cobra.Command) *cobra.Command { c.Example = groupsServiceCommandDeleteExamples flags.AddMultipleBackupIDsFlag(c, false) - flags.AddBackupIDFlag(c, false) + flags.AddBackupIDFlag(c, false, utils.BackupIDCompletionFunc(path.GroupsService)) } return c diff --git a/src/cli/backup/onedrive.go b/src/cli/backup/onedrive.go index 0614bbad7..7a8295401 100644 --- a/src/cli/backup/onedrive.go +++ b/src/cli/backup/onedrive.go @@ -65,14 +65,14 @@ func addOneDriveCommands(cmd *cobra.Command) *cobra.Command { c.Use = c.Use + " " + oneDriveServiceCommandCreateUseSuffix c.Example = oneDriveServiceCommandCreateExamples - flags.AddUserFlag(c) + flags.AddUserFlag(c, utils.UsersCompletionFunc(path.OneDriveService)) flags.AddGenericBackupFlags(c) case listCommand: c, fs = utils.AddCommand(cmd, oneDriveListCmd()) fs.SortFlags = false - flags.AddBackupIDFlag(c, false) + flags.AddBackupIDFlag(c, false, utils.BackupIDCompletionFunc(path.OneDriveService)) flags.AddAllBackupListFlags(c) case detailsCommand: @@ -83,7 +83,7 @@ func addOneDriveCommands(cmd *cobra.Command) *cobra.Command { c.Example = oneDriveServiceCommandDetailsExamples flags.AddSkipReduceFlag(c) - flags.AddBackupIDFlag(c, true) + flags.AddBackupIDFlag(c, true, utils.BackupIDCompletionFunc(path.OneDriveService)) flags.AddOneDriveDetailsAndRestoreFlags(c) case deleteCommand: @@ -94,7 +94,7 @@ func addOneDriveCommands(cmd *cobra.Command) *cobra.Command { c.Example = oneDriveServiceCommandDeleteExamples flags.AddMultipleBackupIDsFlag(c, false) - flags.AddBackupIDFlag(c, false) + flags.AddBackupIDFlag(c, false, utils.BackupIDCompletionFunc(path.OneDriveService)) } return c diff --git a/src/cli/backup/sharepoint.go b/src/cli/backup/sharepoint.go index 012243856..f580568d4 100644 --- a/src/cli/backup/sharepoint.go +++ b/src/cli/backup/sharepoint.go @@ -76,7 +76,7 @@ func addSharePointCommands(cmd *cobra.Command) *cobra.Command { c.Use = c.Use + " " + sharePointServiceCommandCreateUseSuffix c.Example = sharePointServiceCommandCreateExamples - flags.AddSiteFlag(c, true) + flags.AddSiteFlag(c, true, utils.SitesCompletionFunc()) flags.AddSiteIDFlag(c, true) flags.AddDataFlag(c, []string{flags.DataLibraries}, true) flags.AddGenericBackupFlags(c) @@ -85,7 +85,7 @@ func addSharePointCommands(cmd *cobra.Command) *cobra.Command { c, fs = utils.AddCommand(cmd, sharePointListCmd()) fs.SortFlags = false - flags.AddBackupIDFlag(c, false) + flags.AddBackupIDFlag(c, false, utils.BackupIDCompletionFunc(path.SharePointService)) flags.AddAllBackupListFlags(c) case detailsCommand: @@ -96,7 +96,7 @@ func addSharePointCommands(cmd *cobra.Command) *cobra.Command { c.Example = sharePointServiceCommandDetailsExamples flags.AddSkipReduceFlag(c) - flags.AddBackupIDFlag(c, true) + flags.AddBackupIDFlag(c, true, utils.BackupIDCompletionFunc(path.SharePointService)) flags.AddSharePointDetailsAndRestoreFlags(c) case deleteCommand: @@ -107,7 +107,7 @@ func addSharePointCommands(cmd *cobra.Command) *cobra.Command { c.Example = sharePointServiceCommandDeleteExamples flags.AddMultipleBackupIDsFlag(c, false) - flags.AddBackupIDFlag(c, false) + flags.AddBackupIDFlag(c, false, utils.BackupIDCompletionFunc(path.SharePointService)) } return c diff --git a/src/cli/cli.go b/src/cli/cli.go index 0f41e69a7..b3a01f34c 100644 --- a/src/cli/cli.go +++ b/src/cli/cli.go @@ -2,6 +2,7 @@ package cli import ( "context" + "fmt" "os" "regexp" "strings" @@ -54,7 +55,19 @@ func preRun(cc *cobra.Command, args []string) error { } avoidTheseCommands := []string{ - "corso", "env", "help", "backup", "details", "list", "restore", "export", "delete", "repo", "init", "connect", + "corso", + "env", + "help", + "backup", + "details", + "list", + "restore", + "export", + "delete", + "repo", + "init", + "connect", + "completion [bash|zsh|fish|powershell]", } if len(logger.ResolvedLogFile) > 0 && !slices.Contains(avoidTheseCommands, cc.Use) { @@ -121,6 +134,7 @@ func BuildCommandTree(cmd *cobra.Command) { cmd.SetUsageTemplate(indentExamplesTemplate(corsoCmd.UsageTemplate())) cmd.CompletionOptions.DisableDefaultCmd = true + cmd.SuggestionsMinimumDistance = 2 // default repo.AddCommands(cmd) backup.AddCommands(cmd) @@ -128,6 +142,38 @@ func BuildCommandTree(cmd *cobra.Command) { export.AddCommands(cmd) debug.AddCommands(cmd) help.AddCommands(cmd) + AddCompletion(cmd) +} + +// We are not using the default completion command as it will be +// harder to control it, for example skipping printing "Logging to file" +// message +func AddCompletion(cmd *cobra.Command) { + completion := &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate completion script", + Long: "To load completions", + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return cmd.Root().GenBashCompletion(os.Stdout) + case "zsh": + return cmd.Root().GenZshCompletion(os.Stdout) + case "fish": + return cmd.Root().GenFishCompletion(os.Stdout, true) + case "powershell": + return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) + } + + return fmt.Errorf("unknown shell type %q", args[0]) + }, + } + + cmd.AddCommand(completion) } // ------------------------------------------------------------------------------------------ diff --git a/src/cli/debug/exchange.go b/src/cli/debug/exchange.go index adc6c19df..1fbf9afce 100644 --- a/src/cli/debug/exchange.go +++ b/src/cli/debug/exchange.go @@ -6,6 +6,7 @@ import ( "github.com/alcionai/corso/src/cli/flags" "github.com/alcionai/corso/src/cli/utils" + "github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/selectors" ) @@ -26,7 +27,7 @@ func addExchangeCommands(cmd *cobra.Command) *cobra.Command { // More generic (ex: --user) and more frequently used flags take precedence. fs.SortFlags = false - flags.AddBackupIDFlag(c, true) + flags.AddBackupIDFlag(c, true, utils.BackupIDCompletionFunc(path.ExchangeService)) } return c diff --git a/src/cli/debug/groups.go b/src/cli/debug/groups.go index 3335e9e0c..42a977d09 100644 --- a/src/cli/debug/groups.go +++ b/src/cli/debug/groups.go @@ -6,6 +6,7 @@ import ( "github.com/alcionai/corso/src/cli/flags" "github.com/alcionai/corso/src/cli/utils" + "github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/selectors" ) @@ -26,7 +27,7 @@ func addGroupsCommands(cmd *cobra.Command) *cobra.Command { // More generic (ex: --user) and more frequently used flags take precedence. fs.SortFlags = false - flags.AddBackupIDFlag(c, true) + flags.AddBackupIDFlag(c, true, utils.BackupIDCompletionFunc(path.GroupsService)) } return c diff --git a/src/cli/debug/onedrive.go b/src/cli/debug/onedrive.go index 902a1f748..1347c6fc5 100644 --- a/src/cli/debug/onedrive.go +++ b/src/cli/debug/onedrive.go @@ -6,6 +6,7 @@ import ( "github.com/alcionai/corso/src/cli/flags" "github.com/alcionai/corso/src/cli/utils" + "github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/selectors" ) @@ -26,7 +27,7 @@ func addOneDriveCommands(cmd *cobra.Command) *cobra.Command { // More generic (ex: --user) and more frequently used flags take precedence. fs.SortFlags = false - flags.AddBackupIDFlag(c, true) + flags.AddBackupIDFlag(c, true, utils.BackupIDCompletionFunc(path.OneDriveService)) } return c diff --git a/src/cli/debug/sharepoint.go b/src/cli/debug/sharepoint.go index dd6a18383..1c1f1e5b5 100644 --- a/src/cli/debug/sharepoint.go +++ b/src/cli/debug/sharepoint.go @@ -6,6 +6,7 @@ import ( "github.com/alcionai/corso/src/cli/flags" "github.com/alcionai/corso/src/cli/utils" + "github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/selectors" ) @@ -26,7 +27,7 @@ func addSharePointCommands(cmd *cobra.Command) *cobra.Command { // More generic (ex: --user) and more frequently used flags take precedence. fs.SortFlags = false - flags.AddBackupIDFlag(c, true) + flags.AddBackupIDFlag(c, true, utils.BackupIDCompletionFunc(path.SharePointService)) } return c diff --git a/src/cli/export/exchange.go b/src/cli/export/exchange.go index 90e2a9852..860af124b 100644 --- a/src/cli/export/exchange.go +++ b/src/cli/export/exchange.go @@ -7,6 +7,7 @@ import ( "github.com/alcionai/corso/src/cli/flags" "github.com/alcionai/corso/src/cli/utils" + "github.com/alcionai/corso/src/pkg/path" ) // called by export.go to map subcommands to provider-specific handling. @@ -26,7 +27,7 @@ func addExchangeCommands(cmd *cobra.Command) *cobra.Command { // More generic (ex: --user) and more frequently used flags take precedence. fs.SortFlags = false - flags.AddBackupIDFlag(c, true) + flags.AddBackupIDFlag(c, true, utils.BackupIDCompletionFunc(path.ExchangeService)) flags.AddExchangeDetailsAndRestoreFlags(c, true) flags.AddExportConfigFlags(c) flags.AddFailFastFlag(c) diff --git a/src/cli/export/export.go b/src/cli/export/export.go index 99200c1f5..5b6a0326c 100644 --- a/src/cli/export/export.go +++ b/src/cli/export/export.go @@ -49,7 +49,7 @@ func exportCmd() *cobra.Command { Short: "Export your service data", Long: `Export the data stored in one of your M365 services.`, RunE: handleExportCmd, - Args: cobra.NoArgs, + Args: utils.SubcommandsRequiredWithSuggestions, } } diff --git a/src/cli/export/groups.go b/src/cli/export/groups.go index ab9c19406..844d2e216 100644 --- a/src/cli/export/groups.go +++ b/src/cli/export/groups.go @@ -8,6 +8,7 @@ import ( "github.com/alcionai/corso/src/cli/flags" "github.com/alcionai/corso/src/cli/utils" "github.com/alcionai/corso/src/pkg/control" + "github.com/alcionai/corso/src/pkg/path" ) // called by export.go to map subcommands to provider-specific handling. @@ -27,8 +28,8 @@ func addGroupsCommands(cmd *cobra.Command) *cobra.Command { // More generic (ex: --user) and more frequently used flags take precedence. fs.SortFlags = false - flags.AddBackupIDFlag(c, true) - flags.AddSiteFlag(c, false) + flags.AddBackupIDFlag(c, true, utils.BackupIDCompletionFunc(path.GroupsService)) + flags.AddSiteFlag(c, false, utils.SitesCompletionFunc()) flags.AddSiteIDFlag(c, false) flags.AddSharePointDetailsAndRestoreFlags(c) flags.AddGroupDetailsAndRestoreFlags(c) diff --git a/src/cli/export/onedrive.go b/src/cli/export/onedrive.go index c6fb54165..31b048a4a 100644 --- a/src/cli/export/onedrive.go +++ b/src/cli/export/onedrive.go @@ -7,6 +7,7 @@ import ( "github.com/alcionai/corso/src/cli/flags" "github.com/alcionai/corso/src/cli/utils" + "github.com/alcionai/corso/src/pkg/path" ) // called by export.go to map subcommands to provider-specific handling. @@ -26,7 +27,7 @@ func addOneDriveCommands(cmd *cobra.Command) *cobra.Command { // More generic (ex: --user) and more frequently used flags take precedence. fs.SortFlags = false - flags.AddBackupIDFlag(c, true) + flags.AddBackupIDFlag(c, true, utils.BackupIDCompletionFunc(path.OneDriveService)) flags.AddOneDriveDetailsAndRestoreFlags(c) flags.AddExportConfigFlags(c) flags.AddFailFastFlag(c) diff --git a/src/cli/export/sharepoint.go b/src/cli/export/sharepoint.go index e2be0b49b..8060250f1 100644 --- a/src/cli/export/sharepoint.go +++ b/src/cli/export/sharepoint.go @@ -7,6 +7,7 @@ import ( "github.com/alcionai/corso/src/cli/flags" "github.com/alcionai/corso/src/cli/utils" + "github.com/alcionai/corso/src/pkg/path" ) // called by export.go to map subcommands to provider-specific handling. @@ -26,7 +27,7 @@ func addSharePointCommands(cmd *cobra.Command) *cobra.Command { // More generic (ex: --user) and more frequently used flags take precedence. fs.SortFlags = false - flags.AddBackupIDFlag(c, true) + flags.AddBackupIDFlag(c, true, utils.BackupIDCompletionFunc(path.SharePointService)) flags.AddSharePointDetailsAndRestoreFlags(c) flags.AddExportConfigFlags(c) flags.AddFailFastFlag(c) diff --git a/src/cli/flags/groups.go b/src/cli/flags/groups.go index 0ac365ac6..3e8a236e2 100644 --- a/src/cli/flags/groups.go +++ b/src/cli/flags/groups.go @@ -68,9 +68,14 @@ func AddGroupDetailsAndRestoreFlags(cmd *cobra.Command) { // Mail is most accurate, MailNickame is accurate and shorter, but the end user // may not see either one visibly. // https://learn.microsoft.com/en-us/graph/api/group-list?view=graph-rest-1.0&tabs=http -func AddGroupFlag(cmd *cobra.Command) { +func AddGroupFlag( + cmd *cobra.Command, + completionFunc func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective), +) { cmd.Flags().StringSliceVar( &GroupFV, GroupFN, nil, "Backup data by group; accepts '"+Wildcard+"' to select all groups.") + + cobra.CheckErr(cmd.RegisterFlagCompletionFunc(GroupFN, completionFunc)) } diff --git a/src/cli/flags/m365_common.go b/src/cli/flags/m365_common.go index d4a0c0231..c40a9a642 100644 --- a/src/cli/flags/m365_common.go +++ b/src/cli/flags/m365_common.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/spf13/cobra" + "golang.org/x/exp/slices" ) var CategoryDataFV []string @@ -39,4 +40,36 @@ func AddDataFlag(cmd *cobra.Command, allowed []string, hide bool) { if hide { cobra.CheckErr(fs.MarkHidden(CategoryDataFN)) } + + // TODO(meain): This is a hacky way to get it to autocomplete multiple items + cobra.CheckErr(cmd.RegisterFlagCompletionFunc( + CategoryDataFN, + func( + cmd *cobra.Command, + args []string, + toComplete string, + ) ([]string, cobra.ShellCompDirective) { + added := strings.Split(toComplete, ",") + last := added[len(added)-1] + added = added[:len(added)-1] + + if slices.Contains(added, last) { + added = append(added, last) + last = "" + } + + pending := make([]string, 0, len(allowed)-len(added)) + for _, a := range allowed { + if !slices.Contains(added, a) && strings.HasPrefix(a, last) { + pending = append(pending, a) + } + } + + completions := []string{} + for _, p := range pending { + completions = append(completions, strings.Join(append(added, p), ",")) + } + + return completions, cobra.ShellCompDirectiveNoSpace + })) } diff --git a/src/cli/flags/m365_resource.go b/src/cli/flags/m365_resource.go index fdd97671d..f7a9c7b91 100644 --- a/src/cli/flags/m365_resource.go +++ b/src/cli/flags/m365_resource.go @@ -22,16 +22,24 @@ var ( ) // AddUserFlag adds the --user flag. -func AddUserFlag(cmd *cobra.Command) { +func AddUserFlag( + cmd *cobra.Command, + completionFunc func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective), +) { cmd.Flags().StringSliceVar( &UserFV, UserFN, nil, "Backup a specific user's data; accepts '"+Wildcard+"' to select all users.") cobra.CheckErr(cmd.MarkFlagRequired(UserFN)) + + cobra.CheckErr(cmd.RegisterFlagCompletionFunc(UserFN, completionFunc)) } // AddMailBoxFlag adds the --user and --mailbox flag. -func AddMailBoxFlag(cmd *cobra.Command) { +func AddMailBoxFlag( + cmd *cobra.Command, + completionFunc func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective), +) { flags := cmd.Flags() flags.StringSliceVar( @@ -41,10 +49,22 @@ func AddMailBoxFlag(cmd *cobra.Command) { cobra.CheckErr(flags.MarkDeprecated(UserFN, fmt.Sprintf("use --%s instead", MailBoxFN))) + cobra.CheckErr(cmd.RegisterFlagCompletionFunc(UserFN, + func( + cmd *cobra.Command, + args []string, + toComplete string, + ) ([]string, cobra.ShellCompDirective) { + message := fmt.Sprintf("This flag is deprecated, Use --%s instead", MailBoxFN) + return cobra.AppendActiveHelp(nil, message), cobra.ShellCompDirectiveNoFileComp + })) + flags.StringSliceVar( &UserFV, MailBoxFN, nil, "Backup a specific mailbox's data; accepts '"+Wildcard+"' to select all mailbox.") + + cobra.CheckErr(cmd.RegisterFlagCompletionFunc(MailBoxFN, completionFunc)) } // AddAzureCredsFlags adds M365 cred flags diff --git a/src/cli/flags/repo.go b/src/cli/flags/repo.go index ac84488b6..c4211a360 100644 --- a/src/cli/flags/repo.go +++ b/src/cli/flags/repo.go @@ -41,8 +41,13 @@ func AddMultipleBackupIDsFlag(cmd *cobra.Command, require bool) { } // AddBackupIDFlag adds the --backup flag. -func AddBackupIDFlag(cmd *cobra.Command, require bool) { +func AddBackupIDFlag( + cmd *cobra.Command, + require bool, + completionFunc func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective), +) { cmd.Flags().StringVar(&BackupIDFV, BackupFN, "", "ID of the backup to retrieve.") + cobra.CheckErr(cmd.RegisterFlagCompletionFunc(BackupFN, completionFunc)) if require { cobra.CheckErr(cmd.MarkFlagRequired(BackupFN)) diff --git a/src/cli/flags/sharepoint.go b/src/cli/flags/sharepoint.go index 9a4a67ada..d3938df12 100644 --- a/src/cli/flags/sharepoint.go +++ b/src/cli/flags/sharepoint.go @@ -95,6 +95,7 @@ func AddSharePointDetailsAndRestoreFlags(cmd *cobra.Command) { // AddSiteIDFlag adds the --site-id flag, which accepts site ID values. // This flag is hidden, since we expect users to prefer the --site url // and do not want to encourage confusion. +// TODO(meain): --site is the primary one, but it would be useful to have comepletion for this as well func AddSiteIDFlag(cmd *cobra.Command, multiple bool) { fs := cmd.Flags() @@ -112,11 +113,17 @@ func AddSiteIDFlag(cmd *cobra.Command, multiple bool) { } // AddSiteFlag adds the --site flag, which accepts webURL values. -func AddSiteFlag(cmd *cobra.Command, multiple bool) { +func AddSiteFlag( + cmd *cobra.Command, + multiple bool, + completionFunc func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective), +) { message := "Web URL of the site to operate on" if multiple { message += "; accepts '" + Wildcard + "' to select all sites." } cmd.Flags().StringSliceVar(&WebURLFV, SiteFN, nil, message) + + cobra.CheckErr(cmd.RegisterFlagCompletionFunc(SiteFN, completionFunc)) } diff --git a/src/cli/help/env.go b/src/cli/help/env.go index b8fd92e77..b140d6612 100644 --- a/src/cli/help/env.go +++ b/src/cli/help/env.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" . "github.com/alcionai/corso/src/cli/print" + "github.com/alcionai/corso/src/cli/utils" ) // AddCommands attaches all `corso env * *` commands to the parent. @@ -19,7 +20,7 @@ func envCmd() *cobra.Command { Short: "env var guide", Long: `A guide to using environment variables in Corso.`, RunE: handleEnvCmd, - Args: cobra.NoArgs, + Args: utils.SubcommandsRequiredWithSuggestions, } envCmd.SetHelpFunc(envGuide) diff --git a/src/cli/repo/repo.go b/src/cli/repo/repo.go index 7da0cb377..af4dba906 100644 --- a/src/cli/repo/repo.go +++ b/src/cli/repo/repo.go @@ -77,7 +77,7 @@ func repoCmd() *cobra.Command { Short: "Manage your repositories", Long: `Initialize, configure, connect and update to your account backup repositories`, RunE: handleRepoCmd, - Args: cobra.NoArgs, + Args: utils.SubcommandsRequiredWithSuggestions, } } @@ -95,7 +95,7 @@ func initCmd() *cobra.Command { Short: "Initialize a repository.", Long: `Create a new repository to store your backups.`, RunE: handleInitCmd, - Args: cobra.NoArgs, + Args: utils.SubcommandsRequiredWithSuggestions, } } @@ -112,7 +112,7 @@ func connectCmd() *cobra.Command { Short: "Connect to a repository.", Long: `Connect to an existing repository.`, RunE: handleConnectCmd, - Args: cobra.NoArgs, + Args: utils.SubcommandsRequiredWithSuggestions, } } @@ -127,7 +127,7 @@ func maintenanceCmd() *cobra.Command { Short: "Run maintenance on an existing repository", Long: `Run maintenance on an existing repository to optimize performance and storage use`, RunE: handleMaintenanceCmd, - Args: cobra.NoArgs, + Args: utils.SubcommandsRequiredWithSuggestions, } } diff --git a/src/cli/restore/exchange.go b/src/cli/restore/exchange.go index 77793a31e..83fef1d0f 100644 --- a/src/cli/restore/exchange.go +++ b/src/cli/restore/exchange.go @@ -6,6 +6,7 @@ import ( "github.com/alcionai/corso/src/cli/flags" "github.com/alcionai/corso/src/cli/utils" + "github.com/alcionai/corso/src/pkg/path" ) // called by restore.go to map subcommands to provider-specific handling. @@ -26,7 +27,7 @@ func addExchangeCommands(cmd *cobra.Command) *cobra.Command { // general flags fs.SortFlags = false - flags.AddBackupIDFlag(c, true) + flags.AddBackupIDFlag(c, true, utils.BackupIDCompletionFunc(path.ExchangeService)) flags.AddExchangeDetailsAndRestoreFlags(c, false) flags.AddRestoreConfigFlags(c, true) flags.AddFailFastFlag(c) diff --git a/src/cli/restore/groups.go b/src/cli/restore/groups.go index c9ba2d6b7..0d080770a 100644 --- a/src/cli/restore/groups.go +++ b/src/cli/restore/groups.go @@ -7,6 +7,7 @@ import ( "github.com/alcionai/corso/src/cli/flags" "github.com/alcionai/corso/src/cli/utils" "github.com/alcionai/corso/src/internal/common/dttm" + "github.com/alcionai/corso/src/pkg/path" ) // called by restore.go to map subcommands to provider-specific handling. @@ -26,8 +27,8 @@ func addGroupsCommands(cmd *cobra.Command) *cobra.Command { // More generic (ex: --user) and more frequently used flags take precedence. fs.SortFlags = false - flags.AddBackupIDFlag(c, true) - flags.AddSiteFlag(c, false) + flags.AddBackupIDFlag(c, true, utils.BackupIDCompletionFunc(path.GroupsService)) + flags.AddSiteFlag(c, false, utils.SitesCompletionFunc()) flags.AddSiteIDFlag(c, false) flags.AddNoPermissionsFlag(c) flags.AddSharePointDetailsAndRestoreFlags(c) diff --git a/src/cli/restore/onedrive.go b/src/cli/restore/onedrive.go index 8b44d3758..85338ea92 100644 --- a/src/cli/restore/onedrive.go +++ b/src/cli/restore/onedrive.go @@ -7,6 +7,7 @@ import ( "github.com/alcionai/corso/src/cli/flags" "github.com/alcionai/corso/src/cli/utils" "github.com/alcionai/corso/src/internal/common/dttm" + "github.com/alcionai/corso/src/pkg/path" ) // called by restore.go to map subcommands to provider-specific handling. @@ -26,7 +27,7 @@ func addOneDriveCommands(cmd *cobra.Command) *cobra.Command { // More generic (ex: --user) and more frequently used flags take precedence. fs.SortFlags = false - flags.AddBackupIDFlag(c, true) + flags.AddBackupIDFlag(c, true, utils.BackupIDCompletionFunc(path.OneDriveService)) flags.AddOneDriveDetailsAndRestoreFlags(c) flags.AddNoPermissionsFlag(c) flags.AddRestoreConfigFlags(c, true) diff --git a/src/cli/restore/restore.go b/src/cli/restore/restore.go index 7db7dc5a7..c5c779af6 100644 --- a/src/cli/restore/restore.go +++ b/src/cli/restore/restore.go @@ -74,7 +74,7 @@ func restoreCmd() *cobra.Command { Short: "Restore your service data", Long: `Restore the data stored in one of your M365 services.`, RunE: handleRestoreCmd, - Args: cobra.NoArgs, + Args: utils.SubcommandsRequiredWithSuggestions, Example: restoreCommandExamples, } } diff --git a/src/cli/restore/sharepoint.go b/src/cli/restore/sharepoint.go index c79756e7a..f7b8ec593 100644 --- a/src/cli/restore/sharepoint.go +++ b/src/cli/restore/sharepoint.go @@ -7,6 +7,7 @@ import ( "github.com/alcionai/corso/src/cli/flags" "github.com/alcionai/corso/src/cli/utils" "github.com/alcionai/corso/src/internal/common/dttm" + "github.com/alcionai/corso/src/pkg/path" ) // called by restore.go to map subcommands to provider-specific handling. @@ -26,7 +27,7 @@ func addSharePointCommands(cmd *cobra.Command) *cobra.Command { // More generic (ex: --site) and more frequently used flags take precedence. fs.SortFlags = false - flags.AddBackupIDFlag(c, true) + flags.AddBackupIDFlag(c, true, utils.BackupIDCompletionFunc(path.SharePointService)) flags.AddSharePointDetailsAndRestoreFlags(c) flags.AddNoPermissionsFlag(c) flags.AddRestoreConfigFlags(c, true) diff --git a/src/cli/utils/completions.go b/src/cli/utils/completions.go new file mode 100644 index 000000000..ca6326ce0 --- /dev/null +++ b/src/cli/utils/completions.go @@ -0,0 +1,194 @@ +package utils + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/alcionai/corso/src/pkg/backup" + "github.com/alcionai/corso/src/pkg/fault" + "github.com/alcionai/corso/src/pkg/path" + "github.com/alcionai/corso/src/pkg/services/m365" + "github.com/alcionai/corso/src/pkg/store" +) + +func GetBackups(ctx context.Context, cmd *cobra.Command, service path.ServiceType) ([]*backup.Backup, error) { + r, _, err := GetAccountAndConnect(ctx, cmd, service) + if err != nil { + return nil, err + } + + defer CloseRepo(ctx, r) + + return r.BackupsByTag(ctx, store.Service(service)) +} + +type completionFunc func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) + +func BackupIDCompletionFunc(service path.ServiceType) completionFunc { + return func( + cmd *cobra.Command, + args []string, + toComplete string, + ) ([]string, cobra.ShellCompDirective) { + bs, err := GetBackups(cmd.Context(), cmd, service) + if err != nil { + return cobra.AppendActiveHelp( + nil, + fmt.Sprintf("Unable to fetch %s backups", service)), cobra.ShellCompDirectiveNoFileComp + } + + if len(bs) == 0 { + return cobra.AppendActiveHelp( + nil, + fmt.Sprintf("No %s backups found", service)), cobra.ShellCompDirectiveNoFileComp + } + + backups := make([]string, len(bs)) + for _, b := range bs { + backups = append(backups, fmt.Sprintf("%s\tCreated at %s", b.GetID(), b.CreationTime)) + } + + return cobra.AppendActiveHelp(backups, "Choose backup ID to use"), cobra.ShellCompDirectiveNoFileComp + } +} + +func UsersCompletionFunc(service path.ServiceType) completionFunc { + return func( + cmd *cobra.Command, + args []string, + toComplete string, + ) ([]string, cobra.ShellCompDirective) { + ctx := cmd.Context() + + r, acct, err := AccountConnectAndWriteRepoConfig(ctx, cmd, service) + if err != nil { + return cobra.AppendActiveHelp( + nil, + fmt.Sprintf("Unable to fetch %s users", service)), cobra.ShellCompDirectiveNoFileComp + } + + ins, err := UsersMap(ctx, *acct, Control(), r.Counter(), fault.New(true)) + if err != nil { + return cobra.AppendActiveHelp( + nil, + fmt.Sprintf("Unable to fetch %s users", service)), cobra.ShellCompDirectiveNoFileComp + } + + if len(ins.IDs()) == 0 { + return cobra.AppendActiveHelp( + nil, + fmt.Sprintf("No %s users found", service)), cobra.ShellCompDirectiveNoFileComp + } + + backups := make([]string, len(ins.IDs())) + + for _, u := range ins.IDs() { + name, _ := ins.NameOf(u) + backups = append(backups, fmt.Sprintf("%s\t%s", u, name)) + } + + return cobra.AppendActiveHelp(backups, "Choose user ID to use"), cobra.ShellCompDirectiveNoFileComp + } +} + +func MailboxCompletionFunc(service path.ServiceType) completionFunc { + return func( + cmd *cobra.Command, + args []string, + toComplete string, + ) ([]string, cobra.ShellCompDirective) { + ctx := cmd.Context() + + r, acct, err := AccountConnectAndWriteRepoConfig(ctx, cmd, service) + if err != nil { + return cobra.AppendActiveHelp( + nil, + fmt.Sprintf("Unable to fetch %s mailboxes", service)), cobra.ShellCompDirectiveNoFileComp + } + + ins, err := UsersMap(ctx, *acct, Control(), r.Counter(), fault.New(true)) + if err != nil { + return cobra.AppendActiveHelp( + nil, + fmt.Sprintf("Unable to fetch %s mailboxes", service)), cobra.ShellCompDirectiveNoFileComp + } + + if len(ins.IDs()) == 0 { + return cobra.AppendActiveHelp( + nil, + fmt.Sprintf("No %s mailboxes found", service)), cobra.ShellCompDirectiveNoFileComp + } + + backups := ins.Names() + + return cobra.AppendActiveHelp(backups, "Choose mailbox to use"), cobra.ShellCompDirectiveNoFileComp + } +} + +func GroupsCompletionFunc() completionFunc { + return func( + cmd *cobra.Command, + args []string, + toComplete string, + ) ([]string, cobra.ShellCompDirective) { + ctx := cmd.Context() + + _, acct, err := AccountConnectAndWriteRepoConfig(ctx, cmd, path.GroupsService) + if err != nil { + return cobra.AppendActiveHelp(nil, "Unable to fetch Groups"), cobra.ShellCompDirectiveNoFileComp + } + + ins, err := m365.GroupsMap(ctx, *acct, fault.New(true)) + if err != nil { + return cobra.AppendActiveHelp(nil, "Unable to fetch Groups"), cobra.ShellCompDirectiveNoFileComp + } + + if len(ins.IDs()) == 0 { + return cobra.AppendActiveHelp(nil, "No Groups found"), cobra.ShellCompDirectiveNoFileComp + } + + backups := make([]string, len(ins.IDs())) + + for _, u := range ins.IDs() { + name, _ := ins.NameOf(u) + backups = append(backups, fmt.Sprintf("%s\t%s", u, name)) + } + + return cobra.AppendActiveHelp(backups, "Choose Group to use"), cobra.ShellCompDirectiveNoFileComp + } +} + +func SitesCompletionFunc() completionFunc { + return func( + cmd *cobra.Command, + args []string, + toComplete string, + ) ([]string, cobra.ShellCompDirective) { + ctx := cmd.Context() + + _, acct, err := AccountConnectAndWriteRepoConfig(ctx, cmd, path.SharePointService) + if err != nil { + return cobra.AppendActiveHelp(nil, "Unable to fetch Sites"), cobra.ShellCompDirectiveNoFileComp + } + + ins, err := m365.SitesMap(ctx, *acct, fault.New(true)) + if err != nil { + return cobra.AppendActiveHelp(nil, "Unable to fetch Sites"), cobra.ShellCompDirectiveNoFileComp + } + + if len(ins.IDs()) == 0 { + return cobra.AppendActiveHelp(nil, "No Sites found"), cobra.ShellCompDirectiveNoFileComp + } + + backups := make([]string, len(ins.IDs())) + + for _, u := range ins.IDs() { + name, _ := ins.NameOf(u) + backups = append(backups, fmt.Sprintf("%s\t%s", u, name)) + } + + return cobra.AppendActiveHelp(backups, "Choose Site to use"), cobra.ShellCompDirectiveNoFileComp + } +} diff --git a/src/cli/utils/suggestions.go b/src/cli/utils/suggestions.go new file mode 100644 index 000000000..c09f8e294 --- /dev/null +++ b/src/cli/utils/suggestions.go @@ -0,0 +1,115 @@ +package utils + +// The code in this file is mostly lifted out of cobra itself. It as +// of now has some issue with how it handles completions for +// subcommands and only autocompletes for top level commands. +// https://github.com/spf13/cobra/issues/981#issuecomment-547003669 + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +// SubcommandsRequiredWithSuggestions will ensure we have a subcommand provided by the user and augments it with +// suggestion for commands, alias and help on root command. +func SubcommandsRequiredWithSuggestions(cmd *cobra.Command, args []string) error { + requireMsg := "%s requires a valid subcommand" + // This will be triggered if cobra didn't find any subcommands. + // Find some suggestions. + var suggestions []string + + if len(args) != 0 && !cmd.DisableSuggestions { + typedName := args[0] + + if cmd.SuggestionsMinimumDistance <= 0 { + cmd.SuggestionsMinimumDistance = 2 + } + // subcommand suggestions + suggestions = append(cmd.SuggestionsFor(args[0])) + + // subcommand alias suggestions (with distance, not exact) + for _, c := range cmd.Commands() { + if c.IsAvailableCommand() { + for _, alias := range c.Aliases { + levenshteinDistance := levenshteinDistance(typedName, alias, true) + suggestByLevenshtein := levenshteinDistance <= cmd.SuggestionsMinimumDistance + suggestByPrefix := strings.HasPrefix(strings.ToLower(alias), strings.ToLower(typedName)) + + if suggestByLevenshtein || suggestByPrefix { + suggestions = append(suggestions, alias) + } + } + } + } + + // help for root command + if !cmd.HasParent() { + help := "help" + levenshteinDistance := levenshteinDistance(typedName, help, true) + suggestByLevenshtein := levenshteinDistance <= cmd.SuggestionsMinimumDistance + suggestByPrefix := strings.HasPrefix(strings.ToLower(help), strings.ToLower(typedName)) + + if suggestByLevenshtein || suggestByPrefix { + suggestions = append(suggestions, help) + } + } + } + + var suggestionsMsg string + if len(suggestions) > 0 { + suggestionsMsg += "\n\nDid you mean this?\n" + for _, s := range suggestions { + suggestionsMsg += fmt.Sprintf("\t%v\n", s) + } + } + + if len(suggestionsMsg) > 0 { + requireMsg = fmt.Sprintf("%s. %s", requireMsg, suggestionsMsg) + } + + return fmt.Errorf(requireMsg, cmd.Name()) +} + +// levenshteinDistance compares two strings and returns the levenshtein distance between them. +func levenshteinDistance(s, t string, ignoreCase bool) int { + if ignoreCase { + s = strings.ToLower(s) + t = strings.ToLower(t) + } + + d := make([][]int, len(s)+1) + for i := range d { + d[i] = make([]int, len(t)+1) + } + + for i := range d { + d[i][0] = i + } + + for j := range d[0] { + d[0][j] = j + } + + for j := 1; j <= len(t); j++ { + for i := 1; i <= len(s); i++ { + if s[i-1] == t[j-1] { + d[i][j] = d[i-1][j-1] + } else { + min := d[i-1][j] + if d[i][j-1] < min { + min = d[i][j-1] + } + + if d[i-1][j-1] < min { + min = d[i-1][j-1] + } + + d[i][j] = min + 1 + } + } + } + + return d[len(s)][len(t)] +} diff --git a/src/pkg/logger/logger.go b/src/pkg/logger/logger.go index 00a0b34e3..8b916d569 100644 --- a/src/pkg/logger/logger.go +++ b/src/pkg/logger/logger.go @@ -101,6 +101,16 @@ func AddLoggingFlags(cmd *cobra.Command) { addFlags(fs, "corso-.log") + cobra.CheckErr(cmd.RegisterFlagCompletionFunc(LogLevelFN, cobra.FixedCompletions( + []string{string(LLDebug), string(LLInfo), string(LLWarn), string(LLError), string(LLDisabled)}, + cobra.ShellCompDirectiveNoFileComp))) + cobra.CheckErr(cmd.RegisterFlagCompletionFunc(LogFormatFN, cobra.FixedCompletions( + []string{string(LFText), string(LFJSON)}, + cobra.ShellCompDirectiveNoFileComp))) + cobra.CheckErr(cmd.RegisterFlagCompletionFunc(MaskSensitiveDataFN, cobra.FixedCompletions( + []string{string(PIIPlainText), string(PIIMask), string(PIIHash)}, + cobra.ShellCompDirectiveNoFileComp))) + //nolint:errcheck fs.MarkHidden(ReadableLogsFN) }