github.com/go-asm/go@v1.21.1-0.20240213172139-40c5ead50c48/cmd/go/script/cmds.go (about)

     1  // Copyright 2022 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package script
     6  
     7  import (
     8  	"errors"
     9  	"fmt"
    10  	"io/fs"
    11  	"os"
    12  	"os/exec"
    13  	"path/filepath"
    14  	"regexp"
    15  	"runtime"
    16  	"strconv"
    17  	"strings"
    18  	"sync"
    19  	"time"
    20  
    21  	"github.com/go-asm/go/cmd/go/cfg"
    22  	"github.com/go-asm/go/cmd/go/robustio"
    23  	"github.com/go-asm/go/diff"
    24  )
    25  
    26  // DefaultCmds returns a set of broadly useful script commands.
    27  //
    28  // Run the 'help' command within a script engine to view a list of the available
    29  // commands.
    30  func DefaultCmds() map[string]Cmd {
    31  	return map[string]Cmd{
    32  		"cat":     Cat(),
    33  		"cd":      Cd(),
    34  		"chmod":   Chmod(),
    35  		"cmp":     Cmp(),
    36  		"cmpenv":  Cmpenv(),
    37  		"cp":      Cp(),
    38  		"echo":    Echo(),
    39  		"env":     Env(),
    40  		"exec":    Exec(func(cmd *exec.Cmd) error { return cmd.Process.Signal(os.Interrupt) }, 100*time.Millisecond), // arbitrary grace period
    41  		"exists":  Exists(),
    42  		"grep":    Grep(),
    43  		"help":    Help(),
    44  		"mkdir":   Mkdir(),
    45  		"mv":      Mv(),
    46  		"rm":      Rm(),
    47  		"replace": Replace(),
    48  		"sleep":   Sleep(),
    49  		"stderr":  Stderr(),
    50  		"stdout":  Stdout(),
    51  		"stop":    Stop(),
    52  		"symlink": Symlink(),
    53  		"wait":    Wait(),
    54  	}
    55  }
    56  
    57  // Command returns a new Cmd with a Usage method that returns a copy of the
    58  // given CmdUsage and a Run method calls the given function.
    59  func Command(usage CmdUsage, run func(*State, ...string) (WaitFunc, error)) Cmd {
    60  	return &funcCmd{
    61  		usage: usage,
    62  		run:   run,
    63  	}
    64  }
    65  
    66  // A funcCmd implements Cmd using a function value.
    67  type funcCmd struct {
    68  	usage CmdUsage
    69  	run   func(*State, ...string) (WaitFunc, error)
    70  }
    71  
    72  func (c *funcCmd) Run(s *State, args ...string) (WaitFunc, error) {
    73  	return c.run(s, args...)
    74  }
    75  
    76  func (c *funcCmd) Usage() *CmdUsage { return &c.usage }
    77  
    78  // firstNonFlag returns a slice containing the index of the first argument in
    79  // rawArgs that is not a flag, or nil if all arguments are flags.
    80  func firstNonFlag(rawArgs ...string) []int {
    81  	for i, arg := range rawArgs {
    82  		if !strings.HasPrefix(arg, "-") {
    83  			return []int{i}
    84  		}
    85  		if arg == "--" {
    86  			return []int{i + 1}
    87  		}
    88  	}
    89  	return nil
    90  }
    91  
    92  // Cat writes the concatenated contents of the named file(s) to the script's
    93  // stdout buffer.
    94  func Cat() Cmd {
    95  	return Command(
    96  		CmdUsage{
    97  			Summary: "concatenate files and print to the script's stdout buffer",
    98  			Args:    "files...",
    99  		},
   100  		func(s *State, args ...string) (WaitFunc, error) {
   101  			if len(args) == 0 {
   102  				return nil, ErrUsage
   103  			}
   104  
   105  			paths := make([]string, 0, len(args))
   106  			for _, arg := range args {
   107  				paths = append(paths, s.Path(arg))
   108  			}
   109  
   110  			var buf strings.Builder
   111  			errc := make(chan error, 1)
   112  			go func() {
   113  				for _, p := range paths {
   114  					b, err := os.ReadFile(p)
   115  					buf.Write(b)
   116  					if err != nil {
   117  						errc <- err
   118  						return
   119  					}
   120  				}
   121  				errc <- nil
   122  			}()
   123  
   124  			wait := func(*State) (stdout, stderr string, err error) {
   125  				err = <-errc
   126  				return buf.String(), "", err
   127  			}
   128  			return wait, nil
   129  		})
   130  }
   131  
   132  // Cd changes the current working directory.
   133  func Cd() Cmd {
   134  	return Command(
   135  		CmdUsage{
   136  			Summary: "change the working directory",
   137  			Args:    "dir",
   138  		},
   139  		func(s *State, args ...string) (WaitFunc, error) {
   140  			if len(args) != 1 {
   141  				return nil, ErrUsage
   142  			}
   143  			return nil, s.Chdir(args[0])
   144  		})
   145  }
   146  
   147  // Chmod changes the permissions of a file or a directory..
   148  func Chmod() Cmd {
   149  	return Command(
   150  		CmdUsage{
   151  			Summary: "change file mode bits",
   152  			Args:    "perm paths...",
   153  			Detail: []string{
   154  				"Changes the permissions of the named files or directories to be equal to perm.",
   155  				"Only numerical permissions are supported.",
   156  			},
   157  		},
   158  		func(s *State, args ...string) (WaitFunc, error) {
   159  			if len(args) < 2 {
   160  				return nil, ErrUsage
   161  			}
   162  
   163  			perm, err := strconv.ParseUint(args[0], 0, 32)
   164  			if err != nil || perm&uint64(fs.ModePerm) != perm {
   165  				return nil, fmt.Errorf("invalid mode: %s", args[0])
   166  			}
   167  
   168  			for _, arg := range args[1:] {
   169  				err := os.Chmod(s.Path(arg), fs.FileMode(perm))
   170  				if err != nil {
   171  					return nil, err
   172  				}
   173  			}
   174  			return nil, nil
   175  		})
   176  }
   177  
   178  // Cmp compares the contents of two files, or the contents of either the
   179  // "stdout" or "stderr" buffer and a file, returning a non-nil error if the
   180  // contents differ.
   181  func Cmp() Cmd {
   182  	return Command(
   183  		CmdUsage{
   184  			Args:    "[-q] file1 file2",
   185  			Summary: "compare files for differences",
   186  			Detail: []string{
   187  				"By convention, file1 is the actual data and file2 is the expected data.",
   188  				"The command succeeds if the file contents are identical.",
   189  				"File1 can be 'stdout' or 'stderr' to compare the stdout or stderr buffer from the most recent command.",
   190  			},
   191  		},
   192  		func(s *State, args ...string) (WaitFunc, error) {
   193  			return nil, doCompare(s, false, args...)
   194  		})
   195  }
   196  
   197  // Cmpenv is like Compare, but also performs environment substitutions
   198  // on the contents of both arguments.
   199  func Cmpenv() Cmd {
   200  	return Command(
   201  		CmdUsage{
   202  			Args:    "[-q] file1 file2",
   203  			Summary: "compare files for differences, with environment expansion",
   204  			Detail: []string{
   205  				"By convention, file1 is the actual data and file2 is the expected data.",
   206  				"The command succeeds if the file contents are identical after substituting variables from the script environment.",
   207  				"File1 can be 'stdout' or 'stderr' to compare the script's stdout or stderr buffer.",
   208  			},
   209  		},
   210  		func(s *State, args ...string) (WaitFunc, error) {
   211  			return nil, doCompare(s, true, args...)
   212  		})
   213  }
   214  
   215  func doCompare(s *State, env bool, args ...string) error {
   216  	quiet := false
   217  	if len(args) > 0 && args[0] == "-q" {
   218  		quiet = true
   219  		args = args[1:]
   220  	}
   221  	if len(args) != 2 {
   222  		return ErrUsage
   223  	}
   224  
   225  	name1, name2 := args[0], args[1]
   226  	var text1, text2 string
   227  	switch name1 {
   228  	case "stdout":
   229  		text1 = s.Stdout()
   230  	case "stderr":
   231  		text1 = s.Stderr()
   232  	default:
   233  		data, err := os.ReadFile(s.Path(name1))
   234  		if err != nil {
   235  			return err
   236  		}
   237  		text1 = string(data)
   238  	}
   239  
   240  	data, err := os.ReadFile(s.Path(name2))
   241  	if err != nil {
   242  		return err
   243  	}
   244  	text2 = string(data)
   245  
   246  	if env {
   247  		text1 = s.ExpandEnv(text1, false)
   248  		text2 = s.ExpandEnv(text2, false)
   249  	}
   250  
   251  	if text1 != text2 {
   252  		if !quiet {
   253  			diffText := diff.Diff(name1, []byte(text1), name2, []byte(text2))
   254  			s.Logf("%s\n", diffText)
   255  		}
   256  		return fmt.Errorf("%s and %s differ", name1, name2)
   257  	}
   258  	return nil
   259  }
   260  
   261  // Cp copies one or more files to a new location.
   262  func Cp() Cmd {
   263  	return Command(
   264  		CmdUsage{
   265  			Summary: "copy files to a target file or directory",
   266  			Args:    "src... dst",
   267  			Detail: []string{
   268  				"src can include 'stdout' or 'stderr' to copy from the script's stdout or stderr buffer.",
   269  			},
   270  		},
   271  		func(s *State, args ...string) (WaitFunc, error) {
   272  			if len(args) < 2 {
   273  				return nil, ErrUsage
   274  			}
   275  
   276  			dst := s.Path(args[len(args)-1])
   277  			info, err := os.Stat(dst)
   278  			dstDir := err == nil && info.IsDir()
   279  			if len(args) > 2 && !dstDir {
   280  				return nil, &fs.PathError{Op: "cp", Path: dst, Err: errors.New("destination is not a directory")}
   281  			}
   282  
   283  			for _, arg := range args[:len(args)-1] {
   284  				var (
   285  					src  string
   286  					data []byte
   287  					mode fs.FileMode
   288  				)
   289  				switch arg {
   290  				case "stdout":
   291  					src = arg
   292  					data = []byte(s.Stdout())
   293  					mode = 0666
   294  				case "stderr":
   295  					src = arg
   296  					data = []byte(s.Stderr())
   297  					mode = 0666
   298  				default:
   299  					src = s.Path(arg)
   300  					info, err := os.Stat(src)
   301  					if err != nil {
   302  						return nil, err
   303  					}
   304  					mode = info.Mode() & 0777
   305  					data, err = os.ReadFile(src)
   306  					if err != nil {
   307  						return nil, err
   308  					}
   309  				}
   310  				targ := dst
   311  				if dstDir {
   312  					targ = filepath.Join(dst, filepath.Base(src))
   313  				}
   314  				err := os.WriteFile(targ, data, mode)
   315  				if err != nil {
   316  					return nil, err
   317  				}
   318  			}
   319  
   320  			return nil, nil
   321  		})
   322  }
   323  
   324  // Echo writes its arguments to stdout, followed by a newline.
   325  func Echo() Cmd {
   326  	return Command(
   327  		CmdUsage{
   328  			Summary: "display a line of text",
   329  			Args:    "string...",
   330  		},
   331  		func(s *State, args ...string) (WaitFunc, error) {
   332  			var buf strings.Builder
   333  			for i, arg := range args {
   334  				if i > 0 {
   335  					buf.WriteString(" ")
   336  				}
   337  				buf.WriteString(arg)
   338  			}
   339  			buf.WriteString("\n")
   340  			out := buf.String()
   341  
   342  			// Stuff the result into a callback to satisfy the OutputCommandFunc
   343  			// interface, even though it isn't really asynchronous even if run in the
   344  			// background.
   345  			//
   346  			// Nobody should be running 'echo' as a background command, but it's not worth
   347  			// defining yet another interface, and also doesn't seem worth shoehorning
   348  			// into a SimpleCommand the way we did with Wait.
   349  			return func(*State) (stdout, stderr string, err error) {
   350  				return out, "", nil
   351  			}, nil
   352  		})
   353  }
   354  
   355  // Env sets or logs the values of environment variables.
   356  //
   357  // With no arguments, Env reports all variables in the environment.
   358  // "key=value" arguments set variables, and arguments without "="
   359  // cause the corresponding value to be printed to the stdout buffer.
   360  func Env() Cmd {
   361  	return Command(
   362  		CmdUsage{
   363  			Summary: "set or log the values of environment variables",
   364  			Args:    "[key[=value]...]",
   365  			Detail: []string{
   366  				"With no arguments, print the script environment to the log.",
   367  				"Otherwise, add the listed key=value pairs to the environment or print the listed keys.",
   368  			},
   369  		},
   370  		func(s *State, args ...string) (WaitFunc, error) {
   371  			out := new(strings.Builder)
   372  			if len(args) == 0 {
   373  				for _, kv := range s.env {
   374  					fmt.Fprintf(out, "%s\n", kv)
   375  				}
   376  			} else {
   377  				for _, env := range args {
   378  					i := strings.Index(env, "=")
   379  					if i < 0 {
   380  						// Display value instead of setting it.
   381  						fmt.Fprintf(out, "%s=%s\n", env, s.envMap[env])
   382  						continue
   383  					}
   384  					if err := s.Setenv(env[:i], env[i+1:]); err != nil {
   385  						return nil, err
   386  					}
   387  				}
   388  			}
   389  			var wait WaitFunc
   390  			if out.Len() > 0 || len(args) == 0 {
   391  				wait = func(*State) (stdout, stderr string, err error) {
   392  					return out.String(), "", nil
   393  				}
   394  			}
   395  			return wait, nil
   396  		})
   397  }
   398  
   399  // Exec runs an arbitrary executable as a subprocess.
   400  //
   401  // When the Script's context is canceled, Exec sends the interrupt signal, then
   402  // waits for up to the given delay for the subprocess to flush output before
   403  // terminating it with os.Kill.
   404  func Exec(cancel func(*exec.Cmd) error, waitDelay time.Duration) Cmd {
   405  	return Command(
   406  		CmdUsage{
   407  			Summary: "run an executable program with arguments",
   408  			Args:    "program [args...]",
   409  			Detail: []string{
   410  				"Note that 'exec' does not terminate the script (unlike Unix shells).",
   411  			},
   412  			Async: true,
   413  		},
   414  		func(s *State, args ...string) (WaitFunc, error) {
   415  			if len(args) < 1 {
   416  				return nil, ErrUsage
   417  			}
   418  
   419  			// Use the script's PATH to look up the command (if it does not contain a separator)
   420  			// instead of the test process's PATH (see lookPath).
   421  			// Don't use filepath.Clean, since that changes "./foo" to "foo".
   422  			name := filepath.FromSlash(args[0])
   423  			path := name
   424  			if !strings.Contains(name, string(filepath.Separator)) {
   425  				var err error
   426  				path, err = lookPath(s, name)
   427  				if err != nil {
   428  					return nil, err
   429  				}
   430  			}
   431  
   432  			return startCommand(s, name, path, args[1:], cancel, waitDelay)
   433  		})
   434  }
   435  
   436  func startCommand(s *State, name, path string, args []string, cancel func(*exec.Cmd) error, waitDelay time.Duration) (WaitFunc, error) {
   437  	var (
   438  		cmd                  *exec.Cmd
   439  		stdoutBuf, stderrBuf strings.Builder
   440  	)
   441  	for {
   442  		cmd = exec.CommandContext(s.Context(), path, args...)
   443  		if cancel == nil {
   444  			cmd.Cancel = nil
   445  		} else {
   446  			cmd.Cancel = func() error { return cancel(cmd) }
   447  		}
   448  		cmd.WaitDelay = waitDelay
   449  		cmd.Args[0] = name
   450  		cmd.Dir = s.Getwd()
   451  		cmd.Env = s.env
   452  		cmd.Stdout = &stdoutBuf
   453  		cmd.Stderr = &stderrBuf
   454  		err := cmd.Start()
   455  		if err == nil {
   456  			break
   457  		}
   458  		if isETXTBSY(err) {
   459  			// If the script (or its host process) just wrote the executable we're
   460  			// trying to run, a fork+exec in another thread may be holding open the FD
   461  			// that we used to write the executable (see https://go.dev/issue/22315).
   462  			// Since the descriptor should have CLOEXEC set, the problem should
   463  			// resolve as soon as the forked child reaches its exec call.
   464  			// Keep retrying until that happens.
   465  		} else {
   466  			return nil, err
   467  		}
   468  	}
   469  
   470  	wait := func(s *State) (stdout, stderr string, err error) {
   471  		err = cmd.Wait()
   472  		return stdoutBuf.String(), stderrBuf.String(), err
   473  	}
   474  	return wait, nil
   475  }
   476  
   477  // lookPath is (roughly) like exec.LookPath, but it uses the script's current
   478  // PATH to find the executable.
   479  func lookPath(s *State, command string) (string, error) {
   480  	var strEqual func(string, string) bool
   481  	if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
   482  		// Using GOOS as a proxy for case-insensitive file system.
   483  		// TODO(bcmills): Remove this assumption.
   484  		strEqual = strings.EqualFold
   485  	} else {
   486  		strEqual = func(a, b string) bool { return a == b }
   487  	}
   488  
   489  	var pathExt []string
   490  	var searchExt bool
   491  	var isExecutable func(os.FileInfo) bool
   492  	if runtime.GOOS == "windows" {
   493  		// Use the test process's PathExt instead of the script's.
   494  		// If PathExt is set in the command's environment, cmd.Start fails with
   495  		// "parameter is invalid". Not sure why.
   496  		// If the command already has an extension in PathExt (like "cmd.exe")
   497  		// don't search for other extensions (not "cmd.bat.exe").
   498  		pathExt = strings.Split(os.Getenv("PathExt"), string(filepath.ListSeparator))
   499  		searchExt = true
   500  		cmdExt := filepath.Ext(command)
   501  		for _, ext := range pathExt {
   502  			if strEqual(cmdExt, ext) {
   503  				searchExt = false
   504  				break
   505  			}
   506  		}
   507  		isExecutable = func(fi os.FileInfo) bool {
   508  			return fi.Mode().IsRegular()
   509  		}
   510  	} else {
   511  		isExecutable = func(fi os.FileInfo) bool {
   512  			return fi.Mode().IsRegular() && fi.Mode().Perm()&0111 != 0
   513  		}
   514  	}
   515  
   516  	pathEnv, _ := s.LookupEnv(pathEnvName())
   517  	for _, dir := range strings.Split(pathEnv, string(filepath.ListSeparator)) {
   518  		if dir == "" {
   519  			continue
   520  		}
   521  
   522  		// Determine whether dir needs a trailing path separator.
   523  		// Note: we avoid filepath.Join in this function because it cleans the
   524  		// result: we want to preserve the exact dir prefix from the environment.
   525  		sep := string(filepath.Separator)
   526  		if os.IsPathSeparator(dir[len(dir)-1]) {
   527  			sep = ""
   528  		}
   529  
   530  		if searchExt {
   531  			ents, err := os.ReadDir(dir)
   532  			if err != nil {
   533  				continue
   534  			}
   535  			for _, ent := range ents {
   536  				for _, ext := range pathExt {
   537  					if !ent.IsDir() && strEqual(ent.Name(), command+ext) {
   538  						return dir + sep + ent.Name(), nil
   539  					}
   540  				}
   541  			}
   542  		} else {
   543  			path := dir + sep + command
   544  			if fi, err := os.Stat(path); err == nil && isExecutable(fi) {
   545  				return path, nil
   546  			}
   547  		}
   548  	}
   549  	return "", &exec.Error{Name: command, Err: exec.ErrNotFound}
   550  }
   551  
   552  // pathEnvName returns the platform-specific variable used by os/exec.LookPath
   553  // to look up executable names (either "PATH" or "path").
   554  //
   555  // TODO(bcmills): Investigate whether we can instead use PATH uniformly and
   556  // rewrite it to $path when executing subprocesses.
   557  func pathEnvName() string {
   558  	switch runtime.GOOS {
   559  	case "plan9":
   560  		return "path"
   561  	default:
   562  		return "PATH"
   563  	}
   564  }
   565  
   566  // Exists checks that the named file(s) exist.
   567  func Exists() Cmd {
   568  	return Command(
   569  		CmdUsage{
   570  			Summary: "check that files exist",
   571  			Args:    "[-readonly] [-exec] file...",
   572  		},
   573  		func(s *State, args ...string) (WaitFunc, error) {
   574  			var readonly, exec bool
   575  		loop:
   576  			for len(args) > 0 {
   577  				switch args[0] {
   578  				case "-readonly":
   579  					readonly = true
   580  					args = args[1:]
   581  				case "-exec":
   582  					exec = true
   583  					args = args[1:]
   584  				default:
   585  					break loop
   586  				}
   587  			}
   588  			if len(args) == 0 {
   589  				return nil, ErrUsage
   590  			}
   591  
   592  			for _, file := range args {
   593  				file = s.Path(file)
   594  				info, err := os.Stat(file)
   595  				if err != nil {
   596  					return nil, err
   597  				}
   598  				if readonly && info.Mode()&0222 != 0 {
   599  					return nil, fmt.Errorf("%s exists but is writable", file)
   600  				}
   601  				if exec && runtime.GOOS != "windows" && info.Mode()&0111 == 0 {
   602  					return nil, fmt.Errorf("%s exists but is not executable", file)
   603  				}
   604  			}
   605  
   606  			return nil, nil
   607  		})
   608  }
   609  
   610  // Grep checks that file content matches a regexp.
   611  // Like stdout/stderr and unlike Unix grep, it accepts Go regexp syntax.
   612  //
   613  // Grep does not modify the State's stdout or stderr buffers.
   614  // (Its output goes to the script log, not stdout.)
   615  func Grep() Cmd {
   616  	return Command(
   617  		CmdUsage{
   618  			Summary: "find lines in a file that match a pattern",
   619  			Args:    matchUsage + " file",
   620  			Detail: []string{
   621  				"The command succeeds if at least one match (or the exact count, if given) is found.",
   622  				"The -q flag suppresses printing of matches.",
   623  			},
   624  			RegexpArgs: firstNonFlag,
   625  		},
   626  		func(s *State, args ...string) (WaitFunc, error) {
   627  			return nil, match(s, args, "", "grep")
   628  		})
   629  }
   630  
   631  const matchUsage = "[-count=N] [-q] 'pattern'"
   632  
   633  // match implements the Grep, Stdout, and Stderr commands.
   634  func match(s *State, args []string, text, name string) error {
   635  	n := 0
   636  	if len(args) >= 1 && strings.HasPrefix(args[0], "-count=") {
   637  		var err error
   638  		n, err = strconv.Atoi(args[0][len("-count="):])
   639  		if err != nil {
   640  			return fmt.Errorf("bad -count=: %v", err)
   641  		}
   642  		if n < 1 {
   643  			return fmt.Errorf("bad -count=: must be at least 1")
   644  		}
   645  		args = args[1:]
   646  	}
   647  	quiet := false
   648  	if len(args) >= 1 && args[0] == "-q" {
   649  		quiet = true
   650  		args = args[1:]
   651  	}
   652  
   653  	isGrep := name == "grep"
   654  
   655  	wantArgs := 1
   656  	if isGrep {
   657  		wantArgs = 2
   658  	}
   659  	if len(args) != wantArgs {
   660  		return ErrUsage
   661  	}
   662  
   663  	pattern := `(?m)` + args[0]
   664  	re, err := regexp.Compile(pattern)
   665  	if err != nil {
   666  		return err
   667  	}
   668  
   669  	if isGrep {
   670  		name = args[1] // for error messages
   671  		data, err := os.ReadFile(s.Path(args[1]))
   672  		if err != nil {
   673  			return err
   674  		}
   675  		text = string(data)
   676  	}
   677  
   678  	if n > 0 {
   679  		count := len(re.FindAllString(text, -1))
   680  		if count != n {
   681  			return fmt.Errorf("found %d matches for %#q in %s", count, pattern, name)
   682  		}
   683  		return nil
   684  	}
   685  
   686  	if !re.MatchString(text) {
   687  		return fmt.Errorf("no match for %#q in %s", pattern, name)
   688  	}
   689  
   690  	if !quiet {
   691  		// Print the lines containing the match.
   692  		loc := re.FindStringIndex(text)
   693  		for loc[0] > 0 && text[loc[0]-1] != '\n' {
   694  			loc[0]--
   695  		}
   696  		for loc[1] < len(text) && text[loc[1]] != '\n' {
   697  			loc[1]++
   698  		}
   699  		lines := strings.TrimSuffix(text[loc[0]:loc[1]], "\n")
   700  		s.Logf("matched: %s\n", lines)
   701  	}
   702  	return nil
   703  }
   704  
   705  // Help writes command documentation to the script log.
   706  func Help() Cmd {
   707  	return Command(
   708  		CmdUsage{
   709  			Summary: "log help text for commands and conditions",
   710  			Args:    "[-v] name...",
   711  			Detail: []string{
   712  				"To display help for a specific condition, enclose it in brackets: 'help [amd64]'.",
   713  				"To display complete documentation when listing all commands, pass the -v flag.",
   714  			},
   715  		},
   716  		func(s *State, args ...string) (WaitFunc, error) {
   717  			if s.engine == nil {
   718  				return nil, errors.New("no engine configured")
   719  			}
   720  
   721  			verbose := false
   722  			if len(args) > 0 {
   723  				verbose = true
   724  				if args[0] == "-v" {
   725  					args = args[1:]
   726  				}
   727  			}
   728  
   729  			var cmds, conds []string
   730  			for _, arg := range args {
   731  				if strings.HasPrefix(arg, "[") && strings.HasSuffix(arg, "]") {
   732  					conds = append(conds, arg[1:len(arg)-1])
   733  				} else {
   734  					cmds = append(cmds, arg)
   735  				}
   736  			}
   737  
   738  			out := new(strings.Builder)
   739  
   740  			if len(conds) > 0 || (len(args) == 0 && len(s.engine.Conds) > 0) {
   741  				if conds == nil {
   742  					out.WriteString("conditions:\n\n")
   743  				}
   744  				s.engine.ListConds(out, s, conds...)
   745  			}
   746  
   747  			if len(cmds) > 0 || len(args) == 0 {
   748  				if len(args) == 0 {
   749  					out.WriteString("\ncommands:\n\n")
   750  				}
   751  				s.engine.ListCmds(out, verbose, cmds...)
   752  			}
   753  
   754  			wait := func(*State) (stdout, stderr string, err error) {
   755  				return out.String(), "", nil
   756  			}
   757  			return wait, nil
   758  		})
   759  }
   760  
   761  // Mkdir creates a directory and any needed parent directories.
   762  func Mkdir() Cmd {
   763  	return Command(
   764  		CmdUsage{
   765  			Summary: "create directories, if they do not already exist",
   766  			Args:    "path...",
   767  			Detail: []string{
   768  				"Unlike Unix mkdir, parent directories are always created if needed.",
   769  			},
   770  		},
   771  		func(s *State, args ...string) (WaitFunc, error) {
   772  			if len(args) < 1 {
   773  				return nil, ErrUsage
   774  			}
   775  			for _, arg := range args {
   776  				if err := os.MkdirAll(s.Path(arg), 0777); err != nil {
   777  					return nil, err
   778  				}
   779  			}
   780  			return nil, nil
   781  		})
   782  }
   783  
   784  // Mv renames an existing file or directory to a new path.
   785  func Mv() Cmd {
   786  	return Command(
   787  		CmdUsage{
   788  			Summary: "rename a file or directory to a new path",
   789  			Args:    "old new",
   790  			Detail: []string{
   791  				"OS-specific restrictions may apply when old and new are in different directories.",
   792  			},
   793  		},
   794  		func(s *State, args ...string) (WaitFunc, error) {
   795  			if len(args) != 2 {
   796  				return nil, ErrUsage
   797  			}
   798  			return nil, os.Rename(s.Path(args[0]), s.Path(args[1]))
   799  		})
   800  }
   801  
   802  // Program returns a new command that runs the named program, found from the
   803  // host process's PATH (not looked up in the script's PATH).
   804  func Program(name string, cancel func(*exec.Cmd) error, waitDelay time.Duration) Cmd {
   805  	var (
   806  		shortName    string
   807  		summary      string
   808  		lookPathOnce sync.Once
   809  		path         string
   810  		pathErr      error
   811  	)
   812  	if filepath.IsAbs(name) {
   813  		lookPathOnce.Do(func() { path = filepath.Clean(name) })
   814  		shortName = strings.TrimSuffix(filepath.Base(path), ".exe")
   815  		summary = "run the '" + shortName + "' program provided by the script host"
   816  	} else {
   817  		shortName = name
   818  		summary = "run the '" + shortName + "' program from the script host's PATH"
   819  	}
   820  
   821  	return Command(
   822  		CmdUsage{
   823  			Summary: summary,
   824  			Args:    "[args...]",
   825  			Async:   true,
   826  		},
   827  		func(s *State, args ...string) (WaitFunc, error) {
   828  			lookPathOnce.Do(func() {
   829  				path, pathErr = cfg.LookPath(name)
   830  			})
   831  			if pathErr != nil {
   832  				return nil, pathErr
   833  			}
   834  			return startCommand(s, shortName, path, args, cancel, waitDelay)
   835  		})
   836  }
   837  
   838  // Replace replaces all occurrences of a string in a file with another string.
   839  func Replace() Cmd {
   840  	return Command(
   841  		CmdUsage{
   842  			Summary: "replace strings in a file",
   843  			Args:    "[old new]... file",
   844  			Detail: []string{
   845  				"The 'old' and 'new' arguments are unquoted as if in quoted Go strings.",
   846  			},
   847  		},
   848  		func(s *State, args ...string) (WaitFunc, error) {
   849  			if len(args)%2 != 1 {
   850  				return nil, ErrUsage
   851  			}
   852  
   853  			oldNew := make([]string, 0, len(args)-1)
   854  			for _, arg := range args[:len(args)-1] {
   855  				s, err := strconv.Unquote(`"` + arg + `"`)
   856  				if err != nil {
   857  					return nil, err
   858  				}
   859  				oldNew = append(oldNew, s)
   860  			}
   861  
   862  			r := strings.NewReplacer(oldNew...)
   863  			file := s.Path(args[len(args)-1])
   864  
   865  			data, err := os.ReadFile(file)
   866  			if err != nil {
   867  				return nil, err
   868  			}
   869  			replaced := r.Replace(string(data))
   870  
   871  			return nil, os.WriteFile(file, []byte(replaced), 0666)
   872  		})
   873  }
   874  
   875  // Rm removes a file or directory.
   876  //
   877  // If a directory, Rm also recursively removes that directory's
   878  // contents.
   879  func Rm() Cmd {
   880  	return Command(
   881  		CmdUsage{
   882  			Summary: "remove a file or directory",
   883  			Args:    "path...",
   884  			Detail: []string{
   885  				"If the path is a directory, its contents are removed recursively.",
   886  			},
   887  		},
   888  		func(s *State, args ...string) (WaitFunc, error) {
   889  			if len(args) < 1 {
   890  				return nil, ErrUsage
   891  			}
   892  			for _, arg := range args {
   893  				if err := removeAll(s.Path(arg)); err != nil {
   894  					return nil, err
   895  				}
   896  			}
   897  			return nil, nil
   898  		})
   899  }
   900  
   901  // removeAll removes dir and all files and directories it contains.
   902  //
   903  // Unlike os.RemoveAll, removeAll attempts to make the directories writable if
   904  // needed in order to remove their contents.
   905  func removeAll(dir string) error {
   906  	// module cache has 0444 directories;
   907  	// make them writable in order to remove content.
   908  	filepath.WalkDir(dir, func(path string, info fs.DirEntry, err error) error {
   909  		// chmod not only directories, but also things that we couldn't even stat
   910  		// due to permission errors: they may also be unreadable directories.
   911  		if err != nil || info.IsDir() {
   912  			os.Chmod(path, 0777)
   913  		}
   914  		return nil
   915  	})
   916  	return robustio.RemoveAll(dir)
   917  }
   918  
   919  // Sleep sleeps for the given Go duration or until the script's context is
   920  // cancelled, whichever happens first.
   921  func Sleep() Cmd {
   922  	return Command(
   923  		CmdUsage{
   924  			Summary: "sleep for a specified duration",
   925  			Args:    "duration",
   926  			Detail: []string{
   927  				"The duration must be given as a Go time.Duration string.",
   928  			},
   929  			Async: true,
   930  		},
   931  		func(s *State, args ...string) (WaitFunc, error) {
   932  			if len(args) != 1 {
   933  				return nil, ErrUsage
   934  			}
   935  
   936  			d, err := time.ParseDuration(args[0])
   937  			if err != nil {
   938  				return nil, err
   939  			}
   940  
   941  			timer := time.NewTimer(d)
   942  			wait := func(s *State) (stdout, stderr string, err error) {
   943  				ctx := s.Context()
   944  				select {
   945  				case <-ctx.Done():
   946  					timer.Stop()
   947  					return "", "", ctx.Err()
   948  				case <-timer.C:
   949  					return "", "", nil
   950  				}
   951  			}
   952  			return wait, nil
   953  		})
   954  }
   955  
   956  // Stderr searches for a regular expression in the stderr buffer.
   957  func Stderr() Cmd {
   958  	return Command(
   959  		CmdUsage{
   960  			Summary: "find lines in the stderr buffer that match a pattern",
   961  			Args:    matchUsage + " file",
   962  			Detail: []string{
   963  				"The command succeeds if at least one match (or the exact count, if given) is found.",
   964  				"The -q flag suppresses printing of matches.",
   965  			},
   966  			RegexpArgs: firstNonFlag,
   967  		},
   968  		func(s *State, args ...string) (WaitFunc, error) {
   969  			return nil, match(s, args, s.Stderr(), "stderr")
   970  		})
   971  }
   972  
   973  // Stdout searches for a regular expression in the stdout buffer.
   974  func Stdout() Cmd {
   975  	return Command(
   976  		CmdUsage{
   977  			Summary: "find lines in the stdout buffer that match a pattern",
   978  			Args:    matchUsage + " file",
   979  			Detail: []string{
   980  				"The command succeeds if at least one match (or the exact count, if given) is found.",
   981  				"The -q flag suppresses printing of matches.",
   982  			},
   983  			RegexpArgs: firstNonFlag,
   984  		},
   985  		func(s *State, args ...string) (WaitFunc, error) {
   986  			return nil, match(s, args, s.Stdout(), "stdout")
   987  		})
   988  }
   989  
   990  // Stop returns a sentinel error that causes script execution to halt
   991  // and s.Execute to return with a nil error.
   992  func Stop() Cmd {
   993  	return Command(
   994  		CmdUsage{
   995  			Summary: "stop execution of the script",
   996  			Args:    "[msg]",
   997  			Detail: []string{
   998  				"The message is written to the script log, but no error is reported from the script engine.",
   999  			},
  1000  		},
  1001  		func(s *State, args ...string) (WaitFunc, error) {
  1002  			if len(args) > 1 {
  1003  				return nil, ErrUsage
  1004  			}
  1005  			// TODO(bcmills): The argument passed to stop seems redundant with comments.
  1006  			// Either use it systematically or remove it.
  1007  			if len(args) == 1 {
  1008  				return nil, stopError{msg: args[0]}
  1009  			}
  1010  			return nil, stopError{}
  1011  		})
  1012  }
  1013  
  1014  // stopError is the sentinel error type returned by the Stop command.
  1015  type stopError struct {
  1016  	msg string
  1017  }
  1018  
  1019  func (s stopError) Error() string {
  1020  	if s.msg == "" {
  1021  		return "stop"
  1022  	}
  1023  	return "stop: " + s.msg
  1024  }
  1025  
  1026  // Symlink creates a symbolic link.
  1027  func Symlink() Cmd {
  1028  	return Command(
  1029  		CmdUsage{
  1030  			Summary: "create a symlink",
  1031  			Args:    "path -> target",
  1032  			Detail: []string{
  1033  				"Creates path as a symlink to target.",
  1034  				"The '->' token (like in 'ls -l' output on Unix) is required.",
  1035  			},
  1036  		},
  1037  		func(s *State, args ...string) (WaitFunc, error) {
  1038  			if len(args) != 3 || args[1] != "->" {
  1039  				return nil, ErrUsage
  1040  			}
  1041  
  1042  			// Note that the link target args[2] is not interpreted with s.Path:
  1043  			// it will be interpreted relative to the directory file is in.
  1044  			return nil, os.Symlink(filepath.FromSlash(args[2]), s.Path(args[0]))
  1045  		})
  1046  }
  1047  
  1048  // Wait waits for the completion of background commands.
  1049  //
  1050  // When Wait returns, the stdout and stderr buffers contain the concatenation of
  1051  // the background commands' respective outputs in the order in which those
  1052  // commands were started.
  1053  func Wait() Cmd {
  1054  	return Command(
  1055  		CmdUsage{
  1056  			Summary: "wait for completion of background commands",
  1057  			Args:    "",
  1058  			Detail: []string{
  1059  				"Waits for all background commands to complete.",
  1060  				"The output (and any error) from each command is printed to the log in the order in which the commands were started.",
  1061  				"After the call to 'wait', the script's stdout and stderr buffers contain the concatenation of the background commands' outputs.",
  1062  			},
  1063  		},
  1064  		func(s *State, args ...string) (WaitFunc, error) {
  1065  			if len(args) > 0 {
  1066  				return nil, ErrUsage
  1067  			}
  1068  
  1069  			var stdouts, stderrs []string
  1070  			var errs []*CommandError
  1071  			for _, bg := range s.background {
  1072  				stdout, stderr, err := bg.wait(s)
  1073  
  1074  				beforeArgs := ""
  1075  				if len(bg.args) > 0 {
  1076  					beforeArgs = " "
  1077  				}
  1078  				s.Logf("[background] %s%s%s\n", bg.name, beforeArgs, quoteArgs(bg.args))
  1079  
  1080  				if stdout != "" {
  1081  					s.Logf("[stdout]\n%s", stdout)
  1082  					stdouts = append(stdouts, stdout)
  1083  				}
  1084  				if stderr != "" {
  1085  					s.Logf("[stderr]\n%s", stderr)
  1086  					stderrs = append(stderrs, stderr)
  1087  				}
  1088  				if err != nil {
  1089  					s.Logf("[%v]\n", err)
  1090  				}
  1091  				if cmdErr := checkStatus(bg.command, err); cmdErr != nil {
  1092  					errs = append(errs, cmdErr.(*CommandError))
  1093  				}
  1094  			}
  1095  
  1096  			s.stdout = strings.Join(stdouts, "")
  1097  			s.stderr = strings.Join(stderrs, "")
  1098  			s.background = nil
  1099  			if len(errs) > 0 {
  1100  				return nil, waitError{errs: errs}
  1101  			}
  1102  			return nil, nil
  1103  		})
  1104  }
  1105  
  1106  // A waitError wraps one or more errors returned by background commands.
  1107  type waitError struct {
  1108  	errs []*CommandError
  1109  }
  1110  
  1111  func (w waitError) Error() string {
  1112  	b := new(strings.Builder)
  1113  	for i, err := range w.errs {
  1114  		if i != 0 {
  1115  			b.WriteString("\n")
  1116  		}
  1117  		b.WriteString(err.Error())
  1118  	}
  1119  	return b.String()
  1120  }
  1121  
  1122  func (w waitError) Unwrap() error {
  1123  	if len(w.errs) == 1 {
  1124  		return w.errs[0]
  1125  	}
  1126  	return nil
  1127  }