package cli import ( "fmt" "net/url" "os" "strconv" "strings" "time" "golang.org/x/xerrors" "github.com/charmbracelet/lipgloss" "github.com/kirsle/configdir" "github.com/mattn/go-isatty" "github.com/spf13/cobra" "github.com/coder/coder/buildinfo" "github.com/coder/coder/cli/cliflag" "github.com/coder/coder/cli/cliui" "github.com/coder/coder/cli/config" "github.com/coder/coder/codersdk" ) var ( caret = cliui.Styles.Prompt.String() // Applied as annotations to workspace commands // so they display in a separated "help" section. workspaceCommand = map[string]string{ "workspaces": " ", } ) const ( varURL = "url" varToken = "token" varAgentToken = "agent-token" varAgentURL = "agent-url" varGlobalConfig = "global-config" varNoOpen = "no-open" varForceTty = "force-tty" notLoggedInMessage = "You are not logged in. Try logging in using 'coder login '." noVersionCheckFlag = "no-version-warning" envNoVersionCheck = "CODER_NO_VERSION_WARNING" ) var ( errUnauthenticated = xerrors.New(notLoggedInMessage) envSessionToken = "CODER_SESSION_TOKEN" ) func init() { // Customizes the color of headings to make subcommands more visually // appealing. header := cliui.Styles.Placeholder cobra.AddTemplateFunc("usageHeader", func(s string) string { return header.Render(s) }) } func Root() *cobra.Command { var varSuppressVersion bool cmd := &cobra.Command{ Use: "coder", SilenceErrors: true, SilenceUsage: true, Long: `Coder — A tool for provisioning self-hosted development environments. `, PersistentPreRun: func(cmd *cobra.Command, args []string) { err := func() error { if varSuppressVersion { return nil } // Login handles checking the versions itself since it // has a handle to an unauthenticated client. // Server is skipped for obvious reasons. if cmd.Name() == "login" || cmd.Name() == "server" || cmd.Name() == "gitssh" { return nil } client, err := createClient(cmd) // If the client is unauthenticated we can ignore the check. // The child commands should handle an unauthenticated client. if xerrors.Is(err, errUnauthenticated) { return nil } if err != nil { return xerrors.Errorf("create client: %w", err) } return checkVersions(cmd, client) }() if err != nil { // Just log the error here. We never want to fail a command // due to a pre-run. _, _ = fmt.Fprintf(cmd.ErrOrStderr(), cliui.Styles.Warn.Render("check versions error: %s"), err) _, _ = fmt.Fprintln(cmd.ErrOrStderr()) } }, Example: ` Start a Coder server. ` + cliui.Styles.Code.Render("$ coder server") + ` Get started by creating a template from an example. ` + cliui.Styles.Code.Render("$ coder templates init"), } cmd.AddCommand( configSSH(), create(), deleteWorkspace(), dotfiles(), gitssh(), list(), login(), logout(), parameters(), portForward(), publickey(), resetPassword(), schedules(), server(), show(), ssh(), start(), state(), stop(), templates(), update(), users(), versionCmd(), wireguardPortForward(), workspaceAgent(), ) cmd.SetUsageTemplate(usageTemplate()) cmd.PersistentFlags().String(varURL, "", "Specify the URL to your deployment.") cliflag.BoolVarP(cmd.PersistentFlags(), &varSuppressVersion, noVersionCheckFlag, "", envNoVersionCheck, false, "Suppress warning when client and server versions do not match.") cliflag.String(cmd.PersistentFlags(), varToken, "", envSessionToken, "", fmt.Sprintf("Specify an authentication token. For security reasons setting %s is preferred.", envSessionToken)) cliflag.String(cmd.PersistentFlags(), varAgentToken, "", "CODER_AGENT_TOKEN", "", "Specify an agent authentication token.") _ = cmd.PersistentFlags().MarkHidden(varAgentToken) cliflag.String(cmd.PersistentFlags(), varAgentURL, "", "CODER_AGENT_URL", "", "Specify the URL for an agent to access your deployment.") _ = cmd.PersistentFlags().MarkHidden(varAgentURL) cliflag.String(cmd.PersistentFlags(), varGlobalConfig, "", "CODER_CONFIG_DIR", configdir.LocalConfig("coderv2"), "Specify the path to the global `coder` config directory.") cmd.PersistentFlags().Bool(varForceTty, false, "Force the `coder` command to run as if connected to a TTY.") _ = cmd.PersistentFlags().MarkHidden(varForceTty) cmd.PersistentFlags().Bool(varNoOpen, false, "Block automatically opening URLs in the browser.") _ = cmd.PersistentFlags().MarkHidden(varNoOpen) return cmd } // versionCmd prints the coder version func versionCmd() *cobra.Command { return &cobra.Command{ Use: "version", Short: "Show coder version", Example: "coder version", RunE: func(cmd *cobra.Command, args []string) error { var str strings.Builder _, _ = str.WriteString(fmt.Sprintf("Coder %s", buildinfo.Version())) buildTime, valid := buildinfo.Time() if valid { _, _ = str.WriteString(" " + buildTime.Format(time.UnixDate)) } _, _ = str.WriteString("\r\n" + buildinfo.ExternalURL() + "\r\n") _, _ = fmt.Fprint(cmd.OutOrStdout(), str.String()) return nil }, } } // createClient returns a new client from the command context. // It reads from global configuration files if flags are not set. func createClient(cmd *cobra.Command) (*codersdk.Client, error) { root := createConfig(cmd) rawURL, err := cmd.Flags().GetString(varURL) if err != nil || rawURL == "" { rawURL, err = root.URL().Read() if err != nil { // If the configuration files are absent, the user is logged out if os.IsNotExist(err) { return nil, errUnauthenticated } return nil, err } } serverURL, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil { return nil, err } token, err := cmd.Flags().GetString(varToken) if err != nil || token == "" { token, err = root.Session().Read() if err != nil { // If the configuration files are absent, the user is logged out if os.IsNotExist(err) { return nil, errUnauthenticated } return nil, err } } client := codersdk.New(serverURL) client.SessionToken = strings.TrimSpace(token) return client, nil } // createAgentClient returns a new client from the command context. // It works just like createClient, but uses the agent token and URL instead. func createAgentClient(cmd *cobra.Command) (*codersdk.Client, error) { rawURL, err := cmd.Flags().GetString(varAgentURL) if err != nil { return nil, err } serverURL, err := url.Parse(rawURL) if err != nil { return nil, err } token, err := cmd.Flags().GetString(varAgentToken) if err != nil { return nil, err } client := codersdk.New(serverURL) client.SessionToken = token return client, nil } // currentOrganization returns the currently active organization for the authenticated user. func currentOrganization(cmd *cobra.Command, client *codersdk.Client) (codersdk.Organization, error) { orgs, err := client.OrganizationsByUser(cmd.Context(), codersdk.Me) if err != nil { return codersdk.Organization{}, nil } // For now, we won't use the config to set this. // Eventually, we will support changing using "coder switch " return orgs[0], nil } // namedWorkspace fetches and returns a workspace by an identifier, which may be either // a bare name (for a workspace owned by the current user) or a "user/workspace" combination, // where user is either a username or UUID. func namedWorkspace(cmd *cobra.Command, client *codersdk.Client, identifier string) (codersdk.Workspace, error) { parts := strings.Split(identifier, "/") var owner, name string switch len(parts) { case 1: owner = codersdk.Me name = parts[0] case 2: owner = parts[0] name = parts[1] default: return codersdk.Workspace{}, xerrors.Errorf("invalid workspace name: %q", identifier) } return client.WorkspaceByOwnerAndName(cmd.Context(), owner, name, codersdk.WorkspaceOptions{}) } // createConfig consumes the global configuration flag to produce a config root. func createConfig(cmd *cobra.Command) config.Root { globalRoot, err := cmd.Flags().GetString(varGlobalConfig) if err != nil { panic(err) } return config.Root(globalRoot) } // isTTY returns whether the passed reader is a TTY or not. // This accepts a reader to work with Cobra's "InOrStdin" // function for simple testing. func isTTY(cmd *cobra.Command) bool { // If the `--force-tty` command is available, and set, // assume we're in a tty. This is primarily for cases on Windows // where we may not be able to reliably detect this automatically (ie, tests) forceTty, err := cmd.Flags().GetBool(varForceTty) if forceTty && err == nil { return true } file, ok := cmd.InOrStdin().(*os.File) if !ok { return false } return isatty.IsTerminal(file.Fd()) } // isTTYOut returns whether the passed reader is a TTY or not. // This accepts a reader to work with Cobra's "OutOrStdout" // function for simple testing. func isTTYOut(cmd *cobra.Command) bool { // If the `--force-tty` command is available, and set, // assume we're in a tty. This is primarily for cases on Windows // where we may not be able to reliably detect this automatically (ie, tests) forceTty, err := cmd.Flags().GetBool(varForceTty) if forceTty && err == nil { return true } file, ok := cmd.OutOrStdout().(*os.File) if !ok { return false } return isatty.IsTerminal(file.Fd()) } func usageTemplate() string { // usageHeader is defined in init(). return `{{usageHeader "Usage:"}} {{- if .Runnable}} {{.UseLine}} {{end}} {{- if .HasAvailableSubCommands}} {{.CommandPath}} [command] {{end}} {{- if gt (len .Aliases) 0}} {{usageHeader "Aliases:"}} {{.NameAndAliases}} {{end}} {{- if .HasExample}} {{usageHeader "Get Started:"}} {{.Example}} {{end}} {{- if .HasAvailableSubCommands}} {{usageHeader "Commands:"}} {{- range .Commands}} {{- if (or (and .IsAvailableCommand (eq (len .Annotations) 0)) (eq .Name "help"))}} {{rpad .Name .NamePadding }} {{.Short}} {{- end}} {{- end}} {{end}} {{- if and (not .HasParent) .HasAvailableSubCommands}} {{usageHeader "Workspace Commands:"}} {{- range .Commands}} {{- if (and .IsAvailableCommand (ne (index .Annotations "workspaces") ""))}} {{rpad .Name .NamePadding }} {{.Short}} {{- end}} {{- end}} {{end}} {{- if .HasAvailableLocalFlags}} {{usageHeader "Flags:"}} {{.LocalFlags.FlagUsagesWrapped 100}} {{end}} {{- if .HasAvailableInheritedFlags}} {{usageHeader "Global Flags:"}} {{.InheritedFlags.FlagUsagesWrapped 100}} {{end}} {{- if .HasHelpSubCommands}} {{usageHeader "Additional help topics:"}} {{- range .Commands}} {{- if .IsAdditionalHelpTopicCommand}} {{rpad .CommandPath .CommandPathPadding}} {{.Short}} {{- end}} {{- end}} {{end}} {{- if .HasAvailableSubCommands}} Use "{{.CommandPath}} [command] --help" for more information about a command. {{end}}` } // FormatCobraError colorizes and adds "--help" docs to cobra commands. func FormatCobraError(err error, cmd *cobra.Command) string { helpErrMsg := fmt.Sprintf("Run '%s --help' for usage.", cmd.CommandPath()) return cliui.Styles.Error.Render(err.Error() + "\n" + helpErrMsg) } func checkVersions(cmd *cobra.Command, client *codersdk.Client) error { flag := cmd.Flag("no-version-warning") if suppress, _ := strconv.ParseBool(flag.Value.String()); suppress { return nil } clientVersion := buildinfo.Version() info, err := client.BuildInfo(cmd.Context()) if err != nil { return xerrors.Errorf("build info: %w", err) } fmtWarningText := `version mismatch: client %s, server %s download the server version with: 'curl -L https://coder.com/install.sh | sh -s -- --version %s' ` if !buildinfo.VersionsMatch(clientVersion, info.Version) { warn := cliui.Styles.Warn.Copy().Align(lipgloss.Left) // Trim the leading 'v', our install.sh script does not handle this case well. _, _ = fmt.Fprintf(cmd.OutOrStdout(), warn.Render(fmtWarningText), clientVersion, info.Version, strings.TrimPrefix(info.CanonicalVersion(), "v")) _, _ = fmt.Fprintln(cmd.OutOrStdout()) } return nil }