1
0
mirror of https://github.com/go-task/task.git synced 2025-06-08 23:56:21 +02:00

feat: root remote taskfiles

This commit is contained in:
Pete Davison 2024-02-13 01:07:00 +00:00
parent f00693052a
commit cbc19d35ea
12 changed files with 261 additions and 132 deletions

View File

@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"path/filepath"
"strings" "strings"
"github.com/spf13/pflag" "github.com/spf13/pflag"
@ -94,11 +93,6 @@ func run() error {
dir = home dir = home
} }
if entrypoint != "" {
dir = filepath.Dir(entrypoint)
entrypoint = filepath.Base(entrypoint)
}
var taskSorter sort.TaskSorter var taskSorter sort.TaskSorter
switch flags.TaskSort { switch flags.TaskSort {
case "none": case "none":

View File

@ -131,10 +131,6 @@ func Validate() error {
return nil return nil
} }
if Dir != "" && Entrypoint != "" {
return errors.New("task: You can't set both --dir and --taskfile")
}
if Output.Name != "group" { if Output.Name != "group" {
if Output.Group.Begin != "" { if Output.Group.Begin != "" {
return errors.New("task: You can't set --output-group-begin without --output=group") return errors.New("task: You can't set --output-group-begin without --output=group")

View File

@ -54,11 +54,11 @@ func (e *Executor) Setup() error {
} }
func (e *Executor) getRootNode() (taskfile.Node, 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 { if err != nil {
return nil, err return nil, err
} }
e.Dir = node.BaseDir() e.Dir = node.Dir()
return node, err return node, err
} }

View File

@ -73,7 +73,7 @@ func (fct fileContentTest) Run(t *testing.T) {
for name, expectContent := range fct.Files { for name, expectContent := range fct.Files {
t.Run(fct.name(name), func(t *testing.T) { 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) b, err := os.ReadFile(path)
require.NoError(t, err, "Error reading file") require.NoError(t, err, "Error reading file")
s := string(b) s := string(b)
@ -1123,8 +1123,8 @@ func TestIncludesOptionalExplicitFalse(t *testing.T) {
func TestIncludesFromCustomTaskfile(t *testing.T) { func TestIncludesFromCustomTaskfile(t *testing.T) {
tt := fileContentTest{ tt := fileContentTest{
Entrypoint: "testdata/includes_yaml/Custom.ext",
Dir: "testdata/includes_yaml", Dir: "testdata/includes_yaml",
Entrypoint: "Custom.ext",
Target: "default", Target: "default",
TrimSpace: true, TrimSpace: true,
Files: map[string]string{ Files: map[string]string{
@ -1486,13 +1486,9 @@ func TestDotenvShouldIncludeAllEnvFiles(t *testing.T) {
} }
func TestDotenvShouldErrorWhenIncludingDependantDotenvs(t *testing.T) { func TestDotenvShouldErrorWhenIncludingDependantDotenvs(t *testing.T) {
const dir = "testdata/dotenv/error_included_envs"
const entry = "Taskfile.yml"
var buff bytes.Buffer var buff bytes.Buffer
e := task.Executor{ e := task.Executor{
Dir: dir, Dir: "testdata/dotenv/error_included_envs",
Entrypoint: entry,
Summary: true, Summary: true,
Stdout: &buff, Stdout: &buff,
Stderr: &buff, Stderr: &buff,

View File

@ -2,13 +2,9 @@ package ast
import ( import (
"fmt" "fmt"
"path/filepath"
"strings"
"gopkg.in/yaml.v3" "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" omap "github.com/go-task/task/v3/internal/omap"
) )
@ -22,7 +18,6 @@ type Include struct {
Aliases []string Aliases []string
AdvancedImport bool AdvancedImport bool
Vars *Vars Vars *Vars
BaseDir string // The directory from which the including taskfile was loaded; used to resolve relative paths
} }
// Includes represents information about included tasksfiles // Includes represents information about included tasksfiles
@ -120,39 +115,5 @@ func (include *Include) DeepCopy() *Include {
Internal: include.Internal, Internal: include.Internal,
AdvancedImport: include.AdvancedImport, AdvancedImport: include.AdvancedImport,
Vars: include.Vars.DeepCopy(), 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
}

View File

@ -8,20 +8,25 @@ import (
"github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/internal/experiments" "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 { type Node interface {
Read(ctx context.Context) ([]byte, error) Read(ctx context.Context) ([]byte, error)
Parent() Node Parent() Node
Location() string Location() string
Dir() string
Optional() bool Optional() bool
Remote() bool Remote() bool
BaseDir() string ResolveIncludeEntrypoint(include ast.Include) (string, error)
ResolveIncludeDir(include ast.Include) (string, error)
} }
func NewRootNode( func NewRootNode(
dir string, l *logger.Logger,
entrypoint string, entrypoint string,
dir string,
insecure bool, insecure bool,
) (Node, error) { ) (Node, error) {
dir = getDefaultDir(entrypoint, dir) dir = getDefaultDir(entrypoint, dir)
@ -30,32 +35,24 @@ func NewRootNode(
if (stat.Mode()&os.ModeCharDevice) == 0 && stat.Size() > 0 { if (stat.Mode()&os.ModeCharDevice) == 0 && stat.Size() > 0 {
return NewStdinNode(dir) return NewStdinNode(dir)
} }
// If no entrypoint is specified, search for a taskfile return NewNode(l, entrypoint, dir, insecure)
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)
} }
func NewNode( func NewNode(
uri string, l *logger.Logger,
entrypoint string,
dir string,
insecure bool, insecure bool,
opts ...NodeOption, opts ...NodeOption,
) (Node, error) { ) (Node, error) {
var node Node var node Node
var err error var err error
switch getScheme(uri) { switch getScheme(entrypoint) {
case "http", "https": case "http", "https":
node, err = NewHTTPNode(uri, insecure, opts...) node, err = NewHTTPNode(l, entrypoint, dir, insecure, opts...)
default: default:
// If no other scheme matches, we assume it's a file // 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 { 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") 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")

View File

@ -9,6 +9,7 @@ type (
BaseNode struct { BaseNode struct {
parent Node parent Node
optional bool optional bool
dir string
} }
) )
@ -16,6 +17,7 @@ func NewBaseNode(opts ...NodeOption) *BaseNode {
node := &BaseNode{ node := &BaseNode{
parent: nil, parent: nil,
optional: false, optional: false,
dir: "",
} }
// Apply options // Apply options
@ -45,3 +47,7 @@ func WithOptional(optional bool) NodeOption {
func (node *BaseNode) Optional() bool { func (node *BaseNode) Optional() bool {
return node.optional return node.optional
} }
func (node *BaseNode) Dir() string {
return node.dir
}

View File

@ -5,39 +5,36 @@ import (
"io" "io"
"os" "os"
"path/filepath" "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/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. // A FileNode is a node that reads a taskfile from the local filesystem.
type FileNode struct { type FileNode struct {
*BaseNode *BaseNode
Dir string
Entrypoint 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...) base := NewBaseNode(opts...)
if uri == "" { entrypoint, dir, err = resolveFileNodeEntrypointAndDir(l, entrypoint, dir)
d, err := os.Getwd()
if err != nil {
return nil, err
}
uri = d
}
path, err := Exists(uri)
if err != nil { if err != nil {
return nil, err return nil, err
} }
base.dir = dir
return &FileNode{ return &FileNode{
BaseNode: base, BaseNode: base,
Dir: filepath.Dir(path), Entrypoint: entrypoint,
Entrypoint: filepath.Base(path),
}, nil }, nil
} }
func (node *FileNode) Location() string { func (node *FileNode) Location() string {
return filepathext.SmartJoin(node.Dir, node.Entrypoint) return node.Entrypoint
} }
func (node *FileNode) Remote() bool { func (node *FileNode) Remote() bool {
@ -53,6 +50,67 @@ func (node *FileNode) Read(ctx context.Context) ([]byte, error) {
return io.ReadAll(f) return io.ReadAll(f)
} }
func (node *FileNode) BaseDir() string { // resolveFileNodeEntrypointAndDir resolves checks the values of entrypoint and dir and
return node.Dir // 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
} }

View File

@ -5,8 +5,13 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"path/filepath"
"github.com/go-task/task/v3/errors" "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. // 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 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...) base := NewBaseNode(opts...)
url, err := url.Parse(uri) base.dir = dir
url, err := url.Parse(entrypoint)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if url.Scheme == "http" && !insecure { 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{ return &HTTPNode{
BaseNode: base, BaseNode: base,
@ -66,6 +76,26 @@ func (node *HTTPNode) Read(ctx context.Context) ([]byte, error) {
return b, nil return b, nil
} }
func (node *HTTPNode) BaseDir() string { func (node *HTTPNode) ResolveIncludeEntrypoint(include ast.Include) (string, error) {
return "" 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
} }

View File

@ -5,19 +5,23 @@ import (
"context" "context"
"fmt" "fmt"
"os" "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. // A StdinNode is a node that reads a taskfile from the standard input stream.
type StdinNode struct { type StdinNode struct {
*BaseNode *BaseNode
Dir string
} }
func NewStdinNode(dir string) (*StdinNode, error) { func NewStdinNode(dir string) (*StdinNode, error) {
base := NewBaseNode() base := NewBaseNode()
base.dir = dir
return &StdinNode{ return &StdinNode{
BaseNode: base, BaseNode: base,
Dir: dir,
}, nil }, nil
} }
@ -41,6 +45,33 @@ func (node *StdinNode) Read(ctx context.Context) ([]byte, error) {
return stdin, nil return stdin, nil
} }
func (node *StdinNode) BaseDir() string { func (node *StdinNode) ResolveIncludeEntrypoint(include ast.Include) (string, error) {
return node.Dir // 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
} }

View File

@ -48,17 +48,6 @@ func Read(
return nil, &errors.TaskfileVersionCheckError{URI: node.Location()} 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 { err = tf.Includes.Range(func(namespace string, include ast.Include) error {
cache := &templater.Cache{Vars: tf.Vars} cache := &templater.Cache{Vars: tf.Vars}
include = ast.Include{ include = ast.Include{
@ -70,18 +59,22 @@ func Read(
Aliases: include.Aliases, Aliases: include.Aliases,
AdvancedImport: include.AdvancedImport, AdvancedImport: include.AdvancedImport,
Vars: include.Vars, Vars: include.Vars,
BaseDir: include.BaseDir,
} }
if err := cache.Err(); err != nil { if err := cache.Err(); err != nil {
return err return err
} }
uri, err := include.FullTaskfilePath() entrypoint, err := node.ResolveIncludeEntrypoint(include)
if err != nil { if err != nil {
return err 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), WithParent(node),
WithOptional(include.Optional), WithOptional(include.Optional),
) )
@ -109,11 +102,6 @@ func Read(
} }
if include.AdvancedImport { if include.AdvancedImport {
dir, err := include.FullDirPath()
if err != nil {
return err
}
// nolint: errcheck // nolint: errcheck
includedTaskfile.Vars.Range(func(k string, v ast.Var) error { includedTaskfile.Vars.Range(func(k string, v ast.Var) error {
o := v o := v

View File

@ -1,11 +1,16 @@
package taskfile package taskfile
import ( import (
"net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings"
"github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/internal/sysinfo" "github.com/go-task/task/v3/internal/sysinfo"
) )
@ -23,14 +28,80 @@ var (
"Taskfile.dist.yaml", "Taskfile.dist.yaml",
"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 // 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 // return the path to it. If it does not, it will search for any files at the
// files at the given path with any of the default Taskfile files names. If any // given path with any of the default Taskfile files names. If any of these
// of these match a file, the first matching path will be returned. If no files // match a file, the first matching path will be returned. If no files are
// are found, an error will be returned. // found, an error will be returned.
func Exists(path string) (string, error) { func Exists(l *logger.Logger, path string) (string, error) {
fi, err := os.Stat(path) fi, err := os.Stat(path)
if err != nil { if err != nil {
return "", err return "", err
@ -42,10 +113,11 @@ func Exists(path string) (string, error) {
return filepath.Abs(path) return filepath.Abs(path)
} }
for _, n := range defaultTaskfiles { for _, taskfile := range defaultTaskfiles {
fpath := filepathext.SmartJoin(path, n) alt := filepathext.SmartJoin(path, taskfile)
if _, err := os.Stat(fpath); err == nil { if _, err := os.Stat(alt); err == nil {
return filepath.Abs(fpath) 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 // 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 // directory. On supported operating systems, it will also check if the user ID
// of the directory changes and abort if it does. // 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 origPath := path
owner, err := sysinfo.Owner(path) owner, err := sysinfo.Owner(path)
if err != nil { if err != nil {
return "", err return "", err
} }
for { for {
fpath, err := Exists(path) fpath, err := Exists(l, path)
if err == nil { if err == nil {
return fpath, nil return fpath, nil
} }