diff --git a/cmd/task/task.go b/cmd/task/task.go index 5b22ccdf..0d156614 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "os" - "path/filepath" "strings" "github.com/spf13/pflag" @@ -94,11 +93,6 @@ func run() error { dir = home } - if entrypoint != "" { - dir = filepath.Dir(entrypoint) - entrypoint = filepath.Base(entrypoint) - } - var taskSorter sort.TaskSorter switch flags.TaskSort { case "none": diff --git a/internal/flags/flags.go b/internal/flags/flags.go index cc2e9b23..a5c775a8 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -131,10 +131,6 @@ func Validate() error { return nil } - if Dir != "" && Entrypoint != "" { - return errors.New("task: You can't set both --dir and --taskfile") - } - if Output.Name != "group" { if Output.Group.Begin != "" { return errors.New("task: You can't set --output-group-begin without --output=group") diff --git a/setup.go b/setup.go index 6611b976..bef2ad37 100644 --- a/setup.go +++ b/setup.go @@ -54,11 +54,11 @@ func (e *Executor) Setup() error { } func (e *Executor) getRootNode() (taskfile.Node, error) { - node, err := taskfile.NewRootNode(e.Dir, e.Entrypoint, e.Insecure) + node, err := taskfile.NewRootNode(e.Logger, e.Entrypoint, e.Dir, e.Insecure) if err != nil { return nil, err } - e.Dir = node.BaseDir() + e.Dir = node.Dir() return node, err } diff --git a/task_test.go b/task_test.go index bf798bfa..28fded0d 100644 --- a/task_test.go +++ b/task_test.go @@ -73,7 +73,7 @@ func (fct fileContentTest) Run(t *testing.T) { for name, expectContent := range fct.Files { t.Run(fct.name(name), func(t *testing.T) { - path := filepathext.SmartJoin(fct.Dir, name) + path := filepathext.SmartJoin(e.Dir, name) b, err := os.ReadFile(path) require.NoError(t, err, "Error reading file") s := string(b) @@ -1123,8 +1123,8 @@ func TestIncludesOptionalExplicitFalse(t *testing.T) { func TestIncludesFromCustomTaskfile(t *testing.T) { tt := fileContentTest{ + Entrypoint: "testdata/includes_yaml/Custom.ext", Dir: "testdata/includes_yaml", - Entrypoint: "Custom.ext", Target: "default", TrimSpace: true, Files: map[string]string{ @@ -1486,16 +1486,12 @@ func TestDotenvShouldIncludeAllEnvFiles(t *testing.T) { } func TestDotenvShouldErrorWhenIncludingDependantDotenvs(t *testing.T) { - const dir = "testdata/dotenv/error_included_envs" - const entry = "Taskfile.yml" - var buff bytes.Buffer e := task.Executor{ - Dir: dir, - Entrypoint: entry, - Summary: true, - Stdout: &buff, - Stderr: &buff, + Dir: "testdata/dotenv/error_included_envs", + Summary: true, + Stdout: &buff, + Stderr: &buff, } err := e.Setup() diff --git a/taskfile/ast/include.go b/taskfile/ast/include.go index 10b420c8..2d999649 100644 --- a/taskfile/ast/include.go +++ b/taskfile/ast/include.go @@ -2,13 +2,9 @@ package ast import ( "fmt" - "path/filepath" - "strings" "gopkg.in/yaml.v3" - "github.com/go-task/task/v3/internal/execext" - "github.com/go-task/task/v3/internal/filepathext" omap "github.com/go-task/task/v3/internal/omap" ) @@ -22,7 +18,6 @@ type Include struct { Aliases []string AdvancedImport bool Vars *Vars - BaseDir string // The directory from which the including taskfile was loaded; used to resolve relative paths } // Includes represents information about included tasksfiles @@ -120,39 +115,5 @@ func (include *Include) DeepCopy() *Include { Internal: include.Internal, AdvancedImport: include.AdvancedImport, Vars: include.Vars.DeepCopy(), - BaseDir: include.BaseDir, } } - -// FullTaskfilePath returns the fully qualified path to the included taskfile -func (include *Include) FullTaskfilePath() (string, error) { - return include.resolvePath(include.Taskfile) -} - -// FullDirPath returns the fully qualified path to the included taskfile's working directory -func (include *Include) FullDirPath() (string, error) { - return include.resolvePath(include.Dir) -} - -func (include *Include) resolvePath(path string) (string, error) { - // If the file is remote, we don't need to resolve the path - if strings.Contains(include.Taskfile, "://") { - return path, nil - } - - path, err := execext.Expand(path) - if err != nil { - return "", err - } - - if filepathext.IsAbs(path) { - return path, nil - } - - result, err := filepath.Abs(filepathext.SmartJoin(include.BaseDir, path)) - if err != nil { - return "", fmt.Errorf("task: error resolving path %s relative to %s: %w", path, include.BaseDir, err) - } - - return result, nil -} diff --git a/taskfile/node.go b/taskfile/node.go index 27f9ab58..cf8bdeca 100644 --- a/taskfile/node.go +++ b/taskfile/node.go @@ -8,20 +8,25 @@ import ( "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/experiments" + "github.com/go-task/task/v3/internal/logger" + "github.com/go-task/task/v3/taskfile/ast" ) type Node interface { Read(ctx context.Context) ([]byte, error) Parent() Node Location() string + Dir() string Optional() bool Remote() bool - BaseDir() string + ResolveIncludeEntrypoint(include ast.Include) (string, error) + ResolveIncludeDir(include ast.Include) (string, error) } func NewRootNode( - dir string, + l *logger.Logger, entrypoint string, + dir string, insecure bool, ) (Node, error) { dir = getDefaultDir(entrypoint, dir) @@ -30,32 +35,24 @@ func NewRootNode( if (stat.Mode()&os.ModeCharDevice) == 0 && stat.Size() > 0 { return NewStdinNode(dir) } - // If no entrypoint is specified, search for a taskfile - if entrypoint == "" { - root, err := ExistsWalk(dir) - if err != nil { - return nil, err - } - return NewNode(root, insecure) - } - // Use the specified entrypoint - uri := filepath.Join(dir, entrypoint) - return NewNode(uri, insecure) + return NewNode(l, entrypoint, dir, insecure) } func NewNode( - uri string, + l *logger.Logger, + entrypoint string, + dir string, insecure bool, opts ...NodeOption, ) (Node, error) { var node Node var err error - switch getScheme(uri) { + switch getScheme(entrypoint) { case "http", "https": - node, err = NewHTTPNode(uri, insecure, opts...) + node, err = NewHTTPNode(l, entrypoint, dir, insecure, opts...) default: // If no other scheme matches, we assume it's a file - node, err = NewFileNode(uri, opts...) + node, err = NewFileNode(l, entrypoint, dir, opts...) } if node.Remote() && !experiments.RemoteTaskfiles.Enabled { return nil, errors.New("task: Remote taskfiles are not enabled. You can read more about this experiment and how to enable it at https://taskfile.dev/experiments/remote-taskfiles") diff --git a/taskfile/node_base.go b/taskfile/node_base.go index 94ee6ea9..57650d1a 100644 --- a/taskfile/node_base.go +++ b/taskfile/node_base.go @@ -9,6 +9,7 @@ type ( BaseNode struct { parent Node optional bool + dir string } ) @@ -16,6 +17,7 @@ func NewBaseNode(opts ...NodeOption) *BaseNode { node := &BaseNode{ parent: nil, optional: false, + dir: "", } // Apply options @@ -45,3 +47,7 @@ func WithOptional(optional bool) NodeOption { func (node *BaseNode) Optional() bool { return node.optional } + +func (node *BaseNode) Dir() string { + return node.dir +} diff --git a/taskfile/node_file.go b/taskfile/node_file.go index 45a2a9c3..8be8aa47 100644 --- a/taskfile/node_file.go +++ b/taskfile/node_file.go @@ -5,39 +5,36 @@ import ( "io" "os" "path/filepath" + "strings" + "github.com/go-task/task/v3/internal/execext" "github.com/go-task/task/v3/internal/filepathext" + "github.com/go-task/task/v3/internal/logger" + "github.com/go-task/task/v3/taskfile/ast" ) // A FileNode is a node that reads a taskfile from the local filesystem. type FileNode struct { *BaseNode - Dir string Entrypoint string } -func NewFileNode(uri string, opts ...NodeOption) (*FileNode, error) { +func NewFileNode(l *logger.Logger, entrypoint, dir string, opts ...NodeOption) (*FileNode, error) { + var err error base := NewBaseNode(opts...) - if uri == "" { - d, err := os.Getwd() - if err != nil { - return nil, err - } - uri = d - } - path, err := Exists(uri) + entrypoint, dir, err = resolveFileNodeEntrypointAndDir(l, entrypoint, dir) if err != nil { return nil, err } + base.dir = dir return &FileNode{ BaseNode: base, - Dir: filepath.Dir(path), - Entrypoint: filepath.Base(path), + Entrypoint: entrypoint, }, nil } func (node *FileNode) Location() string { - return filepathext.SmartJoin(node.Dir, node.Entrypoint) + return node.Entrypoint } func (node *FileNode) Remote() bool { @@ -53,6 +50,67 @@ func (node *FileNode) Read(ctx context.Context) ([]byte, error) { return io.ReadAll(f) } -func (node *FileNode) BaseDir() string { - return node.Dir +// resolveFileNodeEntrypointAndDir resolves checks the values of entrypoint and dir and +// populates them with default values if necessary. +func resolveFileNodeEntrypointAndDir(l *logger.Logger, entrypoint, dir string) (string, string, error) { + var err error + if entrypoint != "" { + entrypoint, err = Exists(l, entrypoint) + if err != nil { + return "", "", err + } + if dir == "" { + dir = filepath.Dir(entrypoint) + } + return entrypoint, dir, nil + } + if dir == "" { + dir, err = os.Getwd() + if err != nil { + return "", "", err + } + } + entrypoint, err = ExistsWalk(l, dir) + if err != nil { + return "", "", err + } + dir = filepath.Dir(entrypoint) + return entrypoint, dir, nil +} + +func (node *FileNode) ResolveIncludeEntrypoint(include ast.Include) (string, error) { + // If the file is remote, we don't need to resolve the path + if strings.Contains(include.Taskfile, "://") { + return include.Taskfile, nil + } + + path, err := execext.Expand(include.Taskfile) + if err != nil { + return "", err + } + + if filepathext.IsAbs(path) { + return path, nil + } + + // NOTE: Uses the directory of the entrypoint (Taskfile), not the current working directory + // This means that files are included relative to one another + entrypointDir := filepath.Dir(node.Entrypoint) + return filepathext.SmartJoin(entrypointDir, path), nil +} + +func (node *FileNode) ResolveIncludeDir(include ast.Include) (string, error) { + path, err := execext.Expand(include.Dir) + if err != nil { + return "", err + } + + if filepathext.IsAbs(path) { + return path, nil + } + + // NOTE: Uses the directory of the entrypoint (Taskfile), not the current working directory + // This means that files are included relative to one another + entrypointDir := filepath.Dir(node.Entrypoint) + return filepathext.SmartJoin(entrypointDir, path), nil } diff --git a/taskfile/node_http.go b/taskfile/node_http.go index f4c2ab11..1e97fdc5 100644 --- a/taskfile/node_http.go +++ b/taskfile/node_http.go @@ -5,8 +5,13 @@ import ( "io" "net/http" "net/url" + "path/filepath" "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/internal/execext" + "github.com/go-task/task/v3/internal/filepathext" + "github.com/go-task/task/v3/internal/logger" + "github.com/go-task/task/v3/taskfile/ast" ) // An HTTPNode is a node that reads a Taskfile from a remote location via HTTP. @@ -15,14 +20,19 @@ type HTTPNode struct { URL *url.URL } -func NewHTTPNode(uri string, insecure bool, opts ...NodeOption) (*HTTPNode, error) { +func NewHTTPNode(l *logger.Logger, entrypoint, dir string, insecure bool, opts ...NodeOption) (*HTTPNode, error) { base := NewBaseNode(opts...) - url, err := url.Parse(uri) + base.dir = dir + url, err := url.Parse(entrypoint) if err != nil { return nil, err } if url.Scheme == "http" && !insecure { - return nil, &errors.TaskfileNotSecureError{URI: uri} + return nil, &errors.TaskfileNotSecureError{URI: entrypoint} + } + url, err = RemoteExists(l, url) + if err != nil { + return nil, err } return &HTTPNode{ BaseNode: base, @@ -66,6 +76,26 @@ func (node *HTTPNode) Read(ctx context.Context) ([]byte, error) { return b, nil } -func (node *HTTPNode) BaseDir() string { - return "" +func (node *HTTPNode) ResolveIncludeEntrypoint(include ast.Include) (string, error) { + ref, err := url.Parse(include.Taskfile) + if err != nil { + return "", err + } + return node.URL.ResolveReference(ref).String(), nil +} + +func (node *HTTPNode) ResolveIncludeDir(include ast.Include) (string, error) { + path, err := execext.Expand(include.Dir) + if err != nil { + return "", err + } + + if filepathext.IsAbs(path) { + return path, nil + } + + // NOTE: Uses the directory of the entrypoint (Taskfile), not the current working directory + // This means that files are included relative to one another + entrypointDir := filepath.Dir(node.Dir()) + return filepathext.SmartJoin(entrypointDir, path), nil } diff --git a/taskfile/node_stdin.go b/taskfile/node_stdin.go index c16a64a5..46782131 100644 --- a/taskfile/node_stdin.go +++ b/taskfile/node_stdin.go @@ -5,19 +5,23 @@ import ( "context" "fmt" "os" + "strings" + + "github.com/go-task/task/v3/internal/execext" + "github.com/go-task/task/v3/internal/filepathext" + "github.com/go-task/task/v3/taskfile/ast" ) // A StdinNode is a node that reads a taskfile from the standard input stream. type StdinNode struct { *BaseNode - Dir string } func NewStdinNode(dir string) (*StdinNode, error) { base := NewBaseNode() + base.dir = dir return &StdinNode{ BaseNode: base, - Dir: dir, }, nil } @@ -41,6 +45,33 @@ func (node *StdinNode) Read(ctx context.Context) ([]byte, error) { return stdin, nil } -func (node *StdinNode) BaseDir() string { - return node.Dir +func (node *StdinNode) ResolveIncludeEntrypoint(include ast.Include) (string, error) { + // If the file is remote, we don't need to resolve the path + if strings.Contains(include.Taskfile, "://") { + return include.Taskfile, nil + } + + path, err := execext.Expand(include.Taskfile) + if err != nil { + return "", err + } + + if filepathext.IsAbs(path) { + return path, nil + } + + return filepathext.SmartJoin(node.Dir(), path), nil +} + +func (node *StdinNode) ResolveIncludeDir(include ast.Include) (string, error) { + path, err := execext.Expand(include.Dir) + if err != nil { + return "", err + } + + if filepathext.IsAbs(path) { + return path, nil + } + + return filepathext.SmartJoin(node.Dir(), path), nil } diff --git a/taskfile/reader.go b/taskfile/reader.go index 399a8e75..3f7f0899 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -48,17 +48,6 @@ func Read( return nil, &errors.TaskfileVersionCheckError{URI: node.Location()} } - if dir := node.BaseDir(); dir != "" { - _ = tf.Includes.Range(func(namespace string, include ast.Include) error { - // Set the base directory for resolving relative paths, but only if not already set - if include.BaseDir == "" { - include.BaseDir = dir - tf.Includes.Set(namespace, include) - } - return nil - }) - } - err = tf.Includes.Range(func(namespace string, include ast.Include) error { cache := &templater.Cache{Vars: tf.Vars} include = ast.Include{ @@ -70,18 +59,22 @@ func Read( Aliases: include.Aliases, AdvancedImport: include.AdvancedImport, Vars: include.Vars, - BaseDir: include.BaseDir, } if err := cache.Err(); err != nil { return err } - uri, err := include.FullTaskfilePath() + entrypoint, err := node.ResolveIncludeEntrypoint(include) if err != nil { return err } - includeReaderNode, err := NewNode(uri, insecure, + dir, err := node.ResolveIncludeDir(include) + if err != nil { + return err + } + + includeReaderNode, err := NewNode(l, entrypoint, dir, insecure, WithParent(node), WithOptional(include.Optional), ) @@ -109,11 +102,6 @@ func Read( } if include.AdvancedImport { - dir, err := include.FullDirPath() - if err != nil { - return err - } - // nolint: errcheck includedTaskfile.Vars.Range(func(k string, v ast.Var) error { o := v diff --git a/taskfile/taskfile.go b/taskfile/taskfile.go index ae9e9d89..3c006dae 100644 --- a/taskfile/taskfile.go +++ b/taskfile/taskfile.go @@ -1,11 +1,16 @@ package taskfile import ( + "net/http" + "net/url" "os" "path/filepath" + "slices" + "strings" "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/filepathext" + "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/sysinfo" ) @@ -23,14 +28,80 @@ var ( "Taskfile.dist.yaml", "taskfile.dist.yaml", } + + allowedContentTypes = []string{ + "text/plain", + "text/yaml", + "text/x-yaml", + "application/yaml", + "application/x-yaml", + } ) +// RemoteExists will check if a file at the given URL Exists. If it does, it +// will return its URL. If it does not, it will search the search for any files +// at the given URL with any of the default Taskfile files names. If any of +// these match a file, the first matching path will be returned. If no files are +// found, an error will be returned. +func RemoteExists(l *logger.Logger, u *url.URL) (*url.URL, error) { + // Create a new HEAD request for the given URL to check if the resource exists + req, err := http.NewRequest("HEAD", u.String(), nil) + if err != nil { + return nil, errors.TaskfileFetchFailedError{URI: u.String()} + } + + // Request the given URL + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, errors.TaskfileFetchFailedError{URI: u.String()} + } + defer resp.Body.Close() + + // If the request was successful and the content type is allowed, return the + // URL The content type check is to avoid downloading files that are not + // Taskfiles It means we can try other files instead of downloading + // something that is definitely not a Taskfile + contentType := resp.Header.Get("Content-Type") + if resp.StatusCode == http.StatusOK && slices.ContainsFunc(allowedContentTypes, func(s string) bool { + return strings.Contains(contentType, s) + }) { + return u, nil + } + + // If the request was not successful, append the default Taskfile names to + // the URL and return the URL of the first successful request + for _, taskfile := range defaultTaskfiles { + // Fixes a bug with JoinPath where a leading slash is not added to the + // path if it is empty + if u.Path == "" { + u.Path = "/" + } + alt := u.JoinPath(taskfile) + req.URL = alt + + // Try the alternative URL + resp, err = http.DefaultClient.Do(req) + if err != nil { + return nil, errors.TaskfileFetchFailedError{URI: u.String()} + } + defer resp.Body.Close() + + // If the request was successful, return the URL + if resp.StatusCode == http.StatusOK { + l.VerboseOutf(logger.Magenta, "task: [%s] Not found - Using alternative (%s)\n", alt.String(), taskfile) + return alt, nil + } + } + + return nil, errors.TaskfileNotFoundError{URI: u.String(), Walk: false} +} + // Exists will check if a file at the given path Exists. If it does, it will -// return the path to it. If it does not, it will search the search for any -// files at the given path with any of the default Taskfile files names. If any -// of these match a file, the first matching path will be returned. If no files -// are found, an error will be returned. -func Exists(path string) (string, error) { +// return the path to it. If it does not, it will search for any files at the +// given path with any of the default Taskfile files names. If any of these +// match a file, the first matching path will be returned. If no files are +// found, an error will be returned. +func Exists(l *logger.Logger, path string) (string, error) { fi, err := os.Stat(path) if err != nil { return "", err @@ -42,10 +113,11 @@ func Exists(path string) (string, error) { return filepath.Abs(path) } - for _, n := range defaultTaskfiles { - fpath := filepathext.SmartJoin(path, n) - if _, err := os.Stat(fpath); err == nil { - return filepath.Abs(fpath) + for _, taskfile := range defaultTaskfiles { + alt := filepathext.SmartJoin(path, taskfile) + if _, err := os.Stat(alt); err == nil { + l.VerboseOutf(logger.Magenta, "task: [%s] Not found - Using alternative (%s)\n", path, taskfile) + return filepath.Abs(alt) } } @@ -57,14 +129,14 @@ func Exists(path string) (string, error) { // calling the exists function until it finds a file or reaches the root // directory. On supported operating systems, it will also check if the user ID // of the directory changes and abort if it does. -func ExistsWalk(path string) (string, error) { +func ExistsWalk(l *logger.Logger, path string) (string, error) { origPath := path owner, err := sysinfo.Owner(path) if err != nil { return "", err } for { - fpath, err := Exists(path) + fpath, err := Exists(l, path) if err == nil { return fpath, nil }