feat: Add templates to create working release (#422)

* Add templates

* Move API structs to codersdk

* Back to green tests!

* It all works, but now with tea! 🧋

* It works!

* Add cancellation to provisionerd

* Tests pass!

* Add deletion of workspaces and projects

* Fix agent lock

* Add clog

* Fix linting errors

* Remove unused CLI tests

* Rename daemon to start

* Fix leaking command

* Fix promptui test

* Update agent connection frequency

* Skip login tests on Windows

* Increase tunnel connect timeout

* Fix templater

* Lower test requirements

* Fix embed

* Disable promptui tests for Windows

* Fix write newline

* Fix PTY write newline

* Fix CloseReader

* Fix compilation on Windows

* Fix linting error

* Remove bubbletea

* Cleanup readwriter

* Use embedded templates instead of serving over API

* Move templates to examples

* Improve workspace create flow

* Fix Windows build

* Fix tests

* Fix linting errors

* Fix untar with extracting max size

* Fix newline char
This commit is contained in:
Kyle Carberry
2022-03-22 13:17:50 -06:00
committed by GitHub
parent 2818b3ce6d
commit c451f4e685
138 changed files with 7314 additions and 2331 deletions
+50
View File
@@ -0,0 +1,50 @@
package cliui
import (
"github.com/charmbracelet/charm/ui/common"
"github.com/charmbracelet/lipgloss"
"golang.org/x/xerrors"
)
var (
Canceled = xerrors.New("canceled")
defaultStyles = common.DefaultStyles()
)
// ValidateNotEmpty is a helper function to disallow empty inputs!
func ValidateNotEmpty(s string) error {
if s == "" {
return xerrors.New("Must be provided!")
}
return nil
}
// Styles compose visual elements of the UI!
var Styles = struct {
Bold,
Code,
Field,
Keyword,
Paragraph,
Placeholder,
Prompt,
FocusedPrompt,
Fuschia,
Logo,
Warn,
Wrap lipgloss.Style
}{
Bold: lipgloss.NewStyle().Bold(true),
Code: defaultStyles.Code,
Field: defaultStyles.Code.Copy().Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}),
Keyword: defaultStyles.Keyword,
Paragraph: defaultStyles.Paragraph,
Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
Prompt: defaultStyles.Prompt.Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}),
FocusedPrompt: defaultStyles.FocusedPrompt.Foreground(lipgloss.Color("#651fff")),
Fuschia: defaultStyles.SelectedMenuItem.Copy(),
Logo: defaultStyles.Logo.SetString("Coder"),
Warn: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}),
Wrap: defaultStyles.Wrap,
}
+150
View File
@@ -0,0 +1,150 @@
package cliui
import (
"fmt"
"os"
"os/signal"
"time"
"github.com/briandowns/spinner"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
)
type JobOptions struct {
Title string
Fetch func() (codersdk.ProvisionerJob, error)
Cancel func() error
Logs func() (<-chan codersdk.ProvisionerJobLog, error)
}
// Job renders a provisioner job.
func Job(cmd *cobra.Command, opts JobOptions) (codersdk.ProvisionerJob, error) {
var (
spin = spinner.New(spinner.CharSets[5], 100*time.Millisecond, spinner.WithColor("fgGreen"))
started = false
completed = false
job codersdk.ProvisionerJob
)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s%s %s\n", Styles.FocusedPrompt, opts.Title, Styles.Placeholder.Render("(ctrl+c to cancel)"))
spin.Writer = cmd.OutOrStdout()
defer spin.Stop()
// Refreshes the job state!
refresh := func() {
var err error
job, err = opts.Fetch()
if err != nil {
// If a single fetch fails, it could be a one-off.
return
}
if !started && job.StartedAt != nil {
spin.Stop()
_, _ = fmt.Fprintf(cmd.OutOrStdout(), Styles.Prompt.String()+"Started "+Styles.Placeholder.Render("[%dms]")+"\n", job.StartedAt.Sub(job.CreatedAt).Milliseconds())
spin.Start()
started = true
}
if !completed && job.CompletedAt != nil {
spin.Stop()
msg := ""
switch job.Status {
case codersdk.ProvisionerJobCanceled:
msg = "Canceled"
case codersdk.ProvisionerJobFailed:
msg = "Completed"
case codersdk.ProvisionerJobSucceeded:
msg = "Built"
}
started := job.CreatedAt
if job.StartedAt != nil {
started = *job.StartedAt
}
_, _ = fmt.Fprintf(cmd.OutOrStderr(), Styles.Prompt.String()+msg+" "+Styles.Placeholder.Render("[%dms]")+"\n", job.CompletedAt.Sub(started).Milliseconds())
spin.Start()
completed = true
}
switch job.Status {
case codersdk.ProvisionerJobPending:
spin.Suffix = " Queued"
case codersdk.ProvisionerJobRunning:
spin.Suffix = " Running"
case codersdk.ProvisionerJobCanceling:
spin.Suffix = " Canceling"
}
}
refresh()
spin.Start()
stopChan := make(chan os.Signal, 1)
defer signal.Stop(stopChan)
go func() {
signal.Notify(stopChan, os.Interrupt)
select {
case <-cmd.Context().Done():
return
case _, ok := <-stopChan:
if !ok {
return
}
}
spin.Stop()
_, _ = fmt.Fprintf(cmd.OutOrStdout(), Styles.FocusedPrompt.String()+"Gracefully canceling... wait for exit or data loss may occur!\n")
spin.Start()
err := opts.Cancel()
if err != nil {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Failed to cancel %s...\n", err)
}
refresh()
}()
logs, err := opts.Logs()
if err != nil {
return job, err
}
firstLog := false
ticker := time.NewTicker(time.Second)
for {
select {
case <-cmd.Context().Done():
return job, cmd.Context().Err()
case <-ticker.C:
refresh()
if job.CompletedAt != nil {
return job, nil
}
case log, ok := <-logs:
if !ok {
refresh()
continue
}
if !firstLog {
refresh()
firstLog = true
}
spin.Stop()
var style lipgloss.Style
switch log.Level {
case database.LogLevelTrace:
style = defaultStyles.Error
case database.LogLevelDebug:
style = defaultStyles.Error
case database.LogLevelError:
style = defaultStyles.Error
case database.LogLevelWarn:
style = Styles.Warn
case database.LogLevelInfo:
style = defaultStyles.Note
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s %s %s\n", Styles.Placeholder.Render("|"), style.Render(string(log.Level)), log.Output)
spin.Start()
}
}
}
+45
View File
@@ -0,0 +1,45 @@
package cliui
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/coder/coder/coderd/parameter"
"github.com/coder/coder/codersdk"
)
func ParameterSchema(cmd *cobra.Command, parameterSchema codersdk.ProjectVersionParameterSchema) (string, error) {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), Styles.Bold.Render("var."+parameterSchema.Name))
if parameterSchema.Description != "" {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+strings.TrimSpace(strings.Join(strings.Split(parameterSchema.Description, "\n"), "\n "))+"\n")
}
var err error
var options []string
if parameterSchema.ValidationCondition != "" {
options, _, err = parameter.Contains(parameterSchema.ValidationCondition)
if err != nil {
return "", err
}
}
var value string
if len(options) > 0 {
// Move the cursor up a single line for nicer display!
_, _ = fmt.Fprint(cmd.OutOrStdout(), "\033[1A")
value, err = Select(cmd, SelectOptions{
Options: options,
HideSearch: true,
})
if err == nil {
_, _ = fmt.Fprintln(cmd.OutOrStdout())
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Prompt.String()+Styles.Field.Render(value))
}
} else {
value, err = Prompt(cmd, PromptOptions{
Text: Styles.Bold.Render("Enter a value:"),
})
}
return value, err
}
+90
View File
@@ -0,0 +1,90 @@
package cliui
import (
"bufio"
"fmt"
"os"
"os/signal"
"strings"
"github.com/bgentry/speakeasy"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
)
// PromptOptions supply a set of options to the prompt.
type PromptOptions struct {
Text string
Default string
Secret bool
IsConfirm bool
Validate func(string) error
}
// Prompt asks the user for input.
func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.FocusedPrompt.String()+opts.Text+" ")
if opts.IsConfirm {
opts.Default = "yes"
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.Placeholder.Render("("+Styles.Bold.Render("yes")+Styles.Placeholder.Render("/no) ")))
} else if opts.Default != "" {
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.Placeholder.Render("("+opts.Default+") "))
}
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt)
defer signal.Stop(interrupt)
errCh := make(chan error)
lineCh := make(chan string)
go func() {
var line string
var err error
inFile, valid := cmd.InOrStdin().(*os.File)
if opts.Secret && valid && isatty.IsTerminal(inFile.Fd()) {
line, err = speakeasy.Ask("")
} else {
reader := bufio.NewReader(cmd.InOrStdin())
line, err = reader.ReadString('\n')
// Multiline with single quotes!
if err == nil && strings.HasPrefix(line, "'") {
rest, err := reader.ReadString('\'')
if err == nil {
line += rest
line = strings.Trim(line, "'")
}
}
}
if err != nil {
errCh <- err
return
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
line = opts.Default
}
lineCh <- line
}()
select {
case err := <-errCh:
return "", err
case line := <-lineCh:
if opts.IsConfirm && line != "yes" && line != "y" {
return line, Canceled
}
if opts.Validate != nil {
err := opts.Validate(line)
if err != nil {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), defaultStyles.Error.Render(err.Error()))
return Prompt(cmd, opts)
}
}
return line, nil
case <-cmd.Context().Done():
return "", cmd.Context().Err()
case <-interrupt:
// Print a newline so that any further output starts properly on a new line.
_, _ = fmt.Fprintln(cmd.OutOrStdout())
return "", Canceled
}
}
+84
View File
@@ -0,0 +1,84 @@
package cliui_test
import (
"context"
"runtime"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/pty/ptytest"
)
func TestPrompt(t *testing.T) {
t.Parallel()
t.Run("Success", func(t *testing.T) {
t.Parallel()
ptty := ptytest.New(t)
msgChan := make(chan string)
go func() {
resp, err := newPrompt(ptty, cliui.PromptOptions{
Text: "Example",
})
require.NoError(t, err)
msgChan <- resp
}()
ptty.ExpectMatch("Example")
ptty.WriteLine("hello")
require.Equal(t, "hello", <-msgChan)
})
t.Run("Confirm", func(t *testing.T) {
t.Parallel()
ptty := ptytest.New(t)
doneChan := make(chan string)
go func() {
resp, err := newPrompt(ptty, cliui.PromptOptions{
Text: "Example",
IsConfirm: true,
})
require.NoError(t, err)
doneChan <- resp
}()
ptty.ExpectMatch("Example")
ptty.WriteLine("yes")
require.Equal(t, "yes", <-doneChan)
})
t.Run("Multiline", func(t *testing.T) {
t.Parallel()
ptty := ptytest.New(t)
doneChan := make(chan string)
go func() {
resp, err := newPrompt(ptty, cliui.PromptOptions{
Text: "Example",
})
require.NoError(t, err)
doneChan <- resp
}()
ptty.ExpectMatch("Example")
ptty.WriteLine("'this is a")
ptty.WriteLine("test'")
newline := "\n"
if runtime.GOOS == "windows" {
newline = "\r\n"
}
require.Equal(t, "this is a"+newline+"test", <-doneChan)
})
}
func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions) (string, error) {
value := ""
cmd := &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
var err error
value, err = cliui.Prompt(cmd, opts)
return err
},
}
cmd.SetOutput(ptty.Output())
cmd.SetIn(ptty.Input())
return value, cmd.ExecuteContext(context.Background())
}
+73
View File
@@ -0,0 +1,73 @@
package cliui
import (
"errors"
"io"
"strings"
"text/template"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
)
type SelectOptions struct {
Options []string
Size int
HideSearch bool
}
// Select displays a list of user options.
func Select(cmd *cobra.Command, opts SelectOptions) (string, error) {
selector := promptui.Select{
Label: "",
Items: opts.Options,
Size: opts.Size,
Searcher: func(input string, index int) bool {
option := opts.Options[index]
name := strings.Replace(strings.ToLower(option), " ", "", -1)
input = strings.Replace(strings.ToLower(input), " ", "", -1)
return strings.Contains(name, input)
},
HideHelp: opts.HideSearch,
Stdin: io.NopCloser(cmd.InOrStdin()),
Stdout: &writeCloser{cmd.OutOrStdout()},
Templates: &promptui.SelectTemplates{
FuncMap: template.FuncMap{
"faint": func(value interface{}) string {
return Styles.Placeholder.Render(value.(string))
},
"subtle": func(value interface{}) string {
return defaultStyles.Subtle.Render(value.(string))
},
"selected": func(value interface{}) string {
return defaultStyles.Keyword.Render("> " + value.(string))
// return defaultStyles.SelectedMenuItem.Render("> " + value.(string))
},
},
Active: "{{ . | selected }}",
Inactive: " {{ . }}",
Label: "{{.}}",
Selected: "{{ \"\" }}",
Help: `{{ "Use" | faint }} {{ .SearchKey | faint }} {{ "to toggle search" | faint }}`,
},
HideSelected: true,
}
_, result, err := selector.Run()
if errors.Is(err, promptui.ErrAbort) || errors.Is(err, promptui.ErrInterrupt) {
return result, Canceled
}
if err != nil {
return result, err
}
return result, nil
}
type writeCloser struct {
io.Writer
}
func (*writeCloser) Close() error {
return nil
}
+47
View File
@@ -0,0 +1,47 @@
package cliui_test
import (
"context"
"testing"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/pty/ptytest"
)
func TestSelect(t *testing.T) {
t.Parallel()
t.Run("Select", func(t *testing.T) {
t.Parallel()
ptty := ptytest.New(t)
msgChan := make(chan string)
go func() {
resp, err := newSelect(ptty, cliui.SelectOptions{
Options: []string{"First", "Second"},
})
require.NoError(t, err)
msgChan <- resp
}()
ptty.ExpectMatch("Second")
ptty.Write(promptui.KeyNext)
ptty.WriteLine("")
require.Equal(t, "Second", <-msgChan)
})
}
func newSelect(ptty *ptytest.PTY, opts cliui.SelectOptions) (string, error) {
value := ""
cmd := &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
var err error
value, err = cliui.Select(cmd, opts)
return err
},
}
cmd.SetOutput(ptty.Output())
cmd.SetIn(ptty.Input())
return value, cmd.ExecuteContext(context.Background())
}