Files
coder/cli/root.go
Bryan c9c03123eb fix: Run expect tests on Windows with conpty pseudo-terminal (#276)
This brings together a bunch of random, partially implemented packages for support of the new(ish) Windows [`conpty`](https://devblogs.microsoft.com/commandline/windows-command-line-introducing-the-windows-pseudo-console-conpty/) API - such that we can leverage the `expect` style of CLI tests, but in a way that works in Linux/OSX `pty`s and Windows `conpty`.

These include:
- Vendoring the `go-expect` library from Netflix w/ some tweaks to work cross-platform
- Vendoring the `pty` cross-platform implementation from [waypoint-plugin-sdk](b55c787a65/internal/pkg/pty)
- Vendoring the `conpty` Windows-specific implementation from [waypoint-plugin-sdk](b55c787a65/internal/pkg/conpty)
- Adjusting the `pty` interface to work with `go-expect` + the cross-plat version

There were several limitations with the current packages:
- `go-expect` requires the same `os.File` (TTY) for input / output, but `conhost` requires separate file handles
- `conpty` does not handle input, only output
- The cross-platform `pty` didn't expose the full set of primitives needed for `console`

Therefore, the following changes were made:
- Handling of `stdin` was added to the `conpty` interface
- We weren't using the full extent of the `go-expect` interface, so some portions were removed (ie, exec'ing a process) to simplify our implementation and make it easier to extend cross-platform
- Instead of `console` exposing just a `Tty`, it exposes an `InTty` and `OutTty`, to help encapsulate the difference on Windows (on Linux, these point to the same pipe)

Future improvements:
- The `isatty` implementation doesn't support accurate detection of `conhost` pty's without an associated process. In lieu of a more robust check, I've added a `--force-tty` flag intended for test case use - that forces the CLI to run in tty mode.
- It seems the windows implementation doesn't support setting a deadline. This is needed for the expect.Timeout API, but isn't used by us yet.

Fixes #241
2022-02-14 17:05:40 -08:00

202 lines
5.8 KiB
Go

package cli
import (
"fmt"
"io"
"net/url"
"os"
"strings"
"github.com/fatih/color"
"github.com/kirsle/configdir"
"github.com/manifoldco/promptui"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/config"
"github.com/coder/coder/coderd"
"github.com/coder/coder/codersdk"
)
const (
varGlobalConfig = "global-config"
varForceTty = "force-tty"
)
func Root() *cobra.Command {
cmd := &cobra.Command{
Use: "coder",
Long: ` ▄█▀ ▀█▄
▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█
▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀
█▌ ▄▌ ▐█ █▌ ▀█▄▄▄█▌ █ █ ▐█ ██ ██▀▀ █
██████▀▄█ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀
` + color.New(color.Underline).Sprint("Self-hosted developer workspaces on your infra") + `
`,
Example: `
- Create a project for developers to create workspaces
` + color.New(color.FgHiMagenta).Sprint("$ coder projects create <directory>") + `
- Create a workspace for a specific project
` + color.New(color.FgHiMagenta).Sprint("$ coder workspaces create <project>") + `
- Maintain consistency by updating a workspace
` + color.New(color.FgHiMagenta).Sprint("$ coder workspaces update <workspace>"),
}
// Customizes the color of headings to make subcommands
// more visually appealing.
header := color.New(color.FgHiBlack)
cmd.SetUsageTemplate(strings.NewReplacer(
`Usage:`, header.Sprint("Usage:"),
`Examples:`, header.Sprint("Examples:"),
`Available Commands:`, header.Sprint("Commands:"),
`Global Flags:`, header.Sprint("Global Flags:"),
`Flags:`, header.Sprint("Flags:"),
`Additional help topics:`, header.Sprint("Additional help:"),
).Replace(cmd.UsageTemplate()))
cmd.AddCommand(login())
cmd.AddCommand(projects())
cmd.AddCommand(workspaces())
cmd.AddCommand(users())
cmd.PersistentFlags().String(varGlobalConfig, configdir.LocalConfig("coder"), "Path to the global `coder` config directory")
cmd.PersistentFlags().Bool(varForceTty, false, "Force the `coder` command to run as if connected to a TTY")
err := cmd.PersistentFlags().MarkHidden(varForceTty)
if err != nil {
// This should never return an error, because we just added the `--force-tty`` flag prior to calling MarkHidden.
panic(err)
}
return cmd
}
// createClient returns a new client from the command context.
// The configuration directory will be read from the global flag.
func createClient(cmd *cobra.Command) (*codersdk.Client, error) {
root := createConfig(cmd)
rawURL, err := root.URL().Read()
if err != nil {
return nil, err
}
serverURL, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
token, err := root.Session().Read()
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) (coderd.Organization, error) {
orgs, err := client.UserOrganizations(cmd.Context(), "me")
if err != nil {
return coderd.Organization{}, nil
}
// For now, we won't use the config to set this.
// Eventually, we will support changing using "coder switch <org>"
return orgs[0], nil
}
// 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
}
reader := cmd.InOrStdin()
file, ok := reader.(*os.File)
if !ok {
return false
}
return isatty.IsTerminal(file.Fd())
}
func prompt(cmd *cobra.Command, prompt *promptui.Prompt) (string, error) {
var ok bool
prompt.Stdin, ok = cmd.InOrStdin().(io.ReadCloser)
if !ok {
return "", xerrors.New("stdin must be a readcloser")
}
prompt.Stdout, ok = cmd.OutOrStdout().(io.WriteCloser)
if !ok {
return "", xerrors.New("stdout must be a readcloser")
}
// The prompt library displays defaults in a jarring way for the user
// by attempting to autocomplete it. This sets no default enabling us
// to customize the display.
defaultValue := prompt.Default
if !prompt.IsConfirm {
prompt.Default = ""
}
// Rewrite the confirm template to remove bold, and fit to the Coder style.
confirmEnd := fmt.Sprintf("[y/%s] ", color.New(color.Bold).Sprint("N"))
if prompt.Default == "y" {
confirmEnd = fmt.Sprintf("[%s/n] ", color.New(color.Bold).Sprint("Y"))
}
confirm := color.HiBlackString("?") + ` {{ . }} ` + confirmEnd
// Customize to remove bold.
valid := color.HiBlackString("?") + " {{ . }} "
if defaultValue != "" {
valid += fmt.Sprintf("(%s) ", defaultValue)
}
success := valid
invalid := valid
if prompt.IsConfirm {
success = confirm
invalid = confirm
}
prompt.Templates = &promptui.PromptTemplates{
Confirm: confirm,
Success: success,
Invalid: invalid,
Valid: valid,
}
oldValidate := prompt.Validate
if oldValidate != nil {
// Override the validate function to pass our default!
prompt.Validate = func(s string) error {
if s == "" {
s = defaultValue
}
return oldValidate(s)
}
}
value, err := prompt.Run()
if value == "" && !prompt.IsConfirm {
value = defaultValue
}
return value, err
}