mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user