diff --git a/ci/integration/integration_test.go b/ci/integration/integration_test.go index dc921f29..2ee1b139 100644 --- a/ci/integration/integration_test.go +++ b/ci/integration/integration_test.go @@ -34,22 +34,25 @@ func TestCoderCLI(t *testing.T) { tcli.StderrEmpty(), ) - c.Run(ctx, "coder version").Assert(t, + c.Run(ctx, "coder --version").Assert(t, tcli.StderrEmpty(), tcli.Success(), tcli.StdoutMatches("linux"), ) - c.Run(ctx, "coder help").Assert(t, + c.Run(ctx, "coder --help").Assert(t, tcli.Success(), - tcli.StderrMatches("Commands:"), - tcli.StderrMatches("Usage: coder"), - tcli.StdoutEmpty(), + tcli.StdoutMatches("COMMANDS:"), + tcli.StdoutMatches("USAGE:"), ) headlessLogin(ctx, t, c) c.Run(ctx, "coder envs").Assert(t, + tcli.Error(), + ) + + c.Run(ctx, "coder envs ls").Assert(t, tcli.Success(), ) @@ -66,14 +69,14 @@ func TestCoderCLI(t *testing.T) { ) var user entclient.User - c.Run(ctx, `coder users ls -o json | jq -c '.[] | select( .username == "charlie")'`).Assert(t, + c.Run(ctx, `coder users ls --output json | jq -c '.[] | select( .username == "charlie")'`).Assert(t, tcli.Success(), stdoutUnmarshalsJSON(&user), ) assert.Equal(t, "user email is as expected", "charlie@coder.com", user.Email) assert.Equal(t, "username is as expected", "Charlie", user.Name) - c.Run(ctx, "coder users ls -o human | grep charlie").Assert(t, + c.Run(ctx, "coder users ls --output human | grep charlie").Assert(t, tcli.Success(), tcli.StdoutMatches("charlie"), ) @@ -82,7 +85,7 @@ func TestCoderCLI(t *testing.T) { tcli.Success(), ) - c.Run(ctx, "coder envs").Assert(t, + c.Run(ctx, "coder envs ls").Assert(t, tcli.Error(), ) } diff --git a/ci/integration/secrets_test.go b/ci/integration/secrets_test.go index b77c345b..58ba2404 100644 --- a/ci/integration/secrets_test.go +++ b/ci/integration/secrets_test.go @@ -36,7 +36,6 @@ func TestSecrets(t *testing.T) { c.Run(ctx, "coder secrets create").Assert(t, tcli.Error(), - tcli.StdoutEmpty(), ) // this tests the "Value:" prompt fallback diff --git a/cmd/coder/configssh.go b/cmd/coder/configssh.go index ff751322..20a9257a 100644 --- a/cmd/coder/configssh.go +++ b/cmd/coder/configssh.go @@ -11,41 +11,44 @@ import ( "strings" "time" - "github.com/spf13/pflag" - "go.coder.com/cli" - "go.coder.com/flog" - "cdr.dev/coder-cli/internal/config" "cdr.dev/coder-cli/internal/entclient" -) + "github.com/urfave/cli" -var ( - privateKeyFilepath = filepath.Join(os.Getenv("HOME"), ".ssh", "coder_enterprise") + "go.coder.com/flog" ) -type configSSHCmd struct { - filepath string - remove bool - - startToken, startMessage, endToken string -} - -func (cmd *configSSHCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "config-ssh", - Usage: "", - Desc: "add your Coder Enterprise environments to ~/.ssh/config", +func makeConfigSSHCmd() cli.Command { + var ( + configpath string + remove = false + ) + + return cli.Command{ + Name: "config-ssh", + Usage: "Configure SSH to access Coder environments", + Description: "Inject the proper OpenSSH configuration into your local SSH config file.", + Action: configSSH(&configpath, &remove), + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "filepath", + Usage: "overide the default path of your ssh config file", + Value: filepath.Join(os.Getenv("HOME"), ".ssh", "config"), + TakesFile: true, + Destination: &configpath, + }, + cli.BoolFlag{ + Name: "remove", + Usage: "remove the auto-generated Coder Enterprise ssh config", + Destination: &remove, + }, + }, } } -func (cmd *configSSHCmd) RegisterFlags(fl *pflag.FlagSet) { - fl.BoolVar(&cmd.remove, "remove", false, "remove the auto-generated Coder Enterprise ssh config") - home := os.Getenv("HOME") - defaultPath := filepath.Join(home, ".ssh", "config") - fl.StringVar(&cmd.filepath, "config-path", defaultPath, "overide the default path of your ssh config file") - - cmd.startToken = "# ------------START-CODER-ENTERPRISE-----------" - cmd.startMessage = `# The following has been auto-generated by "coder config-ssh" +func configSSH(filepath *string, remove *bool) func(c *cli.Context) { + startToken := "# ------------START-CODER-ENTERPRISE-----------" + startMessage := `# The following has been auto-generated by "coder config-ssh" # to make accessing your Coder Enterprise environments easier. # # To remove this blob, run: @@ -53,120 +56,120 @@ func (cmd *configSSHCmd) RegisterFlags(fl *pflag.FlagSet) { # coder config-ssh --remove # # You should not hand-edit this section, unless you are deleting it.` - cmd.endToken = "# ------------END-CODER-ENTERPRISE------------" -} - -func (cmd *configSSHCmd) Run(fl *pflag.FlagSet) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + endToken := "# ------------END-CODER-ENTERPRISE------------" + + return func(c *cli.Context) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + currentConfig, err := readStr(*filepath) + if os.IsNotExist(err) { + // SSH configs are not always already there. + currentConfig = "" + } else if err != nil { + flog.Fatal("failed to read ssh config file %q: %v", filepath, err) + } - currentConfig, err := readStr(cmd.filepath) - if os.IsNotExist(err) { - // SSH configs are not always already there. - currentConfig = "" - } else if err != nil { - flog.Fatal("failed to read ssh config file %q: %v", cmd.filepath, err) - } + startIndex := strings.Index(currentConfig, startToken) + endIndex := strings.Index(currentConfig, endToken) - startIndex := strings.Index(currentConfig, cmd.startToken) - endIndex := strings.Index(currentConfig, cmd.endToken) + if *remove { + if startIndex == -1 || endIndex == -1 { + flog.Fatal("the Coder Enterprise ssh configuration section could not be safely deleted or does not exist") + } + currentConfig = currentConfig[:startIndex-1] + currentConfig[endIndex+len(endToken)+1:] - if cmd.remove { - if startIndex == -1 || endIndex == -1 { - flog.Fatal("the Coder Enterprise ssh configuration section could not be safely deleted or does not exist") - } - currentConfig = currentConfig[:startIndex-1] + currentConfig[endIndex+len(cmd.endToken)+1:] + err = writeStr(*filepath, currentConfig) + if err != nil { + flog.Fatal("failed to write to ssh config file %q: %v", *filepath, err) + } - err = writeStr(cmd.filepath, currentConfig) - if err != nil { - flog.Fatal("failed to write to ssh config file %q: %v", cmd.filepath, err) + return } - return - } + entClient := requireAuth() - entClient := requireAuth() + sshAvailable := isSSHAvailable(ctx) + if !sshAvailable { + flog.Fatal("SSH is disabled or not available for your Coder Enterprise deployment.") + } - sshAvailable := cmd.ensureSSHAvailable(ctx) - if !sshAvailable { - flog.Fatal("SSH is disabled or not available for your Coder Enterprise deployment.") - } + me, err := entClient.Me() + if err != nil { + flog.Fatal("failed to fetch username: %v", err) + } - me, err := entClient.Me() - if err != nil { - flog.Fatal("failed to fetch username: %v", err) - } + envs := getEnvs(entClient) + if len(envs) < 1 { + flog.Fatal("no environments found") + } + newConfig, err := makeNewConfigs(me.Username, envs, startToken, startMessage, endToken) + if err != nil { + flog.Fatal("failed to make new ssh configurations: %v", err) + } - envs := getEnvs(entClient) - if len(envs) < 1 { - flog.Fatal("no environments found") - } - newConfig, err := cmd.makeNewConfigs(me.Username, envs) - if err != nil { - flog.Fatal("failed to make new ssh configurations: %v", err) - } + // if we find the old config, remove those chars from the string + if startIndex != -1 && endIndex != -1 { + currentConfig = currentConfig[:startIndex-1] + currentConfig[endIndex+len(endToken)+1:] + } - // if we find the old config, remove those chars from the string - if startIndex != -1 && endIndex != -1 { - currentConfig = currentConfig[:startIndex-1] + currentConfig[endIndex+len(cmd.endToken)+1:] - } + err = writeStr(*filepath, currentConfig+newConfig) + if err != nil { + flog.Fatal("failed to write new configurations to ssh config file %q: %v", filepath, err) + } + err = writeSSHKey(ctx, entClient) + if err != nil { + flog.Fatal("failed to fetch and write ssh key: %v", err) + } - err = writeStr(cmd.filepath, currentConfig+newConfig) - if err != nil { - flog.Fatal("failed to write new configurations to ssh config file %q: %v", cmd.filepath, err) - } - err = writeSSHKey(ctx, entClient) - if err != nil { - flog.Fatal("failed to fetch and write ssh key: %v", err) + fmt.Printf("An auto-generated ssh config was written to %q\n", *filepath) + fmt.Printf("Your private ssh key was written to %q\n", privateKeyFilepath) + fmt.Println("You should now be able to ssh into your environment") + fmt.Printf("For example, try running\n\n\t$ ssh coder.%s\n\n", envs[0].Name) } - - fmt.Printf("An auto-generated ssh config was written to %q\n", cmd.filepath) - fmt.Printf("Your private ssh key was written to %q\n", privateKeyFilepath) - fmt.Println("You should now be able to ssh into your environment") - fmt.Printf("For example, try running\n\n\t$ ssh coder.%s\n\n", envs[0].Name) } +var ( + privateKeyFilepath = filepath.Join(os.Getenv("HOME"), ".ssh", "coder_enterprise") +) + func writeSSHKey(ctx context.Context, client *entclient.Client) error { key, err := client.SSHKey() if err != nil { return err } - err = ioutil.WriteFile(privateKeyFilepath, []byte(key.PrivateKey), 0400) - if err != nil { - return err - } - return nil + return ioutil.WriteFile(privateKeyFilepath, []byte(key.PrivateKey), 0400) } -func (cmd *configSSHCmd) makeNewConfigs(userName string, envs []entclient.Environment) (string, error) { +func makeNewConfigs(userName string, envs []entclient.Environment, startToken, startMsg, endToken string) (string, error) { hostname, err := configuredHostname() if err != nil { return "", nil } - newConfig := fmt.Sprintf("\n%s\n%s\n\n", cmd.startToken, cmd.startMessage) + newConfig := fmt.Sprintf("\n%s\n%s\n\n", startToken, startMsg) for _, env := range envs { - newConfig += cmd.makeConfig(hostname, userName, env.Name) + newConfig += makeSSHConfig(hostname, userName, env.Name) } - newConfig += fmt.Sprintf("\n%s\n", cmd.endToken) + newConfig += fmt.Sprintf("\n%s\n", endToken) return newConfig, nil } -func (cmd *configSSHCmd) makeConfig(host, userName, envName string) string { +func makeSSHConfig(host, userName, envName string) string { return fmt.Sprintf( `Host coder.%s - HostName %s - User %s-%s - StrictHostKeyChecking no - ConnectTimeout=0 - IdentityFile=%s - ServerAliveInterval 60 - ServerAliveCountMax 3 + HostName %s + User %s-%s + StrictHostKeyChecking no + ConnectTimeout=0 + IdentityFile=%s + ServerAliveInterval 60 + ServerAliveCountMax 3 `, envName, host, userName, envName, privateKeyFilepath) } -func (cmd *configSSHCmd) ensureSSHAvailable(ctx context.Context) bool { +func isSSHAvailable(ctx context.Context) bool { ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() diff --git a/cmd/coder/envs.go b/cmd/coder/envs.go index 9e45df1f..c3eeba5f 100644 --- a/cmd/coder/envs.go +++ b/cmd/coder/envs.go @@ -1,29 +1,54 @@ package main import ( - "fmt" + "encoding/json" + "os" - "github.com/spf13/pflag" + "cdr.dev/coder-cli/internal/x/xtabwriter" + "github.com/urfave/cli" - "go.coder.com/cli" + "go.coder.com/flog" ) -type envsCmd struct { -} - -func (cmd envsCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "envs", - Desc: "get a list of environments owned by the authenticated user", - } -} - -func (cmd envsCmd) Run(fl *pflag.FlagSet) { - entClient := requireAuth() - - envs := getEnvs(entClient) - - for _, env := range envs { - fmt.Println(env.Name) +func makeEnvsCommand() cli.Command { + var outputFmt string + return cli.Command{ + Name: "envs", + Usage: "Interact with Coder environments", + Description: "Perform operations on the Coder environments owned by the active user.", + Action: exitHelp, + Subcommands: []cli.Command{ + { + Name: "ls", + Usage: "list all environments owned by the active user", + Description: "List all Coder environments owned by the active user.", + ArgsUsage: "[...flags]>", + Action: func(c *cli.Context) { + entClient := requireAuth() + envs := getEnvs(entClient) + + switch outputFmt { + case "human": + err := xtabwriter.WriteTable(len(envs), func(i int) interface{} { + return envs[i] + }) + requireSuccess(err, "failed to write table: %v", err) + case "json": + err := json.NewEncoder(os.Stdout).Encode(envs) + requireSuccess(err, "failed to write json: %v", err) + default: + flog.Fatal("unknown --output value %q", outputFmt) + } + }, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "output", + Usage: "json | human", + Value: "human", + Destination: &outputFmt, + }, + }, + }, + }, } } diff --git a/cmd/coder/login.go b/cmd/coder/login.go index fef1a38b..7064d03c 100644 --- a/cmd/coder/login.go +++ b/cmd/coder/login.go @@ -7,30 +7,27 @@ import ( "strings" "sync" + "cdr.dev/coder-cli/internal/config" + "cdr.dev/coder-cli/internal/loginsrv" "github.com/pkg/browser" - "github.com/spf13/pflag" + "github.com/urfave/cli" - "go.coder.com/cli" "go.coder.com/flog" - - "cdr.dev/coder-cli/internal/config" - "cdr.dev/coder-cli/internal/loginsrv" ) -type loginCmd struct { -} - -func (cmd loginCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "login", - Usage: "[Coder Enterprise URL eg. http://my.coder.domain/ ]", - Desc: "authenticate this client for future operations", +func makeLoginCmd() cli.Command { + return cli.Command{ + Name: "login", + Usage: "Authenticate this client for future operations", + ArgsUsage: "[Coder Enterprise URL eg. http://my.coder.domain/]", + Action: login, } } -func (cmd loginCmd) Run(fl *pflag.FlagSet) { - rawURL := fl.Arg(0) + +func login(c *cli.Context) { + rawURL := c.Args().First() if rawURL == "" || !strings.HasPrefix(rawURL, "http") { - exitUsage(fl) + flog.Fatal("invalid URL") } u, err := url.Parse(rawURL) diff --git a/cmd/coder/logout.go b/cmd/coder/logout.go index 6120f527..6bae109d 100644 --- a/cmd/coder/logout.go +++ b/cmd/coder/logout.go @@ -3,25 +3,21 @@ package main import ( "os" - "github.com/spf13/pflag" + "cdr.dev/coder-cli/internal/config" + "github.com/urfave/cli" - "go.coder.com/cli" "go.coder.com/flog" - - "cdr.dev/coder-cli/internal/config" ) -type logoutCmd struct { -} - -func (cmd logoutCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "logout", - Desc: "remove local authentication credentials (if any)", +func makeLogoutCmd() cli.Command { + return cli.Command{ + Name: "logout", + Usage: "Remove local authentication credentials if any exist", + Action: logout, } } -func (cmd logoutCmd) Run(_ *pflag.FlagSet) { +func logout(c *cli.Context) { err := config.Session.Delete() if err != nil { if os.IsNotExist(err) { diff --git a/cmd/coder/main.go b/cmd/coder/main.go index 5680d30d..bbbdcd9f 100644 --- a/cmd/coder/main.go +++ b/cmd/coder/main.go @@ -1,52 +1,23 @@ package main import ( + "fmt" "log" "net/http" _ "net/http/pprof" "os" + "runtime" "cdr.dev/coder-cli/internal/x/xterminal" - "github.com/spf13/pflag" + "github.com/urfave/cli" "go.coder.com/flog" - - "go.coder.com/cli" ) var ( - version string = "No version built" + version string = "unknown" ) -type rootCmd struct{} - -func (r *rootCmd) Run(fl *pflag.FlagSet) { - fl.Usage() -} - -func (r *rootCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "coder", - Usage: "[subcommand] [flags]", - Desc: "coder provides a CLI for working with an existing Coder Enterprise installation.", - } -} - -func (r *rootCmd) Subcommands() []cli.Command { - return []cli.Command{ - &envsCmd{}, - &loginCmd{}, - &logoutCmd{}, - &shellCmd{}, - &syncCmd{}, - &urlsCmd{}, - &versionCmd{}, - &configSSHCmd{}, - &usersCmd{}, - &secretsCmd{}, - } -} - func main() { if os.Getenv("PPROF") != "" { go func() { @@ -60,7 +31,31 @@ func main() { } defer xterminal.Restore(os.Stdout.Fd(), stdoutState) - cli.RunRoot(&rootCmd{}) + app := cli.NewApp() + app.Name = "coder" + app.Usage = "coder provides a CLI for working with an existing Coder Enterprise installation" + app.Version = fmt.Sprintf("%s %s %s/%s", version, runtime.Version(), runtime.GOOS, runtime.GOARCH) + app.Author = "Coder Technologies Inc." + app.CommandNotFound = func(c *cli.Context, s string) { + flog.Fatal("command %q not found", s) + } + app.Email = "support@coder.com" + app.Action = exitHelp + + app.Commands = []cli.Command{ + makeLoginCmd(), + makeLogoutCmd(), + makeShellCmd(), + makeUsersCmd(), + makeConfigSSHCmd(), + makeSecretsCmd(), + makeEnvsCommand(), + makeSyncCmd(), + } + err = app.Run(os.Args) + if err != nil { + flog.Fatal("%v", err) + } } // requireSuccess prints the given message and format args as a fatal error if err != nil @@ -69,3 +64,7 @@ func requireSuccess(err error, msg string, args ...interface{}) { flog.Fatal(msg, args...) } } + +func exitHelp(c *cli.Context) { + cli.ShowCommandHelpAndExit(c, c.Command.FullName(), 1) +} diff --git a/cmd/coder/secrets.go b/cmd/coder/secrets.go index a07df2de..fd6d222b 100644 --- a/cmd/coder/secrets.go +++ b/cmd/coder/secrets.go @@ -7,60 +7,132 @@ import ( "cdr.dev/coder-cli/internal/entclient" "cdr.dev/coder-cli/internal/x/xtabwriter" - "cdr.dev/coder-cli/internal/x/xvalidate" "github.com/manifoldco/promptui" - "github.com/spf13/pflag" + "github.com/urfave/cli" "golang.org/x/xerrors" "go.coder.com/flog" - - "go.coder.com/cli" -) - -var ( - _ cli.FlaggedCommand = secretsCmd{} - _ cli.ParentCommand = secretsCmd{} - - _ cli.FlaggedCommand = &listSecretsCmd{} - _ cli.FlaggedCommand = &createSecretCmd{} ) -type secretsCmd struct { -} - -func (cmd secretsCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "secrets", - Usage: "[subcommand]", - Desc: "interact with secrets", - } -} - -func (cmd secretsCmd) Run(fl *pflag.FlagSet) { - exitUsage(fl) -} - -func (cmd secretsCmd) RegisterFlags(fl *pflag.FlagSet) {} - -func (cmd secretsCmd) Subcommands() []cli.Command { - return []cli.Command{ - &listSecretsCmd{}, - &viewSecretsCmd{}, - &createSecretCmd{}, - &deleteSecretsCmd{}, +func makeSecretsCmd() cli.Command { + return cli.Command{ + Name: "secrets", + Usage: "Interact with Coder Secrets", + Description: "Interact with secrets objects owned by the active user.", + Action: exitHelp, + Subcommands: []cli.Command{ + { + Name: "ls", + Usage: "List all secrets owned by the active user", + Action: listSecrets, + }, + makeCreateSecret(), + { + Name: "rm", + Usage: "Remove one or more secrets by name", + ArgsUsage: "[...secret_name]", + Action: removeSecrets, + }, + { + Name: "view", + Usage: "View a secret by name", + ArgsUsage: "[secret_name]", + Action: viewSecret, + }, + }, } } -type listSecretsCmd struct{} +func makeCreateSecret() cli.Command { + var ( + fromFile string + fromLiteral string + fromPrompt bool + description string + ) -func (cmd *listSecretsCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "ls", - Desc: "list all secrets", + return cli.Command{ + Name: "create", + Usage: "Create a new secret", + Description: "Create a new secret object to store application secrets and access them securely from within your environments.", + ArgsUsage: "[secret_name]", + Before: func(c *cli.Context) error { + if c.Args().First() == "" { + return xerrors.Errorf("[secret_name] is a required argument") + } + if fromPrompt && (fromLiteral != "" || fromFile != "") { + return xerrors.Errorf("--from-prompt cannot be set along with --from-file or --from-literal") + } + if fromLiteral != "" && fromFile != "" { + return xerrors.Errorf("--from-literal and --from-file cannot both be set") + } + if !fromPrompt && fromFile == "" && fromLiteral == "" { + return xerrors.Errorf("one of [--from-literal, --from-file, --from-prompt] is required") + } + return nil + }, + Action: func(c *cli.Context) { + var ( + client = requireAuth() + name = c.Args().First() + value string + err error + ) + if fromLiteral != "" { + value = fromLiteral + } else if fromFile != "" { + contents, err := ioutil.ReadFile(fromFile) + requireSuccess(err, "failed to read file: %v", err) + value = string(contents) + } else { + prompt := promptui.Prompt{ + Label: "value", + Mask: '*', + Validate: func(s string) error { + if len(s) < 1 { + return xerrors.Errorf("a length > 0 is required") + } + return nil + }, + } + value, err = prompt.Run() + requireSuccess(err, "failed to prompt for value: %v", err) + } + + err = client.InsertSecret(entclient.InsertSecretReq{ + Name: name, + Value: value, + Description: description, + }) + requireSuccess(err, "failed to insert secret: %v", err) + }, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "from-file", + Usage: "a file from which to read the value of the secret", + TakesFile: true, + Destination: &fromFile, + }, + cli.StringFlag{ + Name: "from-literal", + Usage: "the value of the secret", + Destination: &fromLiteral, + }, + cli.BoolFlag{ + Name: "from-prompt", + Usage: "enter the secret value through a terminal prompt", + Destination: &fromPrompt, + }, + cli.StringFlag{ + Name: "description", + Usage: "a description of the secret", + Destination: &description, + }, + }, } } -func (cmd *listSecretsCmd) Run(fl *pflag.FlagSet) { +func listSecrets(_ *cli.Context) { client := requireAuth() secrets, err := client.Secrets() @@ -84,25 +156,13 @@ func (cmd *listSecretsCmd) Run(fl *pflag.FlagSet) { requireSuccess(err, "failed to flush writer: %v", err) } -func (cmd *listSecretsCmd) RegisterFlags(fl *pflag.FlagSet) {} - -type viewSecretsCmd struct{} - -func (cmd viewSecretsCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "view", - Usage: "[secret_name]", - Desc: "view a secret", - } -} - -func (cmd viewSecretsCmd) Run(fl *pflag.FlagSet) { +func viewSecret(c *cli.Context) { var ( client = requireAuth() - name = fl.Arg(0) + name = c.Args().First() ) if name == "" { - exitUsage(fl) + flog.Fatal("[name] is a required argument") } secret, err := client.SecretByName(name) @@ -112,103 +172,26 @@ func (cmd viewSecretsCmd) Run(fl *pflag.FlagSet) { requireSuccess(err, "failed to write: %v", err) } -type createSecretCmd struct { - description string - fromFile string - fromLiteral string - fromPrompt bool -} - -func (cmd *createSecretCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "create", - Usage: `[secret_name] [...flags]`, - Desc: "create a new secret", - } -} - -func (cmd *createSecretCmd) Validate(fl *pflag.FlagSet) (e []error) { - if cmd.fromPrompt && (cmd.fromLiteral != "" || cmd.fromFile != "") { - e = append(e, xerrors.Errorf("--from-prompt cannot be set along with --from-file or --from-literal")) - } - if cmd.fromLiteral != "" && cmd.fromFile != "" { - e = append(e, xerrors.Errorf("--from-literal and --from-file cannot both be set")) - } - if !cmd.fromPrompt && cmd.fromFile == "" && cmd.fromLiteral == "" { - e = append(e, xerrors.Errorf("one of [--from-literal, --from-file, --from-prompt] is required")) - } - return e -} - -func (cmd *createSecretCmd) Run(fl *pflag.FlagSet) { +func removeSecrets(c *cli.Context) { var ( client = requireAuth() - name = fl.Arg(0) - value string - err error + names = append([]string{c.Args().First()}, c.Args().Tail()...) ) - if name == "" { - exitUsage(fl) - } - xvalidate.Validate(fl, cmd) - - if cmd.fromLiteral != "" { - value = cmd.fromLiteral - } else if cmd.fromFile != "" { - contents, err := ioutil.ReadFile(cmd.fromFile) - requireSuccess(err, "failed to read file: %v", err) - value = string(contents) - } else { - prompt := promptui.Prompt{ - Label: "value", - Mask: '*', - Validate: func(s string) error { - if len(s) < 1 { - return xerrors.Errorf("a length > 0 is required") - } - return nil - }, - } - value, err = prompt.Run() - requireSuccess(err, "failed to prompt for value: %v", err) + if len(names) < 1 || names[0] == "" { + flog.Fatal("[...secret_name] is a required argument") } - err = client.InsertSecret(entclient.InsertSecretReq{ - Name: name, - Value: value, - Description: cmd.description, - }) - requireSuccess(err, "failed to insert secret: %v", err) -} - -func (cmd *createSecretCmd) RegisterFlags(fl *pflag.FlagSet) { - fl.StringVar(&cmd.fromFile, "from-file", "", "specify a file from which to read the value of the secret") - fl.StringVar(&cmd.fromLiteral, "from-literal", "", "specify the value of the secret") - fl.BoolVar(&cmd.fromPrompt, "from-prompt", false, "specify the value of the secret through a prompt") - fl.StringVar(&cmd.description, "description", "", "specify a description of the secret") -} - -type deleteSecretsCmd struct{} - -func (cmd *deleteSecretsCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "rm", - Usage: "[secret_name]", - Desc: "remove a secret", + errorSeen := false + for _, n := range names { + err := client.DeleteSecretByName(n) + if err != nil { + flog.Error("failed to delete secret: %v", err) + errorSeen = true + } else { + flog.Info("Successfully deleted secret %q", n) + } } -} - -func (cmd *deleteSecretsCmd) Run(fl *pflag.FlagSet) { - var ( - client = requireAuth() - name = fl.Arg(0) - ) - if name == "" { - exitUsage(fl) + if errorSeen { + os.Exit(1) } - - err := client.DeleteSecretByName(name) - requireSuccess(err, "failed to delete secret: %v", err) - - flog.Info("Successfully deleted secret %q", name) } diff --git a/cmd/coder/shell.go b/cmd/coder/shell.go index c7b42564..c52ea656 100644 --- a/cmd/coder/shell.go +++ b/cmd/coder/shell.go @@ -7,48 +7,40 @@ import ( "strings" "time" - "github.com/spf13/pflag" + "cdr.dev/coder-cli/internal/activity" + "cdr.dev/coder-cli/internal/x/xterminal" + "cdr.dev/wsep" + "github.com/urfave/cli" "golang.org/x/crypto/ssh/terminal" "golang.org/x/time/rate" "golang.org/x/xerrors" "nhooyr.io/websocket" - "go.coder.com/cli" "go.coder.com/flog" - - "cdr.dev/coder-cli/internal/activity" - "cdr.dev/coder-cli/internal/x/xterminal" - "cdr.dev/wsep" ) -type shellCmd struct{} - -func (cmd *shellCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "sh", - Usage: " []", - Desc: "execute a remote command on the environment\nIf no command is specified, the default shell is opened.", - RawArgs: true, +func makeShellCmd() cli.Command { + return cli.Command{ + Name: "sh", + Usage: "Open a shell and execute commands in a Coder environment", + Description: "Execute a remote command on the environment\\nIf no command is specified, the default shell is opened.", + ArgsUsage: "[env_name] []", + SkipFlagParsing: true, + SkipArgReorder: true, + Action: shell, } } -type resizeEvent struct { - height, width uint16 -} - -func (cmd *shellCmd) Run(fl *pflag.FlagSet) { - if len(fl.Args()) < 1 { - exitUsage(fl) - } +func shell(c *cli.Context) { var ( - envName = fl.Arg(0) + envName = c.Args().First() ctx = context.Background() ) command := "sh" args := []string{"-c"} - if len(fl.Args()) > 1 { - args = append(args, strings.Join(fl.Args()[1:], " ")) + if len(c.Args().Tail()) > 0 { + args = append(args, strings.Join(c.Args().Tail(), " ")) } else { // Bring user into shell if no command is specified. args = append(args, "exec $(getent passwd $(whoami) | awk -F: '{ print $7 }')") diff --git a/cmd/coder/sync.go b/cmd/coder/sync.go index 601fddd7..7f2c1f39 100644 --- a/cmd/coder/sync.go +++ b/cmd/coder/sync.go @@ -8,30 +8,36 @@ import ( "path/filepath" "strings" - "github.com/spf13/pflag" + "cdr.dev/coder-cli/internal/sync" + "github.com/urfave/cli" + "golang.org/x/xerrors" - "go.coder.com/cli" "go.coder.com/flog" - - "cdr.dev/coder-cli/internal/sync" ) -type syncCmd struct { - init bool -} - -func (cmd *syncCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "sync", - Usage: "[local directory] [:]", - Desc: "establish a one way directory sync to a remote environment", +func makeSyncCmd() cli.Command { + var init bool + return cli.Command{ + Name: "sync", + Usage: "Establish a one way directory sync to a Coder environment", + ArgsUsage: "[local directory] [:]", + Before: func(c *cli.Context) error { + if c.Args().Get(0) == "" || c.Args().Get(1) == "" { + return xerrors.Errorf("[local] and [remote] arguments are required") + } + return nil + }, + Action: makeRunSync(&init), + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "init", + Usage: "do initial transfer and exit", + Destination: &init, + }, + }, } } -func (cmd *syncCmd) RegisterFlags(fl *pflag.FlagSet) { - fl.BoolVarP(&cmd.init, "init", "i", false, "do initial transfer and exit") -} - // version returns local rsync protocol version as a string. func rsyncVersion() string { cmd := exec.Command("rsync", "--version") @@ -49,63 +55,61 @@ func rsyncVersion() string { return versionString[1] } -func (cmd *syncCmd) Run(fl *pflag.FlagSet) { - var ( - local = fl.Arg(0) - remote = fl.Arg(1) - ) - if local == "" || remote == "" { - exitUsage(fl) - } - - entClient := requireAuth() - - info, err := os.Stat(local) - if err != nil { - flog.Fatal("%v", err) - } - if !info.IsDir() { - flog.Fatal("%s must be a directory", local) - } - - remoteTokens := strings.SplitN(remote, ":", 2) - if len(remoteTokens) != 2 { - flog.Fatal("remote misformatted") - } - var ( - envName = remoteTokens[0] - remoteDir = remoteTokens[1] - ) - - env := findEnv(entClient, envName) - - absLocal, err := filepath.Abs(local) - if err != nil { - flog.Fatal("make abs path out of %v: %v", local, absLocal) - } - - s := sync.Sync{ - Init: cmd.init, - Env: env, - RemoteDir: remoteDir, - LocalDir: absLocal, - Client: entClient, - } - - localVersion := rsyncVersion() - remoteVersion, rsyncErr := s.Version() - - if rsyncErr != nil { - flog.Info("Unable to determine remote rsync version. Proceeding cautiously.") - } else if localVersion != remoteVersion { - flog.Fatal("rsync protocol mismatch: local = %v, remote = %v", localVersion, rsyncErr) - } - - for err == nil || err == sync.ErrRestartSync { - err = s.Run() - } - - if err != nil { - flog.Fatal("%v", err) +func makeRunSync(init *bool) func(c *cli.Context) { + return func(c *cli.Context) { + var ( + local = c.Args().Get(0) + remote = c.Args().Get(1) + ) + + entClient := requireAuth() + + info, err := os.Stat(local) + if err != nil { + flog.Fatal("%v", err) + } + if !info.IsDir() { + flog.Fatal("%s must be a directory", local) + } + + remoteTokens := strings.SplitN(remote, ":", 2) + if len(remoteTokens) != 2 { + flog.Fatal("remote misformatted") + } + var ( + envName = remoteTokens[0] + remoteDir = remoteTokens[1] + ) + + env := findEnv(entClient, envName) + + absLocal, err := filepath.Abs(local) + if err != nil { + flog.Fatal("make abs path out of %v: %v", local, absLocal) + } + + s := sync.Sync{ + Init: *init, + Env: env, + RemoteDir: remoteDir, + LocalDir: absLocal, + Client: entClient, + } + + localVersion := rsyncVersion() + remoteVersion, rsyncErr := s.Version() + + if rsyncErr != nil { + flog.Info("Unable to determine remote rsync version. Proceeding cautiously.") + } else if localVersion != remoteVersion { + flog.Fatal("rsync protocol mismatch: local = %v, remote = %v", localVersion, rsyncErr) + } + + for err == nil || err == sync.ErrRestartSync { + err = s.Run() + } + if err != nil { + flog.Fatal("%v", err) + } } } diff --git a/cmd/coder/users.go b/cmd/coder/users.go index e050edfb..dcf8c0d4 100644 --- a/cmd/coder/users.go +++ b/cmd/coder/users.go @@ -2,84 +2,56 @@ package main import ( "encoding/json" - "fmt" "os" "cdr.dev/coder-cli/internal/x/xtabwriter" - "cdr.dev/coder-cli/internal/x/xvalidate" - "github.com/spf13/pflag" + "github.com/urfave/cli" - "go.coder.com/cli" + "go.coder.com/flog" ) -type usersCmd struct { -} - -func (cmd usersCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "users", - Usage: "[subcommand] ", - Desc: "interact with user accounts", - } -} - -func (cmd usersCmd) Run(fl *pflag.FlagSet) { - exitUsage(fl) -} - -func (cmd *usersCmd) Subcommands() []cli.Command { - return []cli.Command{ - &listCmd{}, +func makeUsersCmd() cli.Command { + var output string + return cli.Command{ + Name: "users", + Usage: "Interact with Coder user accounts", + Action: exitHelp, + Subcommands: []cli.Command{ + { + Name: "ls", + Usage: "list all user accounts", + Action: listUsers(&output), + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "output", + Usage: "json | human", + Value: "human", + Destination: &output, + }, + }, + }, + }, } } -type listCmd struct { - outputFmt string -} - -func (cmd *listCmd) Run(fl *pflag.FlagSet) { - xvalidate.Validate(fl, cmd) - entClient := requireAuth() - - users, err := entClient.Users() - requireSuccess(err, "failed to get users: %v", err) - - switch cmd.outputFmt { - case "human": - w := xtabwriter.NewWriter() - if len(users) > 0 { - _, err = fmt.Fprintln(w, xtabwriter.StructFieldNames(users[0])) - requireSuccess(err, "failed to write: %v", err) - } - for _, u := range users { - _, err = fmt.Fprintln(w, xtabwriter.StructValues(u)) - requireSuccess(err, "failed to write: %v", err) +func listUsers(outputFmt *string) func(c *cli.Context) { + return func(c *cli.Context) { + entClient := requireAuth() + + users, err := entClient.Users() + requireSuccess(err, "failed to get users: %v", err) + + switch *outputFmt { + case "human": + err := xtabwriter.WriteTable(len(users), func(i int) interface{} { + return users[i] + }) + requireSuccess(err, "failed to write table: %v", err) + case "json": + err = json.NewEncoder(os.Stdout).Encode(users) + requireSuccess(err, "failed to encode users to json: %v", err) + default: + flog.Fatal("unknown value for --output") } - err = w.Flush() - requireSuccess(err, "failed to flush writer: %v", err) - case "json": - err = json.NewEncoder(os.Stdout).Encode(users) - requireSuccess(err, "failed to encode users to json: %v", err) - default: - exitUsage(fl) - } -} - -func (cmd *listCmd) RegisterFlags(fl *pflag.FlagSet) { - fl.StringVarP(&cmd.outputFmt, "output", "o", "human", "output format (human | json)") -} - -func (cmd *listCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "ls", - Usage: "", - Desc: "list all users", - } -} - -func (cmd *listCmd) Validate(fl *pflag.FlagSet) (e []error) { - if !(cmd.outputFmt == "json" || cmd.outputFmt == "human") { - e = append(e, fmt.Errorf(`--output must be "json" or "human"`)) } - return e } diff --git a/cmd/coder/version.go b/cmd/coder/version.go deleted file mode 100644 index a1825843..00000000 --- a/cmd/coder/version.go +++ /dev/null @@ -1,28 +0,0 @@ -package main - -import ( - "fmt" - "runtime" - - "github.com/spf13/pflag" - - "go.coder.com/cli" -) - -type versionCmd struct{} - -func (versionCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "version", - Usage: "", - Desc: "print the currently installed CLI version", - } -} - -func (versionCmd) Run(fl *pflag.FlagSet) { - fmt.Println( - version, - runtime.Version(), - runtime.GOOS+"/"+runtime.GOARCH, - ) -} diff --git a/go.mod b/go.mod index 7318d3f6..0ce94e91 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 github.com/rjeczalik/notify v0.9.2 github.com/spf13/pflag v1.0.5 + github.com/urfave/cli v1.22.4 go.coder.com/cli v0.4.0 go.coder.com/flog v0.0.0-20190906214207-47dd47ea0512 golang.org/x/crypto v0.0.0-20200422194213-44a606286825 diff --git a/go.sum b/go.sum index cba75741..3866f35e 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= @@ -165,8 +167,12 @@ github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1: github.com/rjeczalik/notify v0.9.2 h1:MiTWrPj55mNDHEiIX5YUSKefw/+lCQVoAFmD6oQm5w8= github.com/rjeczalik/notify v0.9.2/go.mod h1:aErll2f0sUX9PXZnVNyeiObbmTlk5jnMoCa4QEjJeqM= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -179,6 +185,8 @@ github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/urfave/cli v1.22.4 h1:u7tSpNPPswAFymm8IehJhy4uJMlUuU/GmqSkvJ1InXA= +github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= go.coder.com/cli v0.4.0 h1:PruDGwm/CPFndyK/eMowZG3vzg5CgohRWeXWCTr3zi8= diff --git a/internal/entclient/env.go b/internal/entclient/env.go index 11a806c5..508f438f 100644 --- a/internal/entclient/env.go +++ b/internal/entclient/env.go @@ -4,13 +4,33 @@ import ( "context" "time" + "cdr.dev/coder-cli/internal/x/xjson" "nhooyr.io/websocket" ) // Environment describes a Coder environment type Environment struct { - Name string `json:"name"` - ID string `json:"id"` + ID string `json:"id" tab:"-"` + Name string `json:"name"` + ImageID string `json:"image_id" tab:"-"` + ImageTag string `json:"image_tag"` + OrganizationID string `json:"organization_id" tab:"-"` + UserID string `json:"user_id" tab:"-"` + LastBuiltAt time.Time `json:"last_built_at" tab:"-"` + CPUCores float32 `json:"cpu_cores"` + MemoryGB int `json:"memory_gb"` + DiskGB int `json:"disk_gb"` + GPUs int `json:"gpus"` + Updating bool `json:"updating"` + RebuildMessages []struct { + Text string `json:"text"` + Required bool `json:"required"` + } `json:"rebuild_messages" tab:"-"` + CreatedAt time.Time `json:"created_at" tab:"-"` + UpdatedAt time.Time `json:"updated_at" tab:"-"` + LastOpenedAt time.Time `json:"last_opened_at" tab:"-"` + LastConnectionAt time.Time `json:"last_connection_at" tab:"-"` + AutoOffThreshold xjson.Duration `json:"auto_off_threshold" tab:"-"` } // Envs gets the list of environments owned by the authenticated user diff --git a/internal/entclient/me.go b/internal/entclient/me.go index 2b44debb..2d57d8cc 100644 --- a/internal/entclient/me.go +++ b/internal/entclient/me.go @@ -6,11 +6,12 @@ import ( // User describes a Coder user account type User struct { - ID string `json:"id"` + ID string `json:"id" tab:"-"` Email string `json:"email"` Username string `json:"username"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at" tab:"-"` } // Me gets the details of the authenticated user diff --git a/internal/entclient/secrets.go b/internal/entclient/secrets.go index 98fa543b..72a62105 100644 --- a/internal/entclient/secrets.go +++ b/internal/entclient/secrets.go @@ -7,12 +7,12 @@ import ( // Secret describes a Coder secret type Secret struct { - ID string `json:"id"` + ID string `json:"id" tab:"-"` Name string `json:"name"` Value string `json:"value,omitempty"` Description string `json:"description"` CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + UpdatedAt time.Time `json:"updated_at" tab:"-"` } // Secrets gets all secrets owned by the authed user diff --git a/internal/x/xjson/duration.go b/internal/x/xjson/duration.go new file mode 100644 index 00000000..3ec08b48 --- /dev/null +++ b/internal/x/xjson/duration.go @@ -0,0 +1,33 @@ +package xjson + +import ( + "encoding/json" + "strconv" + "time" +) + +// Duration is a time.Duration that marshals to millisecond precision. +// Most javascript applications expect durations to be in milliseconds. +type Duration time.Duration + +// MarshalJSON marshals the duration to millisecond precision. +func (d Duration) MarshalJSON() ([]byte, error) { + du := time.Duration(d) + return json.Marshal(du.Milliseconds()) +} + +// UnmarshalJSON unmarshals a millisecond-precision integer to +// a time.Duration. +func (d *Duration) UnmarshalJSON(b []byte) error { + i, err := strconv.ParseInt(string(b), 10, 64) + if err != nil { + return err + } + + *d = Duration(time.Duration(i) * time.Millisecond) + return nil +} + +func (d Duration) String() string { + return time.Duration(d).String() +} diff --git a/internal/x/xtabwriter/tabwriter.go b/internal/x/xtabwriter/tabwriter.go index 1c8b1167..1345cca7 100644 --- a/internal/x/xtabwriter/tabwriter.go +++ b/internal/x/xtabwriter/tabwriter.go @@ -8,27 +8,70 @@ import ( "text/tabwriter" ) -// NewWriter chooses reasonable defaults for a human readable output of tabular data +const structFieldTagKey = "tab" + +// NewWriter chooses reasonable defaults for a human readable output of tabular data. func NewWriter() *tabwriter.Writer { return tabwriter.NewWriter(os.Stdout, 0, 0, 4, ' ', 0) } -// StructValues tab delimits the values of a given struct +// StructValues tab delimits the values of a given struct. +// +// Tag a field `tab:"-"` to hide it from output. func StructValues(data interface{}) string { v := reflect.ValueOf(data) s := &strings.Builder{} for i := 0; i < v.NumField(); i++ { - s.WriteString(fmt.Sprintf("%s\t", v.Field(i).Interface())) + if shouldHideField(v.Type().Field(i)) { + continue + } + s.WriteString(fmt.Sprintf("%v\t", v.Field(i).Interface())) } return s.String() } -// StructFieldNames tab delimits the field names of a given struct +// StructFieldNames tab delimits the field names of a given struct. +// +// Tag a field `tab:"-"` to hide it from output. func StructFieldNames(data interface{}) string { v := reflect.ValueOf(data) s := &strings.Builder{} for i := 0; i < v.NumField(); i++ { - s.WriteString(fmt.Sprintf("%s\t", v.Type().Field(i).Name)) + field := v.Type().Field(i) + if shouldHideField(field) { + continue + } + s.WriteString(fmt.Sprintf("%s\t", field.Name)) } return s.String() } + +// WriteTable writes the given list elements to stdout in a human readable +// tabular format. Headers abide by the `tab` struct tag. +// +// `tab:"-"` omits the field and no tag defaults to the Go identifier. +func WriteTable(length int, each func(i int) interface{}) error { + if length < 1 { + return nil + } + w := NewWriter() + defer w.Flush() + for ix := 0; ix < length; ix++ { + item := each(ix) + if ix == 0 { + _, err := fmt.Fprintln(w, StructFieldNames(item)) + if err != nil { + return err + } + } + _, err := fmt.Fprintln(w, StructValues(item)) + if err != nil { + return err + } + } + return nil +} + +func shouldHideField(f reflect.StructField) bool { + return f.Tag.Get(structFieldTagKey) == "-" +} diff --git a/internal/x/xvalidate/errors.go b/internal/x/xvalidate/errors.go deleted file mode 100644 index d502850c..00000000 --- a/internal/x/xvalidate/errors.go +++ /dev/null @@ -1,101 +0,0 @@ -package xvalidate - -import ( - "bytes" - "fmt" - - "github.com/spf13/pflag" - - "go.coder.com/flog" -) - -// cerrors contains a list of errors. -type cerrors struct { - cerrors []error -} - -func (e cerrors) writeTo(buf *bytes.Buffer) { - for i, err := range e.cerrors { - if err == nil { - continue - } - buf.WriteString(err.Error()) - // don't newline after last error - if i != len(e.cerrors)-1 { - buf.WriteRune('\n') - } - } -} - -func (e cerrors) Error() string { - buf := &bytes.Buffer{} - e.writeTo(buf) - return buf.String() -} - -// stripNils removes nil errors from the slice. -func stripNils(errs []error) []error { - // We can't range since errs may be resized - // during the loop. - for i := 0; i < len(errs); i++ { - err := errs[i] - if err == nil { - // shift down - copy(errs[i:], errs[i+1:]) - // pop off last element - errs = errs[:len(errs)-1] - } - } - return errs -} - -// flatten expands all parts of cerrors onto errs. -func flatten(errs []error) []error { - nerrs := make([]error, 0, len(errs)) - for _, err := range errs { - errs, ok := err.(cerrors) - if !ok { - nerrs = append(nerrs, err) - continue - } - nerrs = append(nerrs, errs.cerrors...) - } - return nerrs -} - -// combineErrors combines multiple errors into one -func combineErrors(errs ...error) error { - errs = stripNils(errs) - switch len(errs) { - case 0: - return nil - case 1: - return errs[0] - default: - // Don't return if all of the errors of nil. - for _, err := range errs { - if err != nil { - return cerrors{cerrors: flatten(errs)} - } - } - return nil - } -} - -// Validator is a command capable of validating its flags -type Validator interface { - Validate(fl *pflag.FlagSet) []error -} - -// Validate performs validation and exits with a nonzero status code if validation fails. -// The proper errors are printed to stderr. -func Validate(fl *pflag.FlagSet, v Validator) { - errs := v.Validate(fl) - - err := combineErrors(errs...) - if err != nil { - fl.Usage() - fmt.Println("") - flog.Fatal("failed to validate this command\n%v", err) - } -}