github.com/fastly/cli@v1.7.2-0.20240304164155-9d0f1d77c3bf/pkg/commands/compute/init.go (about)

     1  package compute
     2  
     3  import (
     4  	"crypto/rand"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"io/fs"
     9  	"net/http"
    10  	"os"
    11  	"os/exec"
    12  	"path/filepath"
    13  	"regexp"
    14  	"strconv"
    15  	"strings"
    16  	"time"
    17  
    18  	cp "github.com/otiai10/copy"
    19  
    20  	"github.com/fastly/cli/pkg/argparser"
    21  	"github.com/fastly/cli/pkg/config"
    22  	fsterr "github.com/fastly/cli/pkg/errors"
    23  	fstexec "github.com/fastly/cli/pkg/exec"
    24  	"github.com/fastly/cli/pkg/file"
    25  	"github.com/fastly/cli/pkg/filesystem"
    26  	"github.com/fastly/cli/pkg/global"
    27  	"github.com/fastly/cli/pkg/manifest"
    28  	"github.com/fastly/cli/pkg/profile"
    29  	"github.com/fastly/cli/pkg/text"
    30  )
    31  
    32  var (
    33  	gitRepositoryRegEx        = regexp.MustCompile(`((git|ssh|http(s)?)|(git@[\w\.]+))(:(//)?)([\w\.@\:/\-~]+)(\.git)?(/)?`)
    34  	fastlyOrgRegEx            = regexp.MustCompile(`^https:\/\/github\.com\/fastly`)
    35  	fastlyFileIgnoreListRegEx = regexp.MustCompile(`\.github|LICENSE|SECURITY\.md|CHANGELOG\.md|screenshot\.png`)
    36  )
    37  
    38  // InitCommand initializes a Compute project package on the local machine.
    39  type InitCommand struct {
    40  	argparser.Base
    41  
    42  	branch    string
    43  	dir       string
    44  	cloneFrom string
    45  	language  string
    46  	tag       string
    47  }
    48  
    49  // Languages is a list of supported language options.
    50  var Languages = []string{"rust", "javascript", "go", "other"}
    51  
    52  // NewInitCommand returns a usable command registered under the parent.
    53  func NewInitCommand(parent argparser.Registerer, g *global.Data) *InitCommand {
    54  	var c InitCommand
    55  	c.Globals = g
    56  
    57  	c.CmdClause = parent.Command("init", "Initialize a new Compute package locally")
    58  	c.CmdClause.Flag("author", "Author(s) of the package").Short('a').StringsVar(&g.Manifest.File.Authors)
    59  	c.CmdClause.Flag("branch", "Git branch name to clone from package template repository").Hidden().StringVar(&c.branch)
    60  	c.CmdClause.Flag("directory", "Destination to write the new package, defaulting to the current directory").Short('p').StringVar(&c.dir)
    61  	c.CmdClause.Flag("from", "Local project directory, or Git repository URL, or URL referencing a .zip/.tar.gz file, containing a package template").Short('f').StringVar(&c.cloneFrom)
    62  	c.CmdClause.Flag("language", "Language of the package").Short('l').HintOptions(Languages...).EnumVar(&c.language, Languages...)
    63  	c.CmdClause.Flag("tag", "Git tag name to clone from package template repository").Hidden().StringVar(&c.tag)
    64  
    65  	return &c
    66  }
    67  
    68  // Exec implements the command interface.
    69  func (c *InitCommand) Exec(in io.Reader, out io.Writer) (err error) {
    70  	var introContext string
    71  	if c.cloneFrom != "" {
    72  		introContext = " (using --from to locate package template)"
    73  	}
    74  
    75  	text.Output(out, "Creating a new Compute project%s.\n\n", introContext)
    76  	text.Output(out, "Press ^C at any time to quit.")
    77  
    78  	if c.cloneFrom != "" && c.language == "" {
    79  		text.Warning(out, "\nWhen using the --from flag, the project language cannot be inferred. Please either use the --language flag to explicitly set the language or ensure the project's fastly.toml sets a valid language.")
    80  	}
    81  
    82  	text.Break(out)
    83  	cont, notEmpty, err := c.VerifyDirectory(in, out)
    84  	if err != nil {
    85  		c.Globals.ErrLog.Add(err)
    86  		return err
    87  	}
    88  	if !cont {
    89  		text.Break(out)
    90  		return fsterr.RemediationError{
    91  			Inner:       fmt.Errorf("project directory not empty"),
    92  			Remediation: fsterr.ExistingDirRemediation,
    93  		}
    94  	}
    95  
    96  	defer func(errLog fsterr.LogInterface) {
    97  		if err != nil {
    98  			errLog.Add(err)
    99  		}
   100  	}(c.Globals.ErrLog)
   101  
   102  	wd, err := os.Getwd()
   103  	if err != nil {
   104  		c.Globals.ErrLog.Add(err)
   105  		return fmt.Errorf("error determining current directory: %w", err)
   106  	}
   107  
   108  	mf := c.Globals.Manifest.File
   109  	if c.Globals.Flags.Quiet {
   110  		mf.SetQuiet(true)
   111  	}
   112  	if c.dir == "" && !mf.Exists() && c.Globals.Verbose() {
   113  		text.Info(out, "--directory not specified, using current directory\n\n")
   114  		c.dir = wd
   115  	}
   116  
   117  	spinner, err := text.NewSpinner(out)
   118  	if err != nil {
   119  		return err
   120  	}
   121  
   122  	dst, err := c.VerifyDestination(spinner)
   123  	if err != nil {
   124  		c.Globals.ErrLog.AddWithContext(err, map[string]any{
   125  			"Directory": c.dir,
   126  		})
   127  		return err
   128  	}
   129  	c.dir = dst
   130  
   131  	if notEmpty {
   132  		text.Break(out)
   133  	}
   134  	err = spinner.Process("Validating directory permissions", validateDirectoryPermissions(dst))
   135  	if err != nil {
   136  		return err
   137  	}
   138  
   139  	// Assign the default profile email if available.
   140  	email := ""
   141  	if _, p := profile.Default(c.Globals.Config.Profiles); p != nil {
   142  		email = p.Email
   143  	}
   144  
   145  	name, desc, authors, err := c.PromptOrReturn(email, in, out)
   146  	if err != nil {
   147  		c.Globals.ErrLog.AddWithContext(err, map[string]any{
   148  			"Description": desc,
   149  			"Directory":   c.dir,
   150  		})
   151  		return err
   152  	}
   153  
   154  	languages := NewLanguages(c.Globals.Config.StarterKits)
   155  
   156  	var language *Language
   157  
   158  	if c.language == "" && c.cloneFrom == "" && c.Globals.Manifest.File.Language == "" {
   159  		language, err = c.PromptForLanguage(languages, in, out)
   160  		if err != nil {
   161  			return err
   162  		}
   163  	}
   164  
   165  	// NOTE: The --language flag is an EnumVar, meaning it's already validated.
   166  	if c.language != "" || mf.Language != "" {
   167  		l := c.language
   168  		if c.language == "" {
   169  			l = mf.Language
   170  		}
   171  		for _, recognisedLanguage := range languages {
   172  			if strings.EqualFold(l, recognisedLanguage.Name) {
   173  				language = recognisedLanguage
   174  			}
   175  		}
   176  	}
   177  
   178  	var from, branch, tag string
   179  
   180  	// If the user doesn't tell us where to clone from, or there is already a
   181  	// fastly.toml manifest, or the language they selected was "other" (meaning
   182  	// they're bringing their own project code), then we'll prompt the user to
   183  	// select a starter kit project.
   184  	triggerStarterKitPrompt := c.cloneFrom == "" && !mf.Exists() && language.Name != "other"
   185  	if triggerStarterKitPrompt {
   186  		from, branch, tag, err = c.PromptForStarterKit(language.StarterKits, in, out)
   187  		if err != nil {
   188  			c.Globals.ErrLog.AddWithContext(err, map[string]any{
   189  				"From":           c.cloneFrom,
   190  				"Branch":         c.branch,
   191  				"Tag":            c.tag,
   192  				"Manifest Exist": false,
   193  			})
   194  			return err
   195  		}
   196  		c.cloneFrom = from
   197  	}
   198  
   199  	// We only want to fetch a remote package if c.cloneFrom has been set.
   200  	// This can happen in two ways:
   201  	//
   202  	// 1. --from flag is set
   203  	// 2. user selects starter kit when prompted
   204  	//
   205  	// We don't fetch if the user has indicated their language of choice is
   206  	// "other" because this means they intend on handling the compilation of code
   207  	// that isn't natively supported by the platform.
   208  	if c.cloneFrom != "" {
   209  		err = c.FetchPackageTemplate(branch, tag, file.Archives, spinner, out)
   210  		if err != nil {
   211  			c.Globals.ErrLog.AddWithContext(err, map[string]any{
   212  				"From":      from,
   213  				"Branch":    branch,
   214  				"Tag":       tag,
   215  				"Directory": c.dir,
   216  			})
   217  			return err
   218  		}
   219  	}
   220  
   221  	// If the user was prompted to fill the name/desc/authors/lang, then we insert
   222  	// a line break so the following spinner instances have spacing. But only if
   223  	// the starter kit wasn't prompted for as that already handles spacing.
   224  	if (mf.Name == "" || mf.Description == "" || mf.Language == "" || len(mf.Authors) == 0) && !triggerStarterKitPrompt {
   225  		text.Break(out)
   226  	}
   227  
   228  	mf, err = c.UpdateManifest(mf, spinner, name, desc, authors, language)
   229  	if err != nil {
   230  		c.Globals.ErrLog.AddWithContext(err, map[string]any{
   231  			"Directory":   c.dir,
   232  			"Description": desc,
   233  			"Language":    language,
   234  		})
   235  		return err
   236  	}
   237  
   238  	language, err = c.InitializeLanguage(spinner, language, languages, mf.Language, wd)
   239  	if err != nil {
   240  		c.Globals.ErrLog.Add(err)
   241  		return fmt.Errorf("error initializing package: %w", err)
   242  	}
   243  
   244  	var md manifest.Data
   245  	err = md.File.Read(manifest.Filename)
   246  	if err != nil {
   247  		return fmt.Errorf("failed to read manifest after initialisation: %w", err)
   248  	}
   249  
   250  	postInit := md.File.Scripts.PostInit
   251  	if postInit != "" {
   252  		if !c.Globals.Flags.AutoYes && !c.Globals.Flags.NonInteractive {
   253  			msg := fmt.Sprintf(CustomPostScriptMessage, "init", manifest.Filename)
   254  			err := promptForPostInitContinue(msg, postInit, out, in)
   255  			if err != nil {
   256  				if errors.Is(err, fsterr.ErrPostInitStopped) {
   257  					displayInitOutput(mf.Name, dst, language.Name, out)
   258  					return nil
   259  				}
   260  				return err
   261  			}
   262  		}
   263  
   264  		if c.Globals.Flags.Verbose && len(md.File.Scripts.EnvVars) > 0 {
   265  			text.Description(out, "Environment variables set", strings.Join(md.File.Scripts.EnvVars, " "))
   266  		}
   267  
   268  		// If we're in verbose mode, the command output is shown.
   269  		// So in that case we don't want to have a spinner as it'll interweave output.
   270  		// In non-verbose mode we have a spinner running while the command execution is happening.
   271  		msg := "Running [scripts.post_init]..."
   272  		if !c.Globals.Flags.Verbose {
   273  			err = spinner.Start()
   274  			if err != nil {
   275  				return err
   276  			}
   277  			spinner.Message(msg)
   278  		}
   279  
   280  		s := Shell{}
   281  		command, args := s.Build(postInit)
   282  		// gosec flagged this:
   283  		// G204 (CWE-78): Subprocess launched with function call as argument or cmd arguments
   284  		// Disabling as we require the user to provide this command.
   285  		// #nosec
   286  		// nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command
   287  		err := fstexec.Command(fstexec.CommandOpts{
   288  			Args:           args,
   289  			Command:        command,
   290  			Env:            md.File.Scripts.EnvVars,
   291  			ErrLog:         c.Globals.ErrLog,
   292  			Output:         out,
   293  			Spinner:        spinner,
   294  			SpinnerMessage: msg,
   295  			Timeout:        0, // zero indicates no timeout
   296  			Verbose:        c.Globals.Flags.Verbose,
   297  		})
   298  		if err != nil {
   299  			// In verbose mode we'll have the failure status AFTER the error output.
   300  			// But we can't just call StopFailMessage() without first starting the spinner.
   301  			if c.Globals.Flags.Verbose {
   302  				text.Break(out)
   303  				spinErr := spinner.Start()
   304  				if spinErr != nil {
   305  					return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
   306  				}
   307  				spinner.Message(msg + "...")
   308  				spinner.StopFailMessage(msg)
   309  				spinErr = spinner.StopFail()
   310  				if spinErr != nil {
   311  					return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
   312  				}
   313  			}
   314  			return err
   315  		}
   316  
   317  		// In verbose mode we'll have the failure status AFTER the error output.
   318  		// But we can't just call StopMessage() without first starting the spinner.
   319  		if c.Globals.Flags.Verbose {
   320  			err = spinner.Start()
   321  			if err != nil {
   322  				return err
   323  			}
   324  			spinner.Message(msg + "...")
   325  			text.Break(out)
   326  		}
   327  
   328  		spinner.StopMessage(msg)
   329  		err = spinner.Stop()
   330  		if err != nil {
   331  			return err
   332  		}
   333  	}
   334  
   335  	displayInitOutput(mf.Name, dst, language.Name, out)
   336  	return nil
   337  }
   338  
   339  // VerifyDirectory indicates if the user wants to continue with the execution
   340  // flow when presented with a prompt that suggests the current directory isn't
   341  // empty.
   342  func (c *InitCommand) VerifyDirectory(in io.Reader, out io.Writer) (cont, notEmpty bool, err error) {
   343  	flags := c.Globals.Flags
   344  	dir := c.dir
   345  
   346  	if dir == "" {
   347  		dir = "."
   348  	}
   349  	dir, err = filepath.Abs(dir)
   350  	if err != nil {
   351  		return false, false, err
   352  	}
   353  
   354  	files, err := os.ReadDir(dir)
   355  	if err != nil {
   356  		return false, false, err
   357  	}
   358  
   359  	if strings.Contains(dir, " ") && !flags.Quiet {
   360  		text.Warning(out, "Your project path contains spaces. In some cases this can result in issues with your installed language toolchain, e.g. `npm`. Consider removing any spaces.\n\n")
   361  	}
   362  
   363  	if len(files) > 0 && !flags.AutoYes && !flags.NonInteractive {
   364  		label := fmt.Sprintf("The current directory isn't empty. Are you sure you want to initialize a Compute project in %s? [y/N] ", dir)
   365  		result, err := text.AskYesNo(out, label, in)
   366  		if err != nil {
   367  			return false, true, err
   368  		}
   369  		return result, true, nil
   370  	}
   371  
   372  	return true, false, nil
   373  }
   374  
   375  // VerifyDestination checks the provided path exists and is a directory.
   376  //
   377  // NOTE: For validating user permissions it will create a temporary file within
   378  // the directory and then remove it before returning the absolute path to the
   379  // directory itself.
   380  func (c *InitCommand) VerifyDestination(spinner text.Spinner) (dst string, err error) {
   381  	dst, err = filepath.Abs(c.dir)
   382  	if err != nil {
   383  		return "", err
   384  	}
   385  	fi, err := os.Stat(dst)
   386  	if err != nil && !errors.Is(err, fs.ErrNotExist) {
   387  		return dst, fmt.Errorf("couldn't verify package directory: %w", err) // generic error
   388  	}
   389  	if err == nil && !fi.IsDir() {
   390  		return dst, fmt.Errorf("package destination is not a directory") // specific problem
   391  	}
   392  	if err != nil && errors.Is(err, fs.ErrNotExist) { // normal-ish case
   393  		err := spinner.Process(fmt.Sprintf("Creating %s", dst), func(_ *text.SpinnerWrapper) error {
   394  			if err := os.MkdirAll(dst, 0o700); err != nil {
   395  				return fmt.Errorf("error creating package destination: %w", err)
   396  			}
   397  			return nil
   398  		})
   399  		if err != nil {
   400  			return "", err
   401  		}
   402  	}
   403  	return dst, nil
   404  }
   405  
   406  func validateDirectoryPermissions(dst string) text.SpinnerProcess {
   407  	return func(_ *text.SpinnerWrapper) error {
   408  		tmpname := make([]byte, 16)
   409  		n, err := rand.Read(tmpname)
   410  		if err != nil {
   411  			return fmt.Errorf("error generating random filename: %w", err)
   412  		}
   413  		if n != 16 {
   414  			return fmt.Errorf("failed to generate enough entropy (%d/%d)", n, 16)
   415  		}
   416  
   417  		// gosec flagged this:
   418  		// G304 (CWE-22): Potential file inclusion via variable
   419  		//
   420  		// Disabling as the input is determined by our own package.
   421  		// #nosec
   422  		f, err := os.Create(filepath.Join(dst, fmt.Sprintf("tmp_%x", tmpname)))
   423  		if err != nil {
   424  			return fmt.Errorf("error creating file in package destination: %w", err)
   425  		}
   426  
   427  		if err := f.Close(); err != nil {
   428  			return fmt.Errorf("error closing file in package destination: %w", err)
   429  		}
   430  
   431  		if err := os.Remove(f.Name()); err != nil {
   432  			return fmt.Errorf("error removing file in package destination: %w", err)
   433  		}
   434  		return nil
   435  	}
   436  }
   437  
   438  // PromptOrReturn will prompt the user for information missing from the
   439  // fastly.toml manifest file, otherwise if it already exists then the value is
   440  // returned as is.
   441  func (c *InitCommand) PromptOrReturn(email string, in io.Reader, out io.Writer) (name, description string, authors []string, err error) {
   442  	flags := c.Globals.Flags
   443  	name, _ = c.Globals.Manifest.Name()
   444  	description, _ = c.Globals.Manifest.Description()
   445  	authors, _ = c.Globals.Manifest.Authors()
   446  
   447  	if name == "" && !flags.AcceptDefaults && !flags.NonInteractive {
   448  		text.Break(out)
   449  	}
   450  	name, err = c.PromptPackageName(flags, name, in, out)
   451  	if err != nil {
   452  		return "", description, authors, err
   453  	}
   454  
   455  	if description == "" && !flags.AcceptDefaults && !flags.NonInteractive {
   456  		text.Break(out)
   457  	}
   458  	description, err = promptPackageDescription(flags, description, in, out)
   459  	if err != nil {
   460  		return name, "", authors, err
   461  	}
   462  
   463  	if len(authors) == 0 && !flags.AcceptDefaults && !flags.NonInteractive {
   464  		text.Break(out)
   465  	}
   466  	authors, err = promptPackageAuthors(flags, authors, email, in, out)
   467  	if err != nil {
   468  		return name, description, []string{}, err
   469  	}
   470  
   471  	return name, description, authors, nil
   472  }
   473  
   474  // PromptPackageName prompts the user for a package name unless already defined either
   475  // via the corresponding CLI flag or the manifest file.
   476  //
   477  // It will use a default of the current directory path if no value provided by
   478  // the user via the prompt.
   479  func (c *InitCommand) PromptPackageName(flags global.Flags, name string, in io.Reader, out io.Writer) (string, error) {
   480  	defaultName := filepath.Base(c.dir)
   481  
   482  	if name == "" && (flags.AcceptDefaults || flags.NonInteractive) {
   483  		return defaultName, nil
   484  	}
   485  
   486  	if name == "" {
   487  		var err error
   488  		name, err = text.Input(out, fmt.Sprintf("Name: [%s] ", defaultName), in)
   489  		if err != nil {
   490  			return "", fmt.Errorf("error reading input: %w", err)
   491  		}
   492  		if name == "" {
   493  			name = defaultName
   494  		}
   495  	}
   496  
   497  	return name, nil
   498  }
   499  
   500  // promptPackageDescription prompts the user for a package description unless already
   501  // defined either via the corresponding CLI flag or the manifest file.
   502  func promptPackageDescription(flags global.Flags, desc string, in io.Reader, out io.Writer) (string, error) {
   503  	if desc == "" && (flags.AcceptDefaults || flags.NonInteractive) {
   504  		return desc, nil
   505  	}
   506  
   507  	if desc == "" {
   508  		var err error
   509  
   510  		desc, err = text.Input(out, "Description: ", in)
   511  		if err != nil {
   512  			return "", fmt.Errorf("error reading input: %w", err)
   513  		}
   514  	}
   515  
   516  	return desc, nil
   517  }
   518  
   519  // promptPackageAuthors prompts the user for a package name unless already defined
   520  // either via the corresponding CLI flag or the manifest file.
   521  //
   522  // It will use a default of the user's email found within the manifest, if set
   523  // there, otherwise the value will be an empty slice.
   524  //
   525  // FIXME: Handle prompting for multiple authors.
   526  func promptPackageAuthors(flags global.Flags, authors []string, manifestEmail string, in io.Reader, out io.Writer) ([]string, error) {
   527  	defaultValue := []string{manifestEmail}
   528  	if len(authors) == 0 && (flags.AcceptDefaults || flags.NonInteractive) {
   529  		return defaultValue, nil
   530  	}
   531  	if len(authors) == 0 {
   532  		label := "Author (email): "
   533  
   534  		if manifestEmail != "" {
   535  			label = fmt.Sprintf("%s[%s] ", label, manifestEmail)
   536  		}
   537  
   538  		author, err := text.Input(out, label, in)
   539  		if err != nil {
   540  			return []string{}, fmt.Errorf("error reading input %w", err)
   541  		}
   542  
   543  		if author != "" {
   544  			authors = []string{author}
   545  		} else {
   546  			authors = defaultValue
   547  		}
   548  	}
   549  
   550  	return authors, nil
   551  }
   552  
   553  // PromptForLanguage prompts the user for a package language unless already
   554  // defined either via the corresponding CLI flag or the manifest file.
   555  func (c *InitCommand) PromptForLanguage(languages []*Language, in io.Reader, out io.Writer) (*Language, error) {
   556  	var (
   557  		language *Language
   558  		option   string
   559  		err      error
   560  	)
   561  	flags := c.Globals.Flags
   562  
   563  	if !flags.AcceptDefaults && !flags.NonInteractive {
   564  		text.Output(out, "\n%s", text.Bold("Language:"))
   565  		text.Output(out, "(Find out more about language support at https://developer.fastly.com/learning/compute)")
   566  		for i, lang := range languages {
   567  			text.Output(out, "[%d] %s", i+1, lang.DisplayName)
   568  		}
   569  
   570  		text.Break(out)
   571  		option, err = text.Input(out, "Choose option: [1] ", in, validateLanguageOption(languages))
   572  		if err != nil {
   573  			return nil, fmt.Errorf("reading input %w", err)
   574  		}
   575  	}
   576  
   577  	if option == "" {
   578  		option = "1"
   579  	}
   580  
   581  	i, err := strconv.Atoi(option)
   582  	if err != nil {
   583  		return nil, fmt.Errorf("failed to identify chosen language")
   584  	}
   585  	language = languages[i-1]
   586  
   587  	return language, nil
   588  }
   589  
   590  // validateLanguageOption ensures the user selects an appropriate value from
   591  // the prompt options displayed.
   592  func validateLanguageOption(languages []*Language) func(string) error {
   593  	return func(input string) error {
   594  		errMsg := fmt.Errorf("must be a valid option")
   595  		if input == "" {
   596  			return nil
   597  		}
   598  		if option, err := strconv.Atoi(input); err == nil {
   599  			if option > len(languages) {
   600  				return errMsg
   601  			}
   602  			return nil
   603  		}
   604  		return errMsg
   605  	}
   606  }
   607  
   608  // PromptForStarterKit prompts the user for a package starter kit.
   609  //
   610  // It returns the path to the starter kit, and the corresponding branch/tag.
   611  func (c *InitCommand) PromptForStarterKit(kits []config.StarterKit, in io.Reader, out io.Writer) (from string, branch string, tag string, err error) {
   612  	var option string
   613  	flags := c.Globals.Flags
   614  
   615  	if !flags.AcceptDefaults && !flags.NonInteractive {
   616  		text.Output(out, "\n%s", text.Bold("Starter kit:"))
   617  		for i, kit := range kits {
   618  			fmt.Fprintf(out, "[%d] %s\n", i+1, text.Bold(kit.Name))
   619  			text.Indent(out, 4, "%s\n%s", kit.Description, kit.Path)
   620  		}
   621  		text.Info(out, "\nFor a complete list of Starter Kits:")
   622  		text.Indent(out, 4, "https://developer.fastly.com/solutions/starters/")
   623  		text.Break(out)
   624  
   625  		option, err = text.Input(out, "Choose option or paste git URL: [1] ", in, validateTemplateOptionOrURL(kits))
   626  		if err != nil {
   627  			return "", "", "", fmt.Errorf("error reading input: %w", err)
   628  		}
   629  		text.Break(out)
   630  	}
   631  
   632  	if option == "" {
   633  		option = "1"
   634  	}
   635  
   636  	var i int
   637  	if i, err = strconv.Atoi(option); err == nil {
   638  		template := kits[i-1]
   639  		return template.Path, template.Branch, template.Tag, nil
   640  	}
   641  
   642  	return option, "", "", nil
   643  }
   644  
   645  func validateTemplateOptionOrURL(templates []config.StarterKit) func(string) error {
   646  	return func(input string) error {
   647  		msg := "must be a valid option or git URL"
   648  		if input == "" {
   649  			return nil
   650  		}
   651  		if option, err := strconv.Atoi(input); err == nil {
   652  			if option > len(templates) {
   653  				return fmt.Errorf(msg)
   654  			}
   655  			return nil
   656  		}
   657  		if !gitRepositoryRegEx.MatchString(input) {
   658  			return fmt.Errorf(msg)
   659  		}
   660  		return nil
   661  	}
   662  }
   663  
   664  // FetchPackageTemplate will determine if the package code should be fetched
   665  // from GitHub using the git binary to clone the source or a HTTP request that
   666  // uses content-negotiation to determine the type of archive format used.
   667  func (c *InitCommand) FetchPackageTemplate(branch, tag string, archives []file.Archive, spinner text.Spinner, out io.Writer) error {
   668  	err := spinner.Start()
   669  	if err != nil {
   670  		return err
   671  	}
   672  	msg := "Fetching package template"
   673  	spinner.Message(msg + "...")
   674  
   675  	// If the user has provided a local file path, we'll recursively copy the
   676  	// directory to c.dir.
   677  	if fi, err := os.Stat(c.cloneFrom); err == nil && fi.IsDir() {
   678  		if err := cp.Copy(c.cloneFrom, c.dir); err != nil {
   679  			spinner.StopFailMessage(msg)
   680  			spinErr := spinner.StopFail()
   681  			if spinErr != nil {
   682  				return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
   683  			}
   684  			return err
   685  		}
   686  		spinner.StopMessage(msg)
   687  		return spinner.Stop()
   688  	}
   689  	c.Globals.ErrLog.Add(err)
   690  
   691  	req, err := http.NewRequest("GET", c.cloneFrom, nil)
   692  	if err != nil {
   693  		err = fmt.Errorf("failed to construct package request URL: %w", err)
   694  		c.Globals.ErrLog.Add(err)
   695  
   696  		if gitRepositoryRegEx.MatchString(c.cloneFrom) {
   697  			if err := c.ClonePackageFromEndpoint(branch, tag); err != nil {
   698  				spinner.StopFailMessage(msg)
   699  				spinErr := spinner.StopFail()
   700  				if spinErr != nil {
   701  					return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
   702  				}
   703  				return err
   704  			}
   705  			spinner.StopMessage(msg)
   706  			return spinner.Stop()
   707  		}
   708  
   709  		spinner.StopFailMessage(msg)
   710  		spinErr := spinner.StopFail()
   711  		if spinErr != nil {
   712  			return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
   713  		}
   714  		return err
   715  	}
   716  
   717  	for _, archive := range archives {
   718  		for _, mime := range archive.MimeTypes() {
   719  			req.Header.Add("Accept", mime)
   720  		}
   721  	}
   722  
   723  	res, err := c.Globals.HTTPClient.Do(req)
   724  	if err != nil {
   725  		err = fmt.Errorf("failed to get package: %w", err)
   726  		c.Globals.ErrLog.Add(err)
   727  		spinner.StopFailMessage(msg)
   728  		spinErr := spinner.StopFail()
   729  		if spinErr != nil {
   730  			return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
   731  		}
   732  		return err
   733  	}
   734  	defer res.Body.Close() // #nosec G307
   735  
   736  	if res.StatusCode != http.StatusOK {
   737  		err := fmt.Errorf("failed to get package: %s", res.Status)
   738  		c.Globals.ErrLog.Add(err)
   739  		spinner.StopFailMessage(msg)
   740  		spinErr := spinner.StopFail()
   741  		if spinErr != nil {
   742  			return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
   743  		}
   744  		return err
   745  	}
   746  
   747  	filename := filepath.Base(c.cloneFrom)
   748  	ext := filepath.Ext(filename)
   749  
   750  	// gosec flagged this:
   751  	// G304 (CWE-22): Potential file inclusion via variable
   752  	//
   753  	// Disabling as we require a user to configure their own environment.
   754  	/* #nosec */
   755  	f, err := os.Create(filename)
   756  	if err != nil {
   757  		err = fmt.Errorf("failed to create local %s archive: %w", filename, err)
   758  		c.Globals.ErrLog.Add(err)
   759  		spinner.StopFailMessage(msg)
   760  		spinErr := spinner.StopFail()
   761  		if spinErr != nil {
   762  			return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
   763  		}
   764  		return err
   765  	}
   766  	defer func() {
   767  		// NOTE: Later on we rename the file to include an extension and the
   768  		// following call to os.Remove works still because the `filename` variable
   769  		// that is still in scope is also updated to include the extension.
   770  		err := os.Remove(filename)
   771  		if err != nil {
   772  			c.Globals.ErrLog.Add(err)
   773  			text.Info(out, "We were unable to clean-up the local %s file (it can be safely removed)", filename)
   774  		}
   775  	}()
   776  
   777  	_, err = io.Copy(f, res.Body)
   778  	if err != nil {
   779  		err = fmt.Errorf("failed to write %s archive to disk: %w", filename, err)
   780  		c.Globals.ErrLog.Add(err)
   781  		spinner.StopFailMessage(msg)
   782  		spinErr := spinner.StopFail()
   783  		if spinErr != nil {
   784  			return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
   785  		}
   786  		return err
   787  	}
   788  
   789  	// NOTE: We used to `defer` the closing of the file after its creation but
   790  	// realised that this caused issues on Windows as it was unable to rename the
   791  	// file as we still have the descriptor `f` open.
   792  	if err := f.Close(); err != nil {
   793  		c.Globals.ErrLog.Add(err)
   794  	}
   795  
   796  	var archive file.Archive
   797  
   798  mimes:
   799  	for _, mimetype := range res.Header.Values("Content-Type") {
   800  		for _, a := range archives {
   801  			for _, mime := range a.MimeTypes() {
   802  				if mimetype == mime {
   803  					archive = a
   804  					break mimes
   805  				}
   806  			}
   807  		}
   808  	}
   809  
   810  	if archive == nil {
   811  		for _, a := range archives {
   812  			for _, e := range a.Extensions() {
   813  				if ext == e {
   814  					archive = a
   815  					break
   816  				}
   817  			}
   818  		}
   819  	}
   820  
   821  	if archive != nil {
   822  		// Ensure there is a file extension on our filename, otherwise we won't
   823  		// know what type of archive format we're dealing with when we come to call
   824  		// the archive.Extract() method.
   825  		if ext == "" {
   826  			filenameWithExt := filename + archive.Extensions()[0]
   827  			err := os.Rename(filename, filenameWithExt)
   828  			if err != nil {
   829  				c.Globals.ErrLog.Add(err)
   830  				spinner.StopFailMessage(msg)
   831  				spinErr := spinner.StopFail()
   832  				if spinErr != nil {
   833  					return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
   834  				}
   835  				return err
   836  			}
   837  			filename = filenameWithExt
   838  		}
   839  
   840  		archive.SetDestination(c.dir)
   841  		archive.SetFilename(filename)
   842  
   843  		err = archive.Extract()
   844  		if err != nil {
   845  			err = fmt.Errorf("failed to extract %s archive content: %w", filename, err)
   846  			c.Globals.ErrLog.Add(err)
   847  			spinner.StopFailMessage(msg)
   848  			spinErr := spinner.StopFail()
   849  			if spinErr != nil {
   850  				return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
   851  			}
   852  			return err
   853  		}
   854  
   855  		spinner.StopMessage(msg)
   856  		return spinner.Stop()
   857  	}
   858  
   859  	if err := c.ClonePackageFromEndpoint(branch, tag); err != nil {
   860  		spinner.StopFailMessage(msg)
   861  		spinErr := spinner.StopFail()
   862  		if spinErr != nil {
   863  			return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
   864  		}
   865  		return err
   866  	}
   867  
   868  	spinner.StopMessage(msg)
   869  	return spinner.Stop()
   870  }
   871  
   872  // ClonePackageFromEndpoint clones the given repo (from) into a temp directory,
   873  // then copies specific files to the destination directory (path).
   874  func (c *InitCommand) ClonePackageFromEndpoint(branch, tag string) error {
   875  	from := c.cloneFrom
   876  
   877  	_, err := exec.LookPath("git")
   878  	if err != nil {
   879  		return fsterr.RemediationError{
   880  			Inner:       fmt.Errorf("`git` not found in $PATH"),
   881  			Remediation: fmt.Sprintf("The Fastly CLI requires a local installation of git.  For installation instructions for your operating system see:\n\n\t$ %s", text.Bold("https://git-scm.com/book/en/v2/Getting-Started-Installing-Git")),
   882  		}
   883  	}
   884  
   885  	tempdir, err := tempDir("package-init")
   886  	if err != nil {
   887  		return fmt.Errorf("error creating temporary path for package template: %w", err)
   888  	}
   889  	defer os.RemoveAll(tempdir)
   890  
   891  	if branch != "" && tag != "" {
   892  		return fmt.Errorf("cannot use both git branch and tag name")
   893  	}
   894  
   895  	args := []string{
   896  		"clone",
   897  		"--depth",
   898  		"1",
   899  	}
   900  	var ref string
   901  	if branch != "" {
   902  		ref = branch
   903  	}
   904  	if tag != "" {
   905  		ref = tag
   906  	}
   907  	if ref != "" {
   908  		args = append(args, "--branch", ref)
   909  	}
   910  	args = append(args, from, tempdir)
   911  
   912  	// gosec flagged this:
   913  	// G204 (CWE-78): Subprocess launched with variable
   914  	// Disabling as there should be no vulnerability to cloning a remote repo.
   915  	/* #nosec */
   916  	command := exec.Command("git", args...)
   917  
   918  	// nosemgrep (invalid-usage-of-modified-variable)
   919  	stdoutStderr, err := command.CombinedOutput()
   920  	if err != nil {
   921  		return fmt.Errorf("error fetching package template: %w\n\n%s", err, stdoutStderr)
   922  	}
   923  
   924  	if err := os.RemoveAll(filepath.Join(tempdir, ".git")); err != nil {
   925  		return fmt.Errorf("error removing git metadata from package template: %w", err)
   926  	}
   927  
   928  	err = filepath.Walk(tempdir, func(path string, info os.FileInfo, err error) error {
   929  		if err != nil {
   930  			return err // abort
   931  		}
   932  
   933  		if info.IsDir() {
   934  			return nil // descend
   935  		}
   936  
   937  		rel, err := filepath.Rel(tempdir, path)
   938  		if err != nil {
   939  			return err
   940  		}
   941  
   942  		// Filter any files we want to ignore in Fastly-owned templates.
   943  		if fastlyOrgRegEx.MatchString(from) && fastlyFileIgnoreListRegEx.MatchString(rel) {
   944  			return nil
   945  		}
   946  
   947  		dst := filepath.Join(c.dir, rel)
   948  		if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
   949  			return err
   950  		}
   951  
   952  		return filesystem.CopyFile(path, dst)
   953  	})
   954  
   955  	if err != nil {
   956  		return fmt.Errorf("error copying files from package template: %w", err)
   957  	}
   958  
   959  	return nil
   960  }
   961  
   962  func tempDir(prefix string) (abspath string, err error) {
   963  	abspath, err = filepath.Abs(filepath.Join(
   964  		os.TempDir(),
   965  		fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()),
   966  	))
   967  	if err != nil {
   968  		return "", err
   969  	}
   970  
   971  	if err = os.MkdirAll(abspath, 0o750); err != nil {
   972  		return "", err
   973  	}
   974  
   975  	return abspath, nil
   976  }
   977  
   978  // UpdateManifest updates the manifest with data acquired from various sources.
   979  // e.g. prompting the user, existing manifest file.
   980  //
   981  // NOTE: The language argument might be nil (if the user passes --from flag).
   982  func (c *InitCommand) UpdateManifest(m manifest.File, spinner text.Spinner, name, desc string, authors []string, language *Language) (manifest.File, error) {
   983  	var returnEarly bool
   984  	mp := filepath.Join(c.dir, manifest.Filename)
   985  
   986  	err := spinner.Process("Reading fastly.toml", func(_ *text.SpinnerWrapper) error {
   987  		if err := m.Read(mp); err != nil {
   988  			if language != nil {
   989  				if language.Name == "other" {
   990  					// We create a fastly.toml manifest on behalf of the user if they're
   991  					// bringing their own pre-compiled Wasm binary to be packaged.
   992  					m.ManifestVersion = manifest.ManifestLatestVersion
   993  					m.Name = name
   994  					m.Description = desc
   995  					m.Authors = authors
   996  					m.Language = language.Name
   997  					m.ClonedFrom = c.cloneFrom
   998  					if err := m.Write(mp); err != nil {
   999  						return fmt.Errorf("error saving fastly.toml: %w", err)
  1000  					}
  1001  					returnEarly = true
  1002  					return nil // EXIT updateManifest
  1003  				}
  1004  			}
  1005  			return fmt.Errorf("error reading fastly.toml: %w", err)
  1006  		}
  1007  		return nil
  1008  	})
  1009  	if err != nil {
  1010  		return m, err
  1011  	}
  1012  	if returnEarly {
  1013  		return m, nil
  1014  	}
  1015  
  1016  	err = spinner.Process(fmt.Sprintf("Setting package name in manifest to %q", name), func(_ *text.SpinnerWrapper) error {
  1017  		m.Name = name
  1018  		return nil
  1019  	})
  1020  	if err != nil {
  1021  		return m, err
  1022  	}
  1023  
  1024  	var descMsg string
  1025  	if desc != "" {
  1026  		descMsg = " to '" + desc + "'"
  1027  	}
  1028  
  1029  	err = spinner.Process(fmt.Sprintf("Setting description in manifest%s", descMsg), func(_ *text.SpinnerWrapper) error {
  1030  		// NOTE: We allow an empty description to be set.
  1031  		m.Description = desc
  1032  		return nil
  1033  	})
  1034  	if err != nil {
  1035  		return m, err
  1036  	}
  1037  
  1038  	if len(authors) > 0 {
  1039  		err = spinner.Process(fmt.Sprintf("Setting authors in manifest to '%s'", strings.Join(authors, ", ")), func(_ *text.SpinnerWrapper) error {
  1040  			m.Authors = authors
  1041  			return nil
  1042  		})
  1043  		if err != nil {
  1044  			return m, err
  1045  		}
  1046  	}
  1047  
  1048  	if language != nil {
  1049  		err = spinner.Process(fmt.Sprintf("Setting language in manifest to '%s'", language.Name), func(_ *text.SpinnerWrapper) error {
  1050  			m.Language = language.Name
  1051  			return nil
  1052  		})
  1053  		if err != nil {
  1054  			return m, err
  1055  		}
  1056  	}
  1057  
  1058  	m.ClonedFrom = c.cloneFrom
  1059  
  1060  	err = spinner.Process("Saving manifest changes", func(_ *text.SpinnerWrapper) error {
  1061  		if err := m.Write(mp); err != nil {
  1062  			return fmt.Errorf("error saving fastly.toml: %w", err)
  1063  		}
  1064  		return nil
  1065  	})
  1066  	return m, err
  1067  }
  1068  
  1069  // InitializeLanguage for newly cloned package.
  1070  func (c *InitCommand) InitializeLanguage(spinner text.Spinner, language *Language, languages []*Language, name, wd string) (*Language, error) {
  1071  	err := spinner.Process("Initializing package", func(_ *text.SpinnerWrapper) error {
  1072  		if wd != c.dir {
  1073  			err := os.Chdir(c.dir)
  1074  			if err != nil {
  1075  				return fmt.Errorf("error changing to your project directory: %w", err)
  1076  			}
  1077  		}
  1078  
  1079  		// Language will not be set if user provides the --from flag. So we'll check
  1080  		// the manifest content and ensure what's set there is the language instance
  1081  		// used for the sake of `compute build` operations.
  1082  		if language == nil {
  1083  			var match bool
  1084  			for _, l := range languages {
  1085  				if strings.EqualFold(name, l.Name) {
  1086  					language = l
  1087  					match = true
  1088  					break
  1089  				}
  1090  			}
  1091  			if !match {
  1092  				return fmt.Errorf("unrecognised package language")
  1093  			}
  1094  		}
  1095  		return nil
  1096  	})
  1097  	if err != nil {
  1098  		return nil, err
  1099  	}
  1100  
  1101  	return language, nil
  1102  }
  1103  
  1104  // promptForPostInitContinue ensures the user is happy to continue with running
  1105  // the define post_init script in the fastly.toml manifest file.
  1106  func promptForPostInitContinue(msg, script string, out io.Writer, in io.Reader) error {
  1107  	text.Info(out, "\n%s:\n", msg)
  1108  	text.Indent(out, 4, "%s", script)
  1109  
  1110  	label := "\nDo you want to run this now? [y/N] "
  1111  	answer, err := text.AskYesNo(out, label, in)
  1112  	if err != nil {
  1113  		return err
  1114  	}
  1115  	if !answer {
  1116  		return fsterr.ErrPostInitStopped
  1117  	}
  1118  	text.Break(out)
  1119  	return nil
  1120  }
  1121  
  1122  // displayInitOutput of package information and useful links.
  1123  func displayInitOutput(name, dst, language string, out io.Writer) {
  1124  	text.Break(out)
  1125  	text.Description(out, fmt.Sprintf("Initialized package %s to", text.Bold(name)), dst)
  1126  
  1127  	if language == "other" {
  1128  		text.Description(out, "To package a pre-compiled Wasm binary for deployment, run", "fastly compute pack")
  1129  		text.Description(out, "To deploy the package, run", "fastly compute deploy")
  1130  	} else {
  1131  		text.Description(out, "To publish the package (build and deploy), run", "fastly compute publish")
  1132  	}
  1133  
  1134  	text.Description(out, "To learn about deploying Compute projects using third-party orchestration tools, visit", "https://developer.fastly.com/learning/integrations/orchestration/")
  1135  	text.Success(out, "Initialized package %s", text.Bold(name))
  1136  }