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 }