github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/cli/generate_tiltfile.go (about)

     1  package cli
     2  
     3  import (
     4  	"bytes"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"io/fs"
     9  	"os"
    10  	"path/filepath"
    11  	"runtime"
    12  	"strings"
    13  
    14  	"github.com/fatih/color"
    15  	"golang.org/x/term"
    16  )
    17  
    18  type generateTiltfileResult string
    19  
    20  const (
    21  	generateTiltfileError          = "error"
    22  	generateTiltfileUserExit       = "exit"
    23  	generateTiltfileNonInteractive = "non_interactive"
    24  	generateTiltfileAlreadyExists  = "already_exists"
    25  	generateTiltfileCreated        = "created"
    26  	generateTiltfileDeclined       = "declined"
    27  )
    28  
    29  // maybeGenerateTiltfile offers to create a Tiltfile if one does not exist and
    30  // the user is running in an interactive (TTY) session.
    31  //
    32  // The generateTiltfileResult value is ALWAYS set, even on error.
    33  func maybeGenerateTiltfile(tfPath string) (generateTiltfileResult, error) {
    34  	if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
    35  		return generateTiltfileNonInteractive, nil
    36  	}
    37  
    38  	if absPath, err := filepath.Abs(tfPath); err != nil {
    39  		// the absolute path is really just to improve CLI output, if the path
    40  		// itself is so invalid this fails, we'll catch + report it via the
    41  		// logic to determine if a Tiltfile already exists
    42  		tfPath = absPath
    43  	}
    44  
    45  	if hasTiltfile, err := checkTiltfileExists(tfPath); err != nil {
    46  		// either Tiltfile path is totally invalid or there's something like
    47  		// a permissions error, so report it & exit
    48  		return generateTiltfileError, err
    49  	} else if hasTiltfile {
    50  		// Tiltfile exists, so don't prompt to generate one
    51  		return generateTiltfileAlreadyExists, nil
    52  	}
    53  
    54  	restoreTerm, err := setupTerm(os.Stdin)
    55  	if err != nil {
    56  		return generateTiltfileError, nil
    57  	}
    58  
    59  	lineCount := 0
    60  	var postFinishMessage string
    61  	defer func() {
    62  		_ = restoreTerm()
    63  
    64  		if postFinishMessage != "" {
    65  			// NOTE: pre-win10, there's no support for ANSI escape codes, and
    66  			// 	it's not worth the headache to deal with Windows console API
    67  			// 	for this, so the output isn't cleared there
    68  			if err == nil && runtime.GOOS != "windows" {
    69  				// erase our output once done on success
    70  				// \033[%d -> move cursor up %d rows
    71  				// \r      -> move cursor to first column
    72  				// \033[J  -> clear output from cursor to end of stream
    73  				fmt.Printf("\033[%dA\r\033[J", lineCount)
    74  			}
    75  
    76  			fmt.Println(postFinishMessage)
    77  		}
    78  	}()
    79  
    80  	// using os.Stdin directly works on *nix, but Windows needs the reads to
    81  	// happen from os.Stdin and critically, the writes to be on os.Stdout
    82  	// (this wrapper also works on *nix)
    83  	termIO := struct {
    84  		io.Reader
    85  		io.Writer
    86  	}{os.Stdin, os.Stdout}
    87  	t := term.NewTerminal(termIO, "✨ Create a starter Tiltfile? (y/n) ")
    88  
    89  	// Offer to create a Tiltfile
    90  	var intro bytes.Buffer
    91  	intro.WriteString(tiltfileDoesNotExistWarning(tfPath))
    92  	intro.WriteString(`
    93  Tilt can create a sample Tiltfile for you, which includes
    94  useful snippets to modify and extend with build and deploy
    95  steps for your microservices.
    96  `)
    97  	intro.WriteString("\n")
    98  	_, err = t.Write(intro.Bytes())
    99  	if err != nil {
   100  		return generateTiltfileError, err
   101  	}
   102  	// we track # of lines written to clear the output when done
   103  	lineCount += bytes.Count(intro.Bytes(), []byte("\n"))
   104  
   105  	for {
   106  		line, err := t.ReadLine()
   107  		lineCount++
   108  		if err != nil {
   109  			// perform a carriage return to ensure we're back at the beginning
   110  			// of a new line (if user hit Ctrl-C/Ctrl-D, this is necessary; for
   111  			// any other errors, better to be safe than leave terminal in a bad
   112  			// state)
   113  			fmt.Println("\r")
   114  			if err == io.EOF {
   115  				// since we have the terminal in raw mode, no signal will be fired
   116  				// on Ctrl-C, so we manually propagate it here (sending ourselves
   117  				// a SIGINT signal is not practical)
   118  				return generateTiltfileUserExit, userExitError
   119  			}
   120  			return generateTiltfileError, err
   121  		}
   122  
   123  		line = strings.ToLower(strings.TrimSpace(line))
   124  		if strings.HasPrefix(line, "y") {
   125  			break
   126  		} else if strings.HasPrefix(line, "n") {
   127  			// there's a noticeable delay before further output indicating that
   128  			// Tilt has started, so we don't want users to think Tilt is hung
   129  			postFinishMessage = "Starting Tilt...\n"
   130  			return generateTiltfileDeclined, nil
   131  		}
   132  	}
   133  
   134  	if err = os.WriteFile(tfPath, starterTiltfile, 0644); err != nil {
   135  		return generateTiltfileError, fmt.Errorf("could not write to %s: %v", tfPath, err)
   136  	}
   137  
   138  	postFinishMessage = generateTiltfileSuccessMessage(tfPath)
   139  	return generateTiltfileCreated, nil
   140  }
   141  
   142  func setupTerm(f *os.File) (restore func() error, err error) {
   143  	oldState, err := term.MakeRaw(int(f.Fd()))
   144  	if err != nil {
   145  		return nil, err
   146  	}
   147  	restore = func() error {
   148  		return term.Restore(int(f.Fd()), oldState)
   149  	}
   150  	return restore, nil
   151  }
   152  
   153  func checkTiltfileExists(tfPath string) (bool, error) {
   154  	if fi, err := os.Stat(tfPath); err == nil {
   155  		if fi.Mode().IsDir() {
   156  			return false, fmt.Errorf("could not open Tiltfile at %s: target is a directory", tfPath)
   157  		}
   158  		// Tiltfile exists!
   159  		return true, nil
   160  	} else if errors.Is(err, fs.ErrNotExist) {
   161  		return false, nil
   162  	} else {
   163  		// likely a permissions issue, bubble up the error and exit Tilt
   164  		// N.B. os::Stat always returns a PathError which will include the path in its output
   165  		return false, fmt.Errorf("could not open Tiltfile: %v", err)
   166  	}
   167  }
   168  
   169  func tiltfileDoesNotExistWarning(tfPath string) string {
   170  	return color.YellowString(`
   171  ───────────────────────────────────────────────────────────
   172   ⚠️  No Tiltfile exists at %s
   173  ───────────────────────────────────────────────────────────
   174  `, tfPath)
   175  }
   176  
   177  func generateTiltfileSuccessMessage(tfPath string) string {
   178  	return fmt.Sprintf(`
   179  ───────────────────────────────────────────────────────────
   180   🎉  Tiltfile generated at %s
   181  ───────────────────────────────────────────────────────────
   182  `, color.BlueString(tfPath))
   183  }