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 7317 additions and 2334 deletions

50
cli/cliui/cliui.go Normal file
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
cli/cliui/job.go Normal file
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
cli/cliui/parameter.go Normal file
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
cli/cliui/prompt.go Normal file
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
cli/cliui/prompt_test.go Normal file
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
cli/cliui/select.go Normal file
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
cli/cliui/select_test.go Normal file
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())
}

26
cli/configssh.go Normal file
View File

@ -0,0 +1,26 @@
package cli
import (
"github.com/spf13/cobra"
)
// const sshStartToken = "# ------------START-CODER-----------"
// const sshStartMessage = `# This was generated by "coder config-ssh".
// #
// # To remove this blob, run:
// #
// # coder config-ssh --remove
// #
// # You should not hand-edit this section, unless you are deleting it.`
// const sshEndToken = "# ------------END-CODER------------"
func configSSH() *cobra.Command {
cmd := &cobra.Command{
Use: "config-ssh",
RunE: func(cmd *cobra.Command, args []string) error {
return nil
},
}
return cmd
}

View File

@ -1,112 +0,0 @@
package cli
import (
"context"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
"os"
"time"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/coderd"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/database/databasefake"
"github.com/coder/coder/provisioner/terraform"
"github.com/coder/coder/provisionerd"
"github.com/coder/coder/provisionersdk"
"github.com/coder/coder/provisionersdk/proto"
)
func daemon() *cobra.Command {
var (
address string
)
root := &cobra.Command{
Use: "daemon",
RunE: func(cmd *cobra.Command, args []string) error {
logger := slog.Make(sloghuman.Sink(os.Stderr))
accessURL := &url.URL{
Scheme: "http",
Host: address,
}
handler, closeCoderd := coderd.New(&coderd.Options{
AccessURL: accessURL,
Logger: logger,
Database: databasefake.New(),
Pubsub: database.NewPubsubInMemory(),
})
listener, err := net.Listen("tcp", address)
if err != nil {
return xerrors.Errorf("listen %q: %w", address, err)
}
defer listener.Close()
logger.Info(cmd.Context(), "daemon started", slog.F("url", accessURL.String()))
client := codersdk.New(accessURL)
daemonClose, err := newProvisionerDaemon(cmd.Context(), client, logger)
if err != nil {
return xerrors.Errorf("create provisioner daemon: %w", err)
}
defer daemonClose.Close()
errCh := make(chan error)
go func() {
defer close(errCh)
errCh <- http.Serve(listener, handler)
}()
closeCoderd()
select {
case <-cmd.Context().Done():
return cmd.Context().Err()
case err := <-errCh:
return err
}
},
}
defaultAddress, ok := os.LookupEnv("ADDRESS")
if !ok {
defaultAddress = "127.0.0.1:3000"
}
root.Flags().StringVarP(&address, "address", "a", defaultAddress, "The address to serve the API and dashboard.")
return root
}
func newProvisionerDaemon(ctx context.Context, client *codersdk.Client, logger slog.Logger) (io.Closer, error) {
terraformClient, terraformServer := provisionersdk.TransportPipe()
go func() {
err := terraform.Serve(ctx, &terraform.ServeOptions{
ServeOptions: &provisionersdk.ServeOptions{
Listener: terraformServer,
},
Logger: logger,
})
if err != nil {
panic(err)
}
}()
tempDir, err := ioutil.TempDir("", "provisionerd")
if err != nil {
return nil, err
}
return provisionerd.New(client.ListenProvisionerDaemon, &provisionerd.Options{
Logger: logger,
PollInterval: 50 * time.Millisecond,
UpdateInterval: 50 * time.Millisecond,
Provisioners: provisionerd.Provisioners{
string(database.ProvisionerTypeTerraform): proto.NewDRPCProvisionerClient(provisionersdk.Conn(terraformClient)),
},
WorkDirectory: tempDir,
}), nil
}

View File

@ -1,19 +0,0 @@
package cli_test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/clitest"
)
func TestDaemon(t *testing.T) {
t.Parallel()
ctx, cancelFunc := context.WithCancel(context.Background())
go cancelFunc()
root, _ := clitest.New(t, "daemon")
err := root.ExecuteContext(ctx)
require.ErrorIs(t, err, context.Canceled)
}

View File

@ -1,6 +1,7 @@
package cli
import (
"errors"
"fmt"
"io/ioutil"
"net/url"
@ -9,14 +10,12 @@ import (
"runtime"
"strings"
"github.com/fatih/color"
"github.com/go-playground/validator/v10"
"github.com/manifoldco/promptui"
"github.com/pkg/browser"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)
@ -67,38 +66,36 @@ func login() *cobra.Command {
if !isTTY(cmd) {
return xerrors.New("the initial user cannot be created in non-interactive mode. use the API")
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Your Coder deployment hasn't been set up!\n", caret)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"Your Coder deployment hasn't been set up!\n")
_, err := prompt(cmd, &promptui.Prompt{
Label: "Would you like to create the first user?",
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
Text: "Would you like to create the first user?",
Default: "yes",
IsConfirm: true,
Default: "y",
})
if errors.Is(err, cliui.Canceled) {
return nil
}
if err != nil {
return xerrors.Errorf("create user prompt: %w", err)
return err
}
currentUser, err := user.Current()
if err != nil {
return xerrors.Errorf("get current user: %w", err)
}
username, err := prompt(cmd, &promptui.Prompt{
Label: "What username would you like?",
username, err := cliui.Prompt(cmd, cliui.PromptOptions{
Text: "What " + cliui.Styles.Field.Render("username") + " would you like?",
Default: currentUser.Username,
})
if errors.Is(err, cliui.Canceled) {
return nil
}
if err != nil {
return xerrors.Errorf("pick username prompt: %w", err)
}
organization, err := prompt(cmd, &promptui.Prompt{
Label: "What is the name of your organization?",
Default: "acme-corp",
})
if err != nil {
return xerrors.Errorf("pick organization prompt: %w", err)
}
email, err := prompt(cmd, &promptui.Prompt{
Label: "What's your email?",
email, err := cliui.Prompt(cmd, cliui.PromptOptions{
Text: "What's your " + cliui.Styles.Field.Render("email") + "?",
Validate: func(s string) error {
err := validator.New().Var(s, "email")
if err != nil {
@ -111,24 +108,25 @@ func login() *cobra.Command {
return xerrors.Errorf("specify email prompt: %w", err)
}
password, err := prompt(cmd, &promptui.Prompt{
Label: "Enter a password:",
Mask: '*',
password, err := cliui.Prompt(cmd, cliui.PromptOptions{
Text: "Enter a " + cliui.Styles.Field.Render("password") + ":",
Secret: true,
Validate: cliui.ValidateNotEmpty,
})
if err != nil {
return xerrors.Errorf("specify password prompt: %w", err)
}
_, err = client.CreateFirstUser(cmd.Context(), coderd.CreateFirstUserRequest{
_, err = client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{
Email: email,
Username: username,
Organization: username,
Password: password,
Organization: organization,
})
if err != nil {
return xerrors.Errorf("create initial user: %w", err)
}
resp, err := client.LoginWithPassword(cmd.Context(), coderd.LoginWithPasswordRequest{
resp, err := client.LoginWithPassword(cmd.Context(), codersdk.LoginWithPasswordRequest{
Email: email,
Password: password,
})
@ -147,7 +145,11 @@ func login() *cobra.Command {
return xerrors.Errorf("write server url: %w", err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", caret, color.HiCyanString(username))
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
cliui.Styles.Paragraph.Render(fmt.Sprintf("Welcome to Coder, %s! You're authenticated.", cliui.Styles.Keyword.Render(username)))+"\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
cliui.Styles.Paragraph.Render("Get started by creating a project: "+cliui.Styles.Code.Render("coder projects create"))+"\n")
return nil
}
@ -159,9 +161,9 @@ func login() *cobra.Command {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Your browser has been opened to visit:\n\n\t%s\n\n", authURL.String())
}
sessionToken, err := prompt(cmd, &promptui.Prompt{
Label: "Paste your token here:",
Mask: '*',
sessionToken, err := cliui.Prompt(cmd, cliui.PromptOptions{
Text: "Paste your token here:",
Secret: true,
Validate: func(token string) error {
client.SessionToken = token
_, err := client.User(cmd.Context(), "me")
@ -192,7 +194,7 @@ func login() *cobra.Command {
return xerrors.Errorf("write server url: %w", err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", caret, color.HiCyanString(resp.Username))
_, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"Welcome to Coder, %s! You're authenticated.\n", cliui.Styles.Keyword.Render(resp.Username))
return nil
},
}

View File

@ -1,6 +1,7 @@
package cli_test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
@ -26,19 +27,20 @@ func TestLogin(t *testing.T) {
// The --force-tty flag is required on Windows, because the `isatty` library does not
// accurately detect Windows ptys when they are not attached to a process:
// https://github.com/mattn/go-isatty/issues/59
root, _ := clitest.New(t, "login", client.URL.String(), "--force-tty")
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
pty := ptytest.New(t)
root.SetIn(pty.Input())
root.SetOut(pty.Output())
go func() {
defer close(doneChan)
err := root.Execute()
require.NoError(t, err)
}()
matches := []string{
"first user?", "y",
"first user?", "yes",
"username", "testuser",
"organization", "testorg",
"email", "user@coder.com",
"password", "password",
}
@ -49,6 +51,7 @@ func TestLogin(t *testing.T) {
pty.WriteLine(value)
}
pty.ExpectMatch("Welcome to Coder")
<-doneChan
})
t.Run("ExistingUserValidTokenTTY", func(t *testing.T) {
@ -56,11 +59,13 @@ func TestLogin(t *testing.T) {
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
root, _ := clitest.New(t, "login", client.URL.String(), "--force-tty", "--no-open")
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String(), "--no-open")
pty := ptytest.New(t)
root.SetIn(pty.Input())
root.SetOut(pty.Output())
go func() {
defer close(doneChan)
err := root.Execute()
require.NoError(t, err)
}()
@ -68,6 +73,7 @@ func TestLogin(t *testing.T) {
pty.ExpectMatch("Paste your token here:")
pty.WriteLine(client.SessionToken)
pty.ExpectMatch("Welcome to Coder")
<-doneChan
})
t.Run("ExistingUserInvalidTokenTTY", func(t *testing.T) {
@ -75,12 +81,16 @@ func TestLogin(t *testing.T) {
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
root, _ := clitest.New(t, "login", client.URL.String(), "--force-tty", "--no-open")
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", client.URL.String(), "--no-open")
pty := ptytest.New(t)
root.SetIn(pty.Input())
root.SetOut(pty.Output())
go func() {
err := root.Execute()
defer close(doneChan)
err := root.ExecuteContext(ctx)
// An error is expected in this case, since the login wasn't successful:
require.Error(t, err)
}()
@ -88,5 +98,7 @@ func TestLogin(t *testing.T) {
pty.ExpectMatch("Paste your token here:")
pty.WriteLine("an-invalid-token")
pty.ExpectMatch("That's not a valid token!")
cancelFunc()
<-doneChan
})
}

75
cli/parametercreate.go Normal file
View File

@ -0,0 +1,75 @@
package cli
import (
"fmt"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
)
func parameterCreate() *cobra.Command {
var (
name string
value string
scheme string
)
cmd := &cobra.Command{
Use: "create <scope> [name]",
Aliases: []string{"mk"},
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
organization, err := currentOrganization(cmd, client)
if err != nil {
return err
}
scopeName := ""
if len(args) >= 2 {
scopeName = args[1]
}
scope, scopeID, err := parseScopeAndID(cmd.Context(), client, organization, args[0], scopeName)
if err != nil {
return err
}
scheme, err := parseParameterScheme(scheme)
if err != nil {
return err
}
_, err = client.CreateParameter(cmd.Context(), scope, scopeID, codersdk.CreateParameterRequest{
Name: name,
SourceValue: value,
SourceScheme: database.ParameterSourceSchemeData,
DestinationScheme: scheme,
})
if err != nil {
return err
}
_, _ = fmt.Printf("Created!\n")
return nil
},
}
cmd.Flags().StringVarP(&name, "name", "n", "", "Name for a parameter.")
_ = cmd.MarkFlagRequired("name")
cmd.Flags().StringVarP(&value, "value", "v", "", "Value for a parameter.")
_ = cmd.MarkFlagRequired("value")
cmd.Flags().StringVarP(&scheme, "scheme", "s", "var", `Scheme for the parameter ("var" or "env").`)
return cmd
}
func parseParameterScheme(scheme string) (database.ParameterDestinationScheme, error) {
switch scheme {
case "env":
return database.ParameterDestinationSchemeEnvironmentVariable, nil
case "var":
return database.ParameterDestinationSchemeProvisionerVariable, nil
}
return database.ParameterDestinationSchemeNone, xerrors.Errorf("scheme %q not recognized", scheme)
}

13
cli/parameterdelete.go Normal file
View File

@ -0,0 +1,13 @@
package cli
import "github.com/spf13/cobra"
func parameterDelete() *cobra.Command {
return &cobra.Command{
Use: "delete",
Aliases: []string{"rm"},
RunE: func(cmd *cobra.Command, args []string) error {
return nil
},
}
}

50
cli/parameterlist.go Normal file
View File

@ -0,0 +1,50 @@
package cli
import (
"fmt"
"text/tabwriter"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
func parameterList() *cobra.Command {
return &cobra.Command{
Use: "list <scope> <scope-id>",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
organization, err := currentOrganization(cmd, client)
if err != nil {
return err
}
name := ""
if len(args) >= 2 {
name = args[1]
}
scope, scopeID, err := parseScopeAndID(cmd.Context(), client, organization, args[0], name)
if err != nil {
return err
}
params, err := client.Parameters(cmd.Context(), scope, scopeID)
if err != nil {
return err
}
writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 4, ' ', 0)
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\n",
color.HiBlackString("Parameter"),
color.HiBlackString("Created"),
color.HiBlackString("Scheme"))
for _, param := range params {
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\n",
color.New(color.FgHiCyan).Sprint(param.Name),
color.WhiteString(param.UpdatedAt.Format("January 2, 2006")),
color.New(color.FgHiWhite).Sprint(param.DestinationScheme))
}
return writer.Flush()
},
}
}

74
cli/parameters.go Normal file
View File

@ -0,0 +1,74 @@
package cli
import (
"context"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/codersdk"
)
func parameters() *cobra.Command {
cmd := &cobra.Command{
Use: "parameters",
Aliases: []string{"params"},
}
cmd.AddCommand(parameterCreate(), parameterList(), parameterDelete())
return cmd
}
func parseScopeAndID(ctx context.Context, client *codersdk.Client, organization codersdk.Organization, rawScope string, name string) (codersdk.ParameterScope, string, error) {
scope, err := parseParameterScope(rawScope)
if err != nil {
return scope, "", err
}
var scopeID string
switch scope {
case codersdk.ParameterOrganization:
if name == "" {
scopeID = organization.ID
} else {
org, err := client.OrganizationByName(ctx, "", name)
if err != nil {
return scope, "", err
}
scopeID = org.ID
}
case codersdk.ParameterProject:
project, err := client.ProjectByName(ctx, organization.ID, name)
if err != nil {
return scope, "", err
}
scopeID = project.ID.String()
case codersdk.ParameterUser:
user, err := client.User(ctx, name)
if err != nil {
return scope, "", err
}
scopeID = user.ID
case codersdk.ParameterWorkspace:
workspace, err := client.WorkspaceByName(ctx, "", name)
if err != nil {
return scope, "", err
}
scopeID = workspace.ID.String()
}
return scope, scopeID, nil
}
func parseParameterScope(scope string) (codersdk.ParameterScope, error) {
switch scope {
case string(codersdk.ParameterOrganization):
return codersdk.ParameterOrganization, nil
case string(codersdk.ParameterProject):
return codersdk.ParameterProject, nil
case string(codersdk.ParameterUser):
return codersdk.ParameterUser, nil
case string(codersdk.ParameterWorkspace):
return codersdk.ParameterWorkspace, nil
}
return codersdk.ParameterOrganization, xerrors.Errorf("no scope found by name %q", scope)
}

View File

@ -1,35 +1,34 @@
package cli
import (
"archive/tar"
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"time"
"github.com/briandowns/spinner"
"github.com/fatih/color"
"github.com/google/uuid"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/provisionerd"
"github.com/coder/coder/provisionersdk"
)
func projectCreate() *cobra.Command {
var (
yes bool
directory string
provisioner string
)
cmd := &cobra.Command{
Use: "create",
Use: "create [name]",
Short: "Create a project from the current directory",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
@ -40,70 +39,66 @@ func projectCreate() *cobra.Command {
if err != nil {
return err
}
_, err = prompt(cmd, &promptui.Prompt{
Default: "y",
IsConfirm: true,
Label: fmt.Sprintf("Set up %s in your organization?", color.New(color.FgHiCyan).Sprintf("%q", directory)),
})
var projectName string
if len(args) == 0 {
projectName = filepath.Base(directory)
} else {
projectName = args[0]
}
_, err = client.ProjectByName(cmd.Context(), organization.ID, projectName)
if err == nil {
return xerrors.Errorf("A project already exists named %q!", projectName)
}
spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond)
spin.Writer = cmd.OutOrStdout()
spin.Suffix = cliui.Styles.Keyword.Render(" Uploading current directory...")
spin.Start()
defer spin.Stop()
archive, err := provisionersdk.Tar(directory)
if err != nil {
if errors.Is(err, promptui.ErrAbort) {
return nil
}
return err
}
name, err := prompt(cmd, &promptui.Prompt{
Default: filepath.Base(directory),
Label: "What's your project's name?",
Validate: func(s string) error {
project, _ := client.ProjectByName(cmd.Context(), organization.ID, s)
if project.ID.String() != uuid.Nil.String() {
return xerrors.New("A project already exists with that name!")
resp, err := client.Upload(cmd.Context(), codersdk.ContentTypeTar, archive)
if err != nil {
return err
}
spin.Stop()
spin = spinner.New(spinner.CharSets[5], 100*time.Millisecond)
spin.Writer = cmd.OutOrStdout()
spin.Suffix = cliui.Styles.Keyword.Render("Something")
job, parameters, err := createValidProjectVersion(cmd, client, organization, database.ProvisionerType(provisioner), resp.Hash)
if err != nil {
return err
}
if !yes {
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
Text: "Create project?",
IsConfirm: true,
Default: "yes",
})
if err != nil {
if errors.Is(err, promptui.ErrAbort) {
return nil
}
return nil
},
})
if err != nil {
return err
}
job, err := validateProjectVersionSource(cmd, client, organization, database.ProvisionerType(provisioner), directory)
if err != nil {
return err
}
project, err := client.CreateProject(cmd.Context(), organization.ID, coderd.CreateProjectRequest{
Name: name,
VersionID: job.ID,
})
if err != nil {
return err
}
_, err = prompt(cmd, &promptui.Prompt{
Label: "Create project?",
IsConfirm: true,
Default: "y",
})
if err != nil {
if errors.Is(err, promptui.ErrAbort) {
return nil
return err
}
return err
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s The %s project has been created!\n", caret, color.HiCyanString(project.Name))
_, err = prompt(cmd, &promptui.Prompt{
Label: "Create a new workspace?",
IsConfirm: true,
Default: "y",
_, err = client.CreateProject(cmd.Context(), organization.ID, codersdk.CreateProjectRequest{
Name: projectName,
VersionID: job.ID,
ParameterValues: parameters,
})
if err != nil {
if errors.Is(err, promptui.ErrAbort) {
return nil
}
return err
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "The %s project has been created!\n", projectName)
return nil
},
}
@ -115,146 +110,93 @@ func projectCreate() *cobra.Command {
if err != nil {
panic(err)
}
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Bypass prompts")
return cmd
}
func validateProjectVersionSource(cmd *cobra.Command, client *codersdk.Client, organization coderd.Organization, provisioner database.ProvisionerType, directory string, parameters ...coderd.CreateParameterRequest) (*coderd.ProjectVersion, error) {
spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond)
spin.Writer = cmd.OutOrStdout()
spin.Suffix = " Uploading current directory..."
err := spin.Color("fgHiGreen")
if err != nil {
return nil, err
}
spin.Start()
defer spin.Stop()
tarData, err := tarDirectory(directory)
if err != nil {
return nil, err
}
resp, err := client.Upload(cmd.Context(), codersdk.ContentTypeTar, tarData)
if err != nil {
return nil, err
}
func createValidProjectVersion(cmd *cobra.Command, client *codersdk.Client, organization codersdk.Organization, provisioner database.ProvisionerType, hash string, parameters ...codersdk.CreateParameterRequest) (*codersdk.ProjectVersion, []codersdk.CreateParameterRequest, error) {
before := time.Now()
version, err := client.CreateProjectVersion(cmd.Context(), organization.ID, coderd.CreateProjectVersionRequest{
version, err := client.CreateProjectVersion(cmd.Context(), organization.ID, codersdk.CreateProjectVersionRequest{
StorageMethod: database.ProvisionerStorageMethodFile,
StorageSource: resp.Hash,
StorageSource: hash,
Provisioner: provisioner,
ParameterValues: parameters,
})
if err != nil {
return nil, err
}
spin.Suffix = " Waiting for the import to complete..."
logs, err := client.ProjectVersionLogsAfter(cmd.Context(), version.ID, before)
if err != nil {
return nil, err
}
logBuffer := make([]coderd.ProvisionerJobLog, 0, 64)
for {
log, ok := <-logs
if !ok {
break
}
logBuffer = append(logBuffer, log)
return nil, nil, err
}
_, err = cliui.Job(cmd, cliui.JobOptions{
Title: "Building project...",
Fetch: func() (codersdk.ProvisionerJob, error) {
version, err := client.ProjectVersion(cmd.Context(), version.ID)
return version.Job, err
},
Cancel: func() error {
return client.CancelProjectVersion(cmd.Context(), version.ID)
},
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
return client.ProjectVersionLogsAfter(cmd.Context(), version.ID, before)
},
})
if err != nil {
return nil, nil, err
}
version, err = client.ProjectVersion(cmd.Context(), version.ID)
if err != nil {
return nil, err
return nil, nil, err
}
parameterSchemas, err := client.ProjectVersionSchema(cmd.Context(), version.ID)
if err != nil {
return nil, err
return nil, nil, err
}
parameterValues, err := client.ProjectVersionParameters(cmd.Context(), version.ID)
if err != nil {
return nil, err
return nil, nil, err
}
spin.Stop()
if provisionerd.IsMissingParameterError(version.Job.Error) {
valuesBySchemaID := map[string]coderd.ProjectVersionParameter{}
valuesBySchemaID := map[string]codersdk.ProjectVersionParameter{}
for _, parameterValue := range parameterValues {
valuesBySchemaID[parameterValue.SchemaID.String()] = parameterValue
}
sort.Slice(parameterSchemas, func(i, j int) bool {
return parameterSchemas[i].Name < parameterSchemas[j].Name
})
missingSchemas := make([]codersdk.ProjectVersionParameterSchema, 0)
for _, parameterSchema := range parameterSchemas {
_, ok := valuesBySchemaID[parameterSchema.ID.String()]
if ok {
continue
}
value, err := prompt(cmd, &promptui.Prompt{
Label: fmt.Sprintf("Enter value for %s:", color.HiCyanString(parameterSchema.Name)),
})
missingSchemas = append(missingSchemas, parameterSchema)
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This project has required variables! They are scoped to the project, and not viewable after being set.")+"\r\n")
for _, parameterSchema := range missingSchemas {
value, err := cliui.ParameterSchema(cmd, parameterSchema)
if err != nil {
return nil, err
return nil, nil, err
}
parameters = append(parameters, coderd.CreateParameterRequest{
parameters = append(parameters, codersdk.CreateParameterRequest{
Name: parameterSchema.Name,
SourceValue: value,
SourceScheme: database.ParameterSourceSchemeData,
DestinationScheme: parameterSchema.DefaultDestinationScheme,
})
_, _ = fmt.Fprintln(cmd.OutOrStdout())
}
return validateProjectVersionSource(cmd, client, organization, provisioner, directory, parameters...)
return createValidProjectVersion(cmd, client, organization, provisioner, hash, parameters...)
}
if version.Job.Status != coderd.ProvisionerJobSucceeded {
for _, log := range logBuffer {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s %s\n", color.HiGreenString("[tf]"), log.Output)
}
return nil, xerrors.New(version.Job.Error)
if version.Job.Status != codersdk.ProvisionerJobSucceeded {
return nil, nil, xerrors.New(version.Job.Error)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Successfully imported project source!\n", color.HiGreenString("✓"))
resources, err := client.ProjectVersionResources(cmd.Context(), version.ID)
if err != nil {
return nil, err
return nil, nil, err
}
return &version, displayProjectImportInfo(cmd, parameterSchemas, parameterValues, resources)
}
func tarDirectory(directory string) ([]byte, error) {
var buffer bytes.Buffer
tarWriter := tar.NewWriter(&buffer)
err := filepath.Walk(directory, func(file string, fileInfo os.FileInfo, err error) error {
if err != nil {
return err
}
header, err := tar.FileInfoHeader(fileInfo, file)
if err != nil {
return err
}
rel, err := filepath.Rel(directory, file)
if err != nil {
return err
}
header.Name = rel
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
if fileInfo.IsDir() {
return nil
}
data, err := os.Open(file)
if err != nil {
return err
}
if _, err := io.Copy(tarWriter, data); err != nil {
return err
}
return data.Close()
})
if err != nil {
return nil, err
}
err = tarWriter.Flush()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
return &version, parameters, displayProjectVersionInfo(cmd, resources)
}

View File

@ -9,13 +9,12 @@ import (
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/database"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/coder/coder/pty/ptytest"
)
func TestProjectCreate(t *testing.T) {
t.Parallel()
t.Run("NoParameters", func(t *testing.T) {
t.Run("Create", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
@ -23,24 +22,20 @@ func TestProjectCreate(t *testing.T) {
Parse: echo.ParseComplete,
Provision: echo.ProvisionComplete,
})
cmd, root := clitest.New(t, "projects", "create", "--directory", source, "--provisioner", string(database.ProvisionerTypeEcho))
cmd, root := clitest.New(t, "projects", "create", "my-project", "--directory", source, "--provisioner", string(database.ProvisionerTypeEcho))
clitest.SetupConfig(t, client, root)
_ = coderdtest.NewProvisionerDaemon(t, client)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
closeChan := make(chan struct{})
go func() {
defer close(doneChan)
err := cmd.Execute()
require.NoError(t, err)
close(closeChan)
}()
matches := []string{
"organization?", "y",
"name?", "test-project",
"project?", "y",
"created!", "n",
"Create project?", "yes",
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
@ -48,54 +43,6 @@ func TestProjectCreate(t *testing.T) {
pty.ExpectMatch(match)
pty.WriteLine(value)
}
<-closeChan
})
t.Run("Parameter", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
source := clitest.CreateProjectVersionSource(t, &echo.Responses{
Parse: []*proto.Parse_Response{{
Type: &proto.Parse_Response_Complete{
Complete: &proto.Parse_Complete{
ParameterSchemas: []*proto.ParameterSchema{{
Name: "somevar",
DefaultDestination: &proto.ParameterDestination{
Scheme: proto.ParameterDestination_PROVISIONER_VARIABLE,
},
}},
},
},
}},
Provision: echo.ProvisionComplete,
})
cmd, root := clitest.New(t, "projects", "create", "--directory", source, "--provisioner", string(database.ProvisionerTypeEcho))
clitest.SetupConfig(t, client, root)
coderdtest.NewProvisionerDaemon(t, client)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
closeChan := make(chan struct{})
go func() {
err := cmd.Execute()
require.NoError(t, err)
close(closeChan)
}()
matches := []string{
"organization?", "y",
"name?", "test-project",
"somevar", "value",
"project?", "y",
"created!", "n",
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
}
<-closeChan
<-doneChan
})
}

12
cli/projectedit.go Normal file
View File

@ -0,0 +1,12 @@
package cli
import "github.com/spf13/cobra"
func projectEdit() *cobra.Command {
return &cobra.Command{
Use: "edit",
RunE: func(cmd *cobra.Command, args []string) error {
return nil
},
}
}

73
cli/projectinit.go Normal file
View File

@ -0,0 +1,73 @@
package cli
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/examples"
"github.com/coder/coder/provisionersdk"
)
func projectInit() *cobra.Command {
return &cobra.Command{
Use: "init [directory]",
Short: "Get started with a templated project.",
RunE: func(cmd *cobra.Command, args []string) error {
exampleList, err := examples.List()
if err != nil {
return err
}
exampleNames := []string{}
exampleByName := map[string]examples.Example{}
for _, example := range exampleList {
exampleNames = append(exampleNames, example.Name)
exampleByName[example.Name] = example
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Projects contain Infrastructure as Code that works with Coder to provision development workspaces. Get started by selecting an example:\n"))
option, err := cliui.Select(cmd, cliui.SelectOptions{
Options: exampleNames,
})
if err != nil {
return err
}
selectedTemplate := exampleByName[option]
archive, err := examples.Archive(selectedTemplate.ID)
if err != nil {
return err
}
workingDir, err := os.Getwd()
if err != nil {
return err
}
var directory string
if len(args) > 0 {
directory = args[0]
} else {
directory = filepath.Join(workingDir, selectedTemplate.ID)
}
relPath, err := filepath.Rel(workingDir, directory)
if err != nil {
relPath = directory
} else {
relPath = "./" + relPath
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%sExtracting %s to %s...\n", cliui.Styles.Prompt, cliui.Styles.Field.Render(selectedTemplate.ID), cliui.Styles.Keyword.Render(relPath))
err = os.MkdirAll(directory, 0700)
if err != nil {
return err
}
err = provisionersdk.Untar(directory, archive)
if err != nil {
return err
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Prompt.String()+"Inside that directory, get started by running:")
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Code.Render("coder projects create"))+"\n")
return nil
},
}
}

35
cli/projectinit_test.go Normal file
View File

@ -0,0 +1,35 @@
package cli_test
import (
"os"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/pty/ptytest"
)
func TestProjectInit(t *testing.T) {
t.Parallel()
t.Run("Extract", func(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
cmd, _ := clitest.New(t, "projects", "init", tempDir)
doneChan := make(chan struct{})
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
go func() {
defer close(doneChan)
err := cmd.Execute()
require.NoError(t, err)
}()
pty.ExpectMatch("Develop in Linux")
pty.WriteLine("")
<-doneChan
files, err := os.ReadDir(tempDir)
require.NoError(t, err)
require.Greater(t, len(files), 0)
})
}

View File

@ -1,56 +0,0 @@
package cli_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/pty/ptytest"
)
func TestProjectList(t *testing.T) {
t.Parallel()
t.Run("None", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
cmd, root := clitest.New(t, "projects", "list")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
closeChan := make(chan struct{})
go func() {
err := cmd.Execute()
require.NoError(t, err)
close(closeChan)
}()
pty.ExpectMatch("No projects found")
<-closeChan
})
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
daemon := coderdtest.NewProvisionerDaemon(t, client)
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
_ = daemon.Close()
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
cmd, root := clitest.New(t, "projects", "list")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
closeChan := make(chan struct{})
go func() {
err := cmd.Execute()
require.NoError(t, err)
close(closeChan)
}()
pty.ExpectMatch(project.Name)
<-closeChan
})
}

View File

@ -2,14 +2,13 @@ package cli
import (
"fmt"
"strings"
"sort"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/xlab/treeprint"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
)
@ -32,53 +31,53 @@ func projects() *cobra.Command {
}
cmd.AddCommand(
projectCreate(),
projectEdit(),
projectInit(),
projectList(),
projectPlan(),
projectUpdate(),
projectVersions(),
)
return cmd
}
func displayProjectImportInfo(cmd *cobra.Command, parameterSchemas []coderd.ProjectVersionParameterSchema, parameterValues []coderd.ProjectVersionParameter, resources []coderd.WorkspaceResource) error {
schemaByID := map[string]coderd.ProjectVersionParameterSchema{}
for _, schema := range parameterSchemas {
schemaByID[schema.ID.String()] = schema
}
func displayProjectVersionInfo(cmd *cobra.Command, resources []codersdk.WorkspaceResource) error {
sort.Slice(resources, func(i, j int) bool {
return fmt.Sprintf("%s.%s", resources[i].Type, resources[i].Name) < fmt.Sprintf("%s.%s", resources[j].Type, resources[j].Name)
})
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n %s\n\n", color.HiBlackString("Parameters"))
for _, value := range parameterValues {
schema, ok := schemaByID[value.SchemaID.String()]
if !ok {
return xerrors.Errorf("schema not found: %s", value.Name)
}
displayValue := value.SourceValue
if !schema.RedisplayValue {
displayValue = "<redacted>"
}
output := fmt.Sprintf("%s %s %s", color.HiCyanString(value.Name), color.HiBlackString("="), displayValue)
if value.DefaultSourceValue {
output += " (default value)"
} else if value.Scope != database.ParameterScopeImportJob {
output += fmt.Sprintf(" (inherited from %s)", value.Scope)
}
root := treeprint.NewWithRoot(output)
if schema.Description != "" {
root.AddBranch(fmt.Sprintf("%s\n%s", color.HiBlackString("Description"), schema.Description))
}
if schema.AllowOverrideSource {
root.AddBranch(fmt.Sprintf("%s Users can customize this value!", color.HiYellowString("+")))
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+strings.Join(strings.Split(root.String(), "\n"), "\n "))
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " %s\n\n", color.HiBlackString("Resources"))
addressOnStop := map[string]codersdk.WorkspaceResource{}
for _, resource := range resources {
transition := color.HiGreenString("start")
if resource.Transition == database.WorkspaceTransitionStop {
transition = color.HiRedString("stop")
if resource.Transition != database.WorkspaceTransitionStop {
continue
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " %s %s on %s\n\n", color.HiCyanString(resource.Type), color.HiCyanString(resource.Name), transition)
addressOnStop[resource.Address] = resource
}
displayed := map[string]struct{}{}
for _, resource := range resources {
if resource.Type == "random_string" {
// Hide resources that aren't substantial to a user!
continue
}
_, alreadyShown := displayed[resource.Address]
if alreadyShown {
continue
}
displayed[resource.Address] = struct{}{}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Bold.Render("resource."+resource.Type+"."+resource.Name))
_, existsOnStop := addressOnStop[resource.Address]
if existsOnStop {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Warn.Render("~ persistent"))
} else {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Keyword.Render("+ start")+cliui.Styles.Placeholder.Render(" (deletes on stop)"))
}
if resource.Agent != nil {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Fuschia.Render("▲ allows ssh"))
}
_, _ = fmt.Fprintln(cmd.OutOrStdout())
}
return nil
}

View File

@ -1,13 +1,94 @@
package cli
import "github.com/spf13/cobra"
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/provisionersdk"
)
func projectUpdate() *cobra.Command {
return &cobra.Command{
Use: "update <name>",
Use: "update <project> [directory]",
Args: cobra.MinimumNArgs(1),
Short: "Update a project from the current directory",
Short: "Update the source-code of a project from a directory.",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
organization, err := currentOrganization(cmd, client)
if err != nil {
return err
}
project, err := client.ProjectByName(cmd.Context(), organization.ID, args[0])
if err != nil {
return err
}
directory, err := os.Getwd()
if err != nil {
return err
}
if len(args) >= 2 {
directory, err = filepath.Abs(args[1])
if err != nil {
return err
}
}
content, err := provisionersdk.Tar(directory)
if err != nil {
return err
}
resp, err := client.Upload(cmd.Context(), codersdk.ContentTypeTar, content)
if err != nil {
return err
}
before := time.Now()
projectVersion, err := client.CreateProjectVersion(cmd.Context(), organization.ID, codersdk.CreateProjectVersionRequest{
ProjectID: project.ID,
StorageMethod: database.ProvisionerStorageMethodFile,
StorageSource: resp.Hash,
Provisioner: database.ProvisionerTypeTerraform,
})
if err != nil {
return err
}
logs, err := client.ProjectVersionLogsAfter(cmd.Context(), projectVersion.ID, before)
if err != nil {
return err
}
for {
log, ok := <-logs
if !ok {
break
}
_, _ = fmt.Printf("terraform (%s): %s\n", log.Level, log.Output)
}
projectVersion, err = client.ProjectVersion(cmd.Context(), projectVersion.ID)
if err != nil {
return err
}
if projectVersion.Job.Status != codersdk.ProvisionerJobSucceeded {
return xerrors.New("job failed")
}
err = client.UpdateActiveProjectVersion(cmd.Context(), project.ID, codersdk.UpdateActiveProjectVersion{
ID: projectVersion.ID,
})
if err != nil {
return err
}
_, _ = fmt.Printf("Updated version!\n")
return nil
},
}

15
cli/projectversions.go Normal file
View File

@ -0,0 +1,15 @@
package cli
import "github.com/spf13/cobra"
func projectVersions() *cobra.Command {
return &cobra.Command{
Use: "versions",
Aliases: []string{"version"},
RunE: func(cmd *cobra.Command, args []string) error {
return nil
},
}
}
// coder project versions

View File

@ -1,25 +1,22 @@
package cli
import (
"fmt"
"io"
"net/url"
"os"
"strings"
"github.com/fatih/color"
"github.com/charmbracelet/lipgloss"
"github.com/kirsle/configdir"
"github.com/manifoldco/promptui"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/cli/config"
"github.com/coder/coder/coderd"
"github.com/coder/coder/codersdk"
)
var (
caret = color.HiBlackString(">")
caret = cliui.Styles.Prompt.String()
)
const (
@ -30,47 +27,48 @@ const (
func Root() *cobra.Command {
cmd := &cobra.Command{
Use: "coder",
Use: "coder",
SilenceUsage: true,
Long: ` ▄█▀ ▀█▄
▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█
▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀
█▌ ▄▌ ▐█ █▌ ▀█▄▄▄█▌ █ █ ▐█ ██ ██▀▀ █
██████▀▄█ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀
` + color.New(color.Underline).Sprint("Self-hosted developer workspaces on your infra") + `
` + lipgloss.NewStyle().Underline(true).Render("Self-hosted developer workspaces on your infra") + `
`,
Example: `
- Create a project for developers to create workspaces
Example: cliui.Styles.Paragraph.Render(`Start Coder in "dev" mode. This dev-mode requires no further setup, and your local `+cliui.Styles.Code.Render("coder")+` CLI will be authenticated to talk to it. This makes it easy to experiment with Coder.`) + `
` + color.New(color.FgHiMagenta).Sprint("$ coder projects create <directory>") + `
` + cliui.Styles.Code.Render("$ coder start --dev") + `
` + cliui.Styles.Paragraph.Render("Get started by creating a project from an example.") + `
- 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>"),
` + cliui.Styles.Code.Render("$ coder projects init"),
}
// Customizes the color of headings to make subcommands
// more visually appealing.
header := color.New(color.FgHiBlack)
header := cliui.Styles.Placeholder
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:"),
`Usage:`, header.Render("Usage:"),
`Examples:`, header.Render("Examples:"),
`Available Commands:`, header.Render("Commands:"),
`Global Flags:`, header.Render("Global Flags:"),
`Flags:`, header.Render("Flags:"),
`Additional help topics:`, header.Render("Additional help:"),
).Replace(cmd.UsageTemplate()))
cmd.AddCommand(daemon())
cmd.AddCommand(login())
cmd.AddCommand(projects())
cmd.AddCommand(workspaces())
cmd.AddCommand(users())
cmd.AddCommand(
configSSH(),
start(),
login(),
parameters(),
projects(),
users(),
workspaces(),
workspaceSSH(),
workspaceTunnel(),
)
cmd.PersistentFlags().String(varGlobalConfig, configdir.LocalConfig("coder"), "Path to the global `coder` config directory")
cmd.PersistentFlags().String(varGlobalConfig, configdir.LocalConfig("coderv2"), "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 {
@ -108,10 +106,10 @@ func createClient(cmd *cobra.Command) (*codersdk.Client, error) {
}
// currentOrganization returns the currently active organization for the authenticated user.
func currentOrganization(cmd *cobra.Command, client *codersdk.Client) (coderd.Organization, error) {
func currentOrganization(cmd *cobra.Command, client *codersdk.Client) (codersdk.Organization, error) {
orgs, err := client.OrganizationsByUser(cmd.Context(), "me")
if err != nil {
return coderd.Organization{}, nil
return codersdk.Organization{}, nil
}
// For now, we won't use the config to set this.
// Eventually, we will support changing using "coder switch <org>"
@ -138,76 +136,9 @@ func isTTY(cmd *cobra.Command) bool {
if forceTty && err == nil {
return true
}
reader := cmd.InOrStdin()
file, ok := reader.(*os.File)
file, ok := cmd.InOrStdin().(*os.File)
if !ok {
return false
}
return isatty.IsTerminal(file.Fd())
}
func prompt(cmd *cobra.Command, prompt *promptui.Prompt) (string, error) {
prompt.Stdin = io.NopCloser(cmd.InOrStdin())
prompt.Stdout = readWriteCloser{
Writer: cmd.OutOrStdout(),
}
// 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
}
// readWriteCloser fakes reads, writes, and closing!
type readWriteCloser struct {
io.Reader
io.Writer
io.Closer
}

View File

@ -1 +1,88 @@
package cli
import (
"fmt"
"os"
"github.com/pion/webrtc/v3"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
"golang.org/x/term"
"github.com/coder/coder/agent"
"github.com/coder/coder/peer"
"github.com/coder/coder/peerbroker"
)
func workspaceSSH() *cobra.Command {
cmd := &cobra.Command{
Use: "ssh <workspace> [resource]",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
workspace, err := client.WorkspaceByName(cmd.Context(), "", args[0])
if err != nil {
return err
}
resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID)
if err != nil {
return err
}
for _, resource := range resources {
_, _ = fmt.Printf("Got resource: %+v\n", resource)
if resource.Agent == nil {
continue
}
dialed, err := client.DialWorkspaceAgent(cmd.Context(), resource.ID)
if err != nil {
return err
}
stream, err := dialed.NegotiateConnection(cmd.Context())
if err != nil {
return err
}
conn, err := peerbroker.Dial(stream, []webrtc.ICEServer{{
URLs: []string{"stun:stun.l.google.com:19302"},
}}, &peer.ConnOptions{})
if err != nil {
return err
}
client, err := agent.DialSSHClient(conn)
if err != nil {
return err
}
session, err := client.NewSession()
if err != nil {
return err
}
_, _ = term.MakeRaw(int(os.Stdin.Fd()))
err = session.RequestPty("xterm-256color", 128, 128, ssh.TerminalModes{
ssh.OCRNL: 1,
})
if err != nil {
return err
}
session.Stdin = os.Stdin
session.Stdout = os.Stdout
session.Stderr = os.Stderr
err = session.Shell()
if err != nil {
return err
}
err = session.Wait()
if err != nil {
return err
}
}
return nil
},
}
return cmd
}

190
cli/start.go Normal file
View File

@ -0,0 +1,190 @@
package cli
import (
"context"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
"os"
"time"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"google.golang.org/api/idtoken"
"google.golang.org/api/option"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/tunnel"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/database/databasefake"
"github.com/coder/coder/provisioner/terraform"
"github.com/coder/coder/provisionerd"
"github.com/coder/coder/provisionersdk"
"github.com/coder/coder/provisionersdk/proto"
)
func start() *cobra.Command {
var (
address string
dev bool
useTunnel bool
)
root := &cobra.Command{
Use: "start",
RunE: func(cmd *cobra.Command, args []string) error {
logger := slog.Make(sloghuman.Sink(os.Stderr))
listener, err := net.Listen("tcp", address)
if err != nil {
return xerrors.Errorf("listen %q: %w", address, err)
}
defer listener.Close()
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
if !valid {
return xerrors.New("must be listening on tcp")
}
if tcpAddr.IP.IsUnspecified() {
tcpAddr.IP = net.IPv4(127, 0, 0, 1)
}
localURL := &url.URL{
Scheme: "http",
Host: tcpAddr.String(),
}
accessURL := localURL
var tunnelErr <-chan error
if dev {
if useTunnel {
var accessURLRaw string
accessURLRaw, tunnelErr, err = tunnel.New(cmd.Context(), localURL.String())
if err != nil {
return xerrors.Errorf("create tunnel: %w", err)
}
accessURL, err = url.Parse(accessURLRaw)
if err != nil {
return xerrors.Errorf("parse: %w", err)
}
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), ` ▄█▀ ▀█▄
▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█
▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀
█▌ ▄▌ ▐█ █▌ ▀█▄▄▄█▌ █ █ ▐█ ██ ██▀▀ █
██████▀▄█ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀
`+cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Started in `+
cliui.Styles.Field.Render("dev")+` mode. All data is in-memory! Learn how to setup and manage a production Coder deployment here: `+cliui.Styles.Prompt.Render("https://coder.com/docs/TODO")))+
`
`+
cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.FocusedPrompt.String()+`Run `+cliui.Styles.Code.Render("coder projects init")+" in a new terminal to get started.\n"))+`
`)
}
validator, err := idtoken.NewValidator(cmd.Context(), option.WithoutAuthentication())
if err != nil {
return err
}
handler, closeCoderd := coderd.New(&coderd.Options{
AccessURL: accessURL,
Logger: logger,
Database: databasefake.New(),
Pubsub: database.NewPubsubInMemory(),
GoogleTokenValidator: validator,
})
client := codersdk.New(localURL)
daemonClose, err := newProvisionerDaemon(cmd.Context(), client, logger)
if err != nil {
return xerrors.Errorf("create provisioner daemon: %w", err)
}
defer daemonClose.Close()
errCh := make(chan error)
go func() {
defer close(errCh)
errCh <- http.Serve(listener, handler)
}()
if dev {
config := createConfig(cmd)
_, err = client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{
Email: "dev@coder.com",
Username: "developer",
Password: "password",
Organization: "coder",
})
if err != nil {
return xerrors.Errorf("create first user: %w\n", err)
}
token, err := client.LoginWithPassword(cmd.Context(), codersdk.LoginWithPasswordRequest{
Email: "dev@coder.com",
Password: "password",
})
if err != nil {
return xerrors.Errorf("login with first user: %w", err)
}
err = config.URL().Write(localURL.String())
if err != nil {
return xerrors.Errorf("write local url: %w", err)
}
err = config.Session().Write(token.SessionToken)
if err != nil {
return xerrors.Errorf("write session token: %w", err)
}
}
closeCoderd()
select {
case <-cmd.Context().Done():
return cmd.Context().Err()
case err := <-tunnelErr:
return err
case err := <-errCh:
return err
}
},
}
defaultAddress, ok := os.LookupEnv("ADDRESS")
if !ok {
defaultAddress = "127.0.0.1:3000"
}
root.Flags().StringVarP(&address, "address", "a", defaultAddress, "The address to serve the API and dashboard.")
root.Flags().BoolVarP(&dev, "dev", "", false, "Serve Coder in dev mode for tinkering.")
root.Flags().BoolVarP(&useTunnel, "tunnel", "", true, `Serve "dev" mode through a Cloudflare Tunnel for easy setup.`)
return root
}
func newProvisionerDaemon(ctx context.Context, client *codersdk.Client, logger slog.Logger) (io.Closer, error) {
terraformClient, terraformServer := provisionersdk.TransportPipe()
go func() {
err := terraform.Serve(ctx, &terraform.ServeOptions{
ServeOptions: &provisionersdk.ServeOptions{
Listener: terraformServer,
},
Logger: logger,
})
if err != nil {
panic(err)
}
}()
tempDir, err := ioutil.TempDir("", "provisionerd")
if err != nil {
return nil, err
}
return provisionerd.New(client.ListenProvisionerDaemon, &provisionerd.Options{
Logger: logger,
PollInterval: 50 * time.Millisecond,
UpdateInterval: 50 * time.Millisecond,
Provisioners: provisionerd.Provisioners{
string(database.ProvisionerTypeTerraform): proto.NewDRPCProvisionerClient(provisionersdk.Conn(terraformClient)),
},
WorkDirectory: tempDir,
}), nil
}

50
cli/start_test.go Normal file
View File

@ -0,0 +1,50 @@
package cli_test
import (
"context"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/codersdk"
)
func TestStart(t *testing.T) {
t.Parallel()
t.Run("Production", func(t *testing.T) {
t.Parallel()
ctx, cancelFunc := context.WithCancel(context.Background())
go cancelFunc()
root, _ := clitest.New(t, "start", "--address", ":0")
err := root.ExecuteContext(ctx)
require.ErrorIs(t, err, context.Canceled)
})
t.Run("Development", func(t *testing.T) {
t.Parallel()
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
root, cfg := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0")
go func() {
err := root.ExecuteContext(ctx)
require.ErrorIs(t, err, context.Canceled)
}()
var accessURL string
require.Eventually(t, func() bool {
var err error
accessURL, err = cfg.URL().Read()
return err == nil
}, 15*time.Second, 25*time.Millisecond)
// Verify that authentication was properly set in dev-mode.
token, err := cfg.Session().Read()
require.NoError(t, err)
parsed, err := url.Parse(accessURL)
require.NoError(t, err)
client := codersdk.New(parsed)
client.SessionToken = token
_, err = client.User(ctx, "")
require.NoError(t, err)
})
}

12
cli/tunnel.go Normal file
View File

@ -0,0 +1,12 @@
package cli
import "github.com/spf13/cobra"
func workspaceTunnel() *cobra.Command {
return &cobra.Command{
Use: "tunnel",
RunE: func(cmd *cobra.Command, args []string) error {
return nil
},
}
}

View File

@ -4,12 +4,15 @@ import (
"net/url"
"os"
"github.com/powersj/whatsthis/pkg/cloud"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/agent"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/peer"
)
func workspaceAgent() *cobra.Command {
@ -30,26 +33,28 @@ func workspaceAgent() *cobra.Command {
client := codersdk.New(coderURL)
sessionToken, exists := os.LookupEnv("CODER_TOKEN")
if !exists {
probe, err := cloud.New()
// probe, err := cloud.New()
// if err != nil {
// return xerrors.Errorf("probe cloud: %w", err)
// }
// if !probe.Detected {
// return xerrors.Errorf("no valid authentication method found; set \"CODER_TOKEN\"")
// }
// switch {
// case probe.GCP():
response, err := client.AuthWorkspaceGoogleInstanceIdentity(cmd.Context(), "", nil)
if err != nil {
return xerrors.Errorf("probe cloud: %w", err)
}
if !probe.Detected {
return xerrors.Errorf("no valid authentication method found; set \"CODER_TOKEN\"")
}
switch {
case probe.GCP():
response, err := client.AuthWorkspaceGoogleInstanceIdentity(cmd.Context(), "", nil)
if err != nil {
return xerrors.Errorf("authenticate workspace with gcp: %w", err)
}
sessionToken = response.SessionToken
default:
return xerrors.Errorf("%q authentication not supported; set \"CODER_TOKEN\" instead", probe.Name)
return xerrors.Errorf("authenticate workspace with gcp: %w", err)
}
sessionToken = response.SessionToken
// default:
// return xerrors.Errorf("%q authentication not supported; set \"CODER_TOKEN\" instead", probe.Name)
// }
}
client.SessionToken = sessionToken
closer := agent.New(client.ListenWorkspaceAgent, nil)
closer := agent.New(client.ListenWorkspaceAgent, &peer.ConnOptions{
Logger: slog.Make(sloghuman.Sink(cmd.OutOrStdout())),
})
<-cmd.Context().Done()
return closer.Close()
},

View File

@ -3,21 +3,27 @@ package cli
import (
"errors"
"fmt"
"sort"
"time"
"github.com/briandowns/spinner"
"github.com/fatih/color"
"github.com/google/uuid"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
)
func workspaceCreate() *cobra.Command {
var (
projectName string
)
cmd := &cobra.Command{
Use: "create <project> [name]",
Use: "create <name>",
Args: cobra.ExactArgs(1),
Short: "Create a workspace from a project",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
@ -29,37 +35,51 @@ func workspaceCreate() *cobra.Command {
return err
}
var name string
if len(args) >= 2 {
name = args[1]
} else {
name, err = prompt(cmd, &promptui.Prompt{
Label: "What's your workspace's name?",
Validate: func(s string) error {
if s == "" {
return xerrors.Errorf("You must provide a name!")
}
workspace, _ := client.WorkspaceByName(cmd.Context(), "", s)
if workspace.ID.String() != uuid.Nil.String() {
return xerrors.New("A workspace already exists with that name!")
}
return nil
},
var project codersdk.Project
if projectName == "" {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Select a project:"))
projectNames := []string{}
projectByName := map[string]codersdk.Project{}
projects, err := client.ProjectsByOrganization(cmd.Context(), organization.ID)
if err != nil {
return err
}
for _, project := range projects {
projectNames = append(projectNames, project.Name)
projectByName[project.Name] = project
}
sort.Slice(projectNames, func(i, j int) bool {
return projectByName[projectNames[i]].WorkspaceOwnerCount > projectByName[projectNames[j]].WorkspaceOwnerCount
})
// Move the cursor up a single line for nicer display!
option, err := cliui.Select(cmd, cliui.SelectOptions{
Options: projectNames,
HideSearch: true,
})
if err != nil {
if errors.Is(err, promptui.ErrAbort) {
return nil
}
return err
}
project = projectByName[option]
} else {
project, err = client.ProjectByName(cmd.Context(), organization.ID, projectName)
if err != nil {
return xerrors.Errorf("get project by name: %w", err)
}
if err != nil {
return err
}
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Previewing project create...\n", caret)
_, _ = fmt.Fprintln(cmd.OutOrStdout())
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Prompt.String()+"Creating with the "+cliui.Styles.Field.Render(project.Name)+" project...")
project, err := client.ProjectByName(cmd.Context(), organization.ID, args[0])
if err != nil {
return err
workspaceName := args[0]
_, err = client.WorkspaceByName(cmd.Context(), "", workspaceName)
if err == nil {
return xerrors.Errorf("A workspace already exists named %q!", workspaceName)
}
projectVersion, err := client.ProjectVersion(cmd.Context(), project.ActiveVersionID)
if err != nil {
return err
@ -68,22 +88,46 @@ func workspaceCreate() *cobra.Command {
if err != nil {
return err
}
parameterValues, err := client.ProjectVersionParameters(cmd.Context(), projectVersion.ID)
if err != nil {
return err
printed := false
parameters := make([]codersdk.CreateParameterRequest, 0)
for _, parameterSchema := range parameterSchemas {
if !parameterSchema.AllowOverrideSource {
continue
}
if !printed {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This project has customizable parameters! These can be changed after create, but may have unintended side effects (like data loss).")+"\r\n")
printed = true
}
value, err := cliui.ParameterSchema(cmd, parameterSchema)
if err != nil {
return err
}
parameters = append(parameters, codersdk.CreateParameterRequest{
Name: parameterSchema.Name,
SourceValue: value,
SourceScheme: database.ParameterSourceSchemeData,
DestinationScheme: parameterSchema.DefaultDestinationScheme,
})
}
if printed {
_, _ = fmt.Fprintln(cmd.OutOrStdout())
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.FocusedPrompt.String()+"Previewing resources...")
_, _ = fmt.Fprintln(cmd.OutOrStdout())
}
resources, err := client.ProjectVersionResources(cmd.Context(), projectVersion.ID)
if err != nil {
return err
}
err = displayProjectImportInfo(cmd, parameterSchemas, parameterValues, resources)
err = displayProjectVersionInfo(cmd, resources)
if err != nil {
return err
}
_, err = prompt(cmd, &promptui.Prompt{
Label: fmt.Sprintf("Create workspace %s?", color.HiCyanString(name)),
Default: "y",
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
Text: fmt.Sprintf("Create workspace %s?", color.HiCyanString(workspaceName)),
Default: "yes",
IsConfirm: true,
})
if err != nil {
@ -93,39 +137,70 @@ func workspaceCreate() *cobra.Command {
return err
}
workspace, err := client.CreateWorkspace(cmd.Context(), "", coderd.CreateWorkspaceRequest{
ProjectID: project.ID,
Name: name,
before := time.Now()
workspace, err := client.CreateWorkspace(cmd.Context(), "", codersdk.CreateWorkspaceRequest{
ProjectID: project.ID,
Name: workspaceName,
ParameterValues: parameters,
})
if err != nil {
return err
}
version, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
ProjectVersionID: projectVersion.ID,
Transition: database.WorkspaceTransitionStart,
_, err = cliui.Job(cmd, cliui.JobOptions{
Title: "Building workspace...",
Fetch: func() (codersdk.ProvisionerJob, error) {
build, err := client.WorkspaceBuild(cmd.Context(), workspace.LatestBuild.ID)
return build.Job, err
},
Cancel: func() error {
return client.CancelWorkspaceBuild(cmd.Context(), workspace.LatestBuild.ID)
},
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
return client.WorkspaceBuildLogsAfter(cmd.Context(), workspace.LatestBuild.ID, before)
},
})
if err != nil {
return err
}
logs, err := client.WorkspaceBuildLogsAfter(cmd.Context(), version.ID, time.Time{})
resources, err = client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID)
if err != nil {
return err
}
for {
log, ok := <-logs
if !ok {
spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond, spinner.WithColor("fgGreen"))
spin.Writer = cmd.OutOrStdout()
spin.Suffix = " Waiting for agent to connect..."
spin.Start()
defer spin.Stop()
for _, resource := range resources {
if resource.Agent == nil {
continue
}
ticker := time.NewTicker(1 * time.Second)
for {
select {
case <-cmd.Context().Done():
return nil
case <-ticker.C:
}
resource, err := client.WorkspaceResource(cmd.Context(), resource.ID)
if err != nil {
return err
}
if resource.Agent.FirstConnectedAt == nil {
continue
}
spin.Stop()
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace has been created!\n\n", cliui.Styles.Keyword.Render(workspace.Name))
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Code.Render("coder ssh "+workspace.Name))
_, _ = fmt.Fprintln(cmd.OutOrStdout())
break
}
_, _ = fmt.Printf("Terraform: %s\n", log.Output)
}
// This command is WIP, and output will change!
_, _ = fmt.Printf("Created workspace! %s\n", name)
return nil
return err
},
}
cmd.Flags().StringVarP(&projectName, "project", "p", "", "Specify a project name.")
return cmd
}

View File

@ -1,63 +0,0 @@
package cli_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/coder/coder/pty/ptytest"
)
func TestWorkspaceCreate(t *testing.T) {
t.Parallel()
t.Run("Create", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
_ = coderdtest.NewProvisionerDaemon(t, client)
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
}},
},
},
}},
})
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
cmd, root := clitest.New(t, "workspaces", "create", project.Name)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
closeChan := make(chan struct{})
go func() {
err := cmd.Execute()
require.NoError(t, err)
close(closeChan)
}()
matches := []string{
"name?", "workspace-name",
"Create workspace", "y",
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
}
pty.ExpectMatch("Create")
<-closeChan
})
}

51
cli/workspacedelete.go Normal file
View File

@ -0,0 +1,51 @@
package cli
import (
"time"
"github.com/spf13/cobra"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
)
func workspaceDelete() *cobra.Command {
return &cobra.Command{
Use: "delete <workspace>",
Aliases: []string{"rm"},
ValidArgsFunction: validArgsWorkspaceName,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
workspace, err := client.WorkspaceByName(cmd.Context(), "", args[0])
if err != nil {
return err
}
before := time.Now()
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: database.WorkspaceTransitionDelete,
})
if err != nil {
return err
}
_, err = cliui.Job(cmd, cliui.JobOptions{
Title: "Deleting workspace...",
Fetch: func() (codersdk.ProvisionerJob, error) {
build, err := client.WorkspaceBuild(cmd.Context(), build.ID)
return build.Job, err
},
Cancel: func() error {
return client.CancelWorkspaceBuild(cmd.Context(), build.ID)
},
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
return client.WorkspaceBuildLogsAfter(cmd.Context(), build.ID, before)
},
})
return err
},
}
}

59
cli/workspacelist.go Normal file
View File

@ -0,0 +1,59 @@
package cli
import (
"fmt"
"text/tabwriter"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/coder/coder/cli/cliui"
)
func workspaceList() *cobra.Command {
return &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
start := time.Now()
workspaces, err := client.WorkspacesByUser(cmd.Context(), "")
if err != nil {
return err
}
if len(workspaces) == 0 {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Prompt.String()+"No workspaces found! Create one:")
_, _ = fmt.Fprintln(cmd.OutOrStdout())
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Code.Render("coder workspaces create <name>"))
_, _ = fmt.Fprintln(cmd.OutOrStdout())
return nil
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Workspaces found %s\n\n",
caret,
color.HiBlackString("[%dms]",
time.Since(start).Milliseconds()))
writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 4, ' ', 0)
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\n",
color.HiBlackString("Workspace"),
color.HiBlackString("Project"),
color.HiBlackString("Status"),
color.HiBlackString("Last Built"),
color.HiBlackString("Outdated"))
for _, workspace := range workspaces {
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%+v\n",
color.New(color.FgHiCyan).Sprint(workspace.Name),
color.WhiteString(workspace.ProjectName),
color.WhiteString(string(workspace.LatestBuild.Transition)),
color.WhiteString(workspace.LatestBuild.Job.CompletedAt.Format("January 2, 2006")),
workspace.Outdated)
}
return writer.Flush()
},
}
}

View File

@ -1,13 +1,44 @@
package cli
import "github.com/spf13/cobra"
import (
"strings"
"github.com/spf13/cobra"
)
func workspaces() *cobra.Command {
cmd := &cobra.Command{
Use: "workspaces",
Use: "workspaces",
Aliases: []string{"ws"},
}
cmd.AddCommand(workspaceAgent())
cmd.AddCommand(workspaceCreate())
cmd.AddCommand(workspaceDelete())
cmd.AddCommand(workspaceList())
cmd.AddCommand(workspaceShow())
cmd.AddCommand(workspaceStop())
cmd.AddCommand(workspaceStart())
cmd.AddCommand(workspaceSSH())
cmd.AddCommand(workspaceUpdate())
return cmd
}
func validArgsWorkspaceName(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
client, err := createClient(cmd)
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
workspaces, err := client.WorkspacesByUser(cmd.Context(), "")
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
names := make([]string, 0)
for _, workspace := range workspaces {
if !strings.HasPrefix(workspace.Name, toComplete) {
continue
}
names = append(names, workspace.Name)
}
return names, cobra.ShellCompDirectiveDefault
}

35
cli/workspaceshow.go Normal file
View File

@ -0,0 +1,35 @@
package cli
import (
"fmt"
"github.com/spf13/cobra"
)
func workspaceShow() *cobra.Command {
return &cobra.Command{
Use: "show",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
workspace, err := client.WorkspaceByName(cmd.Context(), "", args[0])
if err != nil {
return err
}
resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID)
if err != nil {
return err
}
for _, resource := range resources {
if resource.Agent == nil {
continue
}
_, _ = fmt.Printf("Agent: %+v\n", resource.Agent)
}
return nil
},
}
}

50
cli/workspacestart.go Normal file
View File

@ -0,0 +1,50 @@
package cli
import (
"time"
"github.com/spf13/cobra"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
)
func workspaceStart() *cobra.Command {
return &cobra.Command{
Use: "start <workspace>",
ValidArgsFunction: validArgsWorkspaceName,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
workspace, err := client.WorkspaceByName(cmd.Context(), "", args[0])
if err != nil {
return err
}
before := time.Now()
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: database.WorkspaceTransitionStart,
})
if err != nil {
return err
}
_, err = cliui.Job(cmd, cliui.JobOptions{
Title: "Starting workspace...",
Fetch: func() (codersdk.ProvisionerJob, error) {
build, err := client.WorkspaceBuild(cmd.Context(), build.ID)
return build.Job, err
},
Cancel: func() error {
return client.CancelWorkspaceBuild(cmd.Context(), build.ID)
},
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
return client.WorkspaceBuildLogsAfter(cmd.Context(), build.ID, before)
},
})
return err
},
}
}

53
cli/workspacestop.go Normal file
View File

@ -0,0 +1,53 @@
package cli
import (
"time"
"github.com/spf13/cobra"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
)
func workspaceStop() *cobra.Command {
return &cobra.Command{
Use: "stop <workspace>",
ValidArgsFunction: validArgsWorkspaceName,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
workspace, err := client.WorkspaceByName(cmd.Context(), "", args[0])
if err != nil {
return err
}
before := time.Now()
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: database.WorkspaceTransitionStop,
})
if err != nil {
return err
}
_, err = cliui.Job(cmd, cliui.JobOptions{
Title: "Stopping workspace...",
Fetch: func() (codersdk.ProvisionerJob, error) {
build, err := client.WorkspaceBuild(cmd.Context(), build.ID)
return build.Job, err
},
Cancel: func() error {
return client.CancelWorkspaceBuild(cmd.Context(), build.ID)
},
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
return client.WorkspaceBuildLogsAfter(cmd.Context(), build.ID, before)
},
})
if err != nil {
return err
}
return nil
},
}
}

54
cli/workspaceupdate.go Normal file
View File

@ -0,0 +1,54 @@
package cli
import (
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/coder/coder/codersdk"
)
func workspaceUpdate() *cobra.Command {
return &cobra.Command{
Use: "update",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
workspace, err := client.WorkspaceByName(cmd.Context(), "", args[0])
if err != nil {
return err
}
if !workspace.Outdated {
_, _ = fmt.Printf("Workspace isn't outdated!\n")
return nil
}
project, err := client.Project(cmd.Context(), workspace.ProjectID)
if err != nil {
return nil
}
before := time.Now()
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: workspace.LatestBuild.Transition,
})
if err != nil {
return err
}
logs, err := client.WorkspaceBuildLogsAfter(cmd.Context(), build.ID, before)
if err != nil {
return err
}
for {
log, ok := <-logs
if !ok {
break
}
_, _ = fmt.Printf("Output: %s\n", log.Output)
}
return nil
},
}
}