diff --git a/Runfile b/Runfile index 65116bd..4249cdf 100644 --- a/Runfile +++ b/Runfile @@ -11,6 +11,7 @@ tasks: echo "DONE" example: + dir: ./examples cmd: - |+ - run -f ./examples/Runfile cook + run cook clean diff --git a/cmd/run/main.go b/cmd/run/main.go index bfe2a49..c8a6c14 100644 --- a/cmd/run/main.go +++ b/cmd/run/main.go @@ -9,6 +9,7 @@ import ( "path/filepath" "syscall" + "github.com/nxtcoder17/fwatcher/pkg/logging" "github.com/nxtcoder17/runfile/pkg/runfile" "github.com/urfave/cli/v3" ) @@ -26,6 +27,23 @@ func main() { Aliases: []string{"f"}, Value: "", }, + + &cli.BoolFlag{ + Name: "parallel", + Aliases: []string{"p"}, + Value: false, + }, + + &cli.BoolFlag{ + Name: "watch", + Aliases: []string{"w"}, + Value: false, + }, + + &cli.BoolFlag{ + Name: "debug", + Value: false, + }, }, EnableShellCompletion: true, ShellComplete: func(ctx context.Context, c *cli.Command) { @@ -38,7 +56,7 @@ func main() { panic(err) } - runfile, err := runfile.ParseRunFile(runfilePath) + runfile, err := runfile.Parse(runfilePath) if err != nil { panic(err) } @@ -48,11 +66,18 @@ func main() { } }, Action: func(ctx context.Context, c *cli.Command) error { - if c.Args().Len() > 1 { - return fmt.Errorf("too many arguments") - } - if c.Args().Len() != 1 { - return fmt.Errorf("missing argument") + parallel := c.Bool("parallel") + watch := c.Bool("watch") + debug := c.Bool("debug") + + logging.NewSlogLogger(logging.SlogOptions{ + ShowCaller: debug, + ShowDebugLogs: debug, + SetAsDefaultLogger: true, + }) + + if c.Args().Len() < 1 { + return fmt.Errorf("missing argument, at least one argument is required") } runfilePath, err := locateRunfile(c) @@ -60,13 +85,41 @@ func main() { return err } - runfile, err := runfile.ParseRunFile(runfilePath) + rf, err := runfile.Parse(runfilePath) if err != nil { panic(err) } - s := c.Args().First() - return runfile.Run(ctx, s) + args := make([]string, 0, len(c.Args().Slice())) + for _, arg := range c.Args().Slice() { + if arg == "-p" || arg == "--parallel" { + parallel = true + continue + } + + if arg == "-w" || arg == "--watch" { + watch = true + continue + } + + if arg == "--debug" { + debug = true + continue + } + + args = append(args, arg) + } + + if parallel && watch { + return fmt.Errorf("parallel and watch can't be set together") + } + + return rf.Run(ctx, runfile.RunArgs{ + Tasks: args, + ExecuteInParallel: parallel, + Watch: watch, + Debug: debug, + }) }, } diff --git a/examples/Runfile b/examples/Runfile index 3130c74..e9c548b 100644 --- a/examples/Runfile +++ b/examples/Runfile @@ -9,8 +9,9 @@ tasks: k3: sh: echo -n "hello" dotenv: - - .secrets/env + - ../.secrets/env cmd: + # - sleep 5 - echo "hi hello" - echo "value of k1 is '$k1'" - echo "value of k2 is '$k2'" @@ -21,13 +22,15 @@ tasks: name: clean shell: ["python", "-c"] dotenv: - - .secrets/env + - ../.secrets/env cmd: - |+ import secrets import os + import time print(os.environ['key_id']) - # print(secrets.token_hex(32)) + time.sleep(6) + print(secrets.token_hex(32)) laundry: name: laundry shell: ["node", "-e"] diff --git a/go.mod b/go.mod index e4160a9..b1527b8 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,25 @@ module github.com/nxtcoder17/runfile go 1.22.7 require ( + github.com/joho/godotenv v1.5.1 + github.com/nxtcoder17/fwatcher v1.0.1 github.com/urfave/cli/v3 v3.0.0-alpha9 + golang.org/x/sync v0.8.0 sigs.k8s.io/yaml v1.4.0 ) -require github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/lipgloss v0.13.0 // indirect + github.com/charmbracelet/log v0.4.0 // indirect + github.com/charmbracelet/x/ansi v0.1.4 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sys v0.19.0 // indirect +) diff --git a/go.sum b/go.sum index 8c4c5ae..dd09597 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,47 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= +github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= +github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= +github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= +github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= +github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/nxtcoder17/fwatcher v1.0.1 h1:Rqy+7etcGv9L1KIoK8YGGpAhdXW/pkfkXQwdlJzL1a8= +github.com/nxtcoder17/fwatcher v1.0.1/go.mod h1:MNmSwXYOrqp7U1pUxh0GWB5skpjFTWTQXhAA0+sPJcU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli/v3 v3.0.0-alpha9 h1:P0RMy5fQm1AslQS+XCmy9UknDXctOmG/q/FZkUFnJSo= github.com/urfave/cli/v3 v3.0.0-alpha9/go.mod h1:0kK/RUFHyh+yIKSfWxwheGndfnrvYSmYFVeKCh03ZUc= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/main.go b/main.go deleted file mode 100644 index 57040c6..0000000 --- a/main.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import "fmt" - -func main() { - fmt.Println("RUN FILE") -} diff --git a/pkg/functions/helpers.go b/pkg/functions/helpers.go new file mode 100644 index 0000000..ad75806 --- /dev/null +++ b/pkg/functions/helpers.go @@ -0,0 +1,18 @@ +package functions + +func DefaultIfNil[T any](v *T, dv T) T { + if v == nil { + return dv + } + return *v +} + +// Must panics if err is not nil +// It is intended to be used very sparingly, and only in cases where the caller is +// certain that the error will never be nil in ideal scenarios +func Must[T any](v T, err error) T { + if err != nil { + panic(err) + } + return v +} diff --git a/pkg/runfile/parser.go b/pkg/runfile/parser.go index 33ae39b..91c2235 100644 --- a/pkg/runfile/parser.go +++ b/pkg/runfile/parser.go @@ -1,55 +1,41 @@ package runfile import ( - "bufio" "fmt" + "io" "os" - "strconv" - "strings" + "path/filepath" - "sigs.k8s.io/yaml" + "github.com/joho/godotenv" ) -func ParseRunFile(file string) (*RunFile, error) { - var runfile RunFile - f, err := os.ReadFile(file) - if err != nil { - return &runfile, err - } - err = yaml.Unmarshal(f, &runfile) - if err != nil { - return &runfile, err - } - return &runfile, nil +func parseDotEnv(reader io.Reader) (map[string]string, error) { + return godotenv.Parse(reader) } // parseDotEnv parses the .env file and returns a slice of strings as in os.Environ() -func parseDotEnv(files ...string) ([]string, error) { - results := make([]string, 0, 5) +func parseDotEnvFiles(files ...string) (map[string]string, error) { + results := make(map[string]string) for i := range files { + if !filepath.IsAbs(files[i]) { + return nil, fmt.Errorf("dotenv file path %s, must be absolute", files[i]) + } + f, err := os.Open(files[i]) if err != nil { return nil, err } + m, err := parseDotEnv(f) + if err != nil { + return nil, err + } + f.Close() - s := bufio.NewScanner(f) - for s.Scan() { - s2 := strings.SplitN(s.Text(), "=", 2) - if len(s2) != 2 { - continue - } - s, _ := strconv.Unquote(string(s2[1])) - - // os.Setenv(s2[0], s2[1]) - os.Setenv(s2[0], s) - results = append(results, s2[0]) + for k, v := range m { + results[k] = v } - } - for i := range results { - v := os.Getenv(results[i]) - results[i] = fmt.Sprintf("%s=%v", results[i], v) } return results, nil diff --git a/pkg/runfile/parser_test.go b/pkg/runfile/parser_test.go new file mode 100644 index 0000000..a33331f --- /dev/null +++ b/pkg/runfile/parser_test.go @@ -0,0 +1,103 @@ +package runfile + +import ( + "bytes" + "io" + "reflect" + "testing" +) + +func Test_parseDotEnvFile(t *testing.T) { + type args struct { + reader io.Reader + } + tests := []struct { + name string + args args + want map[string]string + wantErr bool + }{ + { + name: "key=", + args: args{ + reader: bytes.NewBuffer([]byte(`key=`)), + }, + want: map[string]string{ + "key": "", + }, + wantErr: false, + }, + { + name: "key=1", + args: args{ + reader: bytes.NewBuffer([]byte(`key=1`)), + }, + want: map[string]string{ + "key": "1", + }, + wantErr: false, + }, + { + name: "key=one", + args: args{ + reader: bytes.NewBuffer([]byte(`key=one`)), + }, + want: map[string]string{ + "key": "one", + }, + wantErr: false, + }, + { + name: "key='one'", + args: args{ + reader: bytes.NewBuffer([]byte(`key='one'`)), + }, + want: map[string]string{ + "key": "one", + }, + wantErr: false, + }, + { + name: `key='o"ne'`, + args: args{ + reader: bytes.NewBuffer([]byte(`key='o"ne'`)), + }, + want: map[string]string{ + "key": `o"ne`, + }, + wantErr: false, + }, + { + name: `key="one"`, + args: args{ + reader: bytes.NewBuffer([]byte(`key="one"`)), + }, + want: map[string]string{ + "key": `one`, + }, + wantErr: false, + }, + { + name: `key=sample==`, + args: args{ + reader: bytes.NewBuffer([]byte(`key=sample==`)), + }, + want: map[string]string{ + "key": `sample==`, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseDotEnv(tt.args.reader) + if (err != nil) != tt.wantErr { + t.Errorf("parseDotEnvFile() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseDotEnvFile()\n\t got: %#v,\n\twant: %#v", got, tt.want) + } + }) + } +} diff --git a/pkg/runfile/run.go b/pkg/runfile/run.go index 21b70ef..676a928 100644 --- a/pkg/runfile/run.go +++ b/pkg/runfile/run.go @@ -1,24 +1,31 @@ package runfile import ( - "bytes" "context" "fmt" "io" + "log/slog" "os" "os/exec" + "path/filepath" + "strings" + + fn "github.com/nxtcoder17/runfile/pkg/functions" + "golang.org/x/sync/errgroup" ) -type runArgs struct { - shell []string - env []string // [key=value, key=value, ...] - cmd string +type cmdArgs struct { + shell []string + env []string // [key=value, key=value, ...] + workingDir string + + cmd string stdout io.Writer stderr io.Writer } -func runInShell(ctx context.Context, args runArgs) error { +func createCommand(ctx context.Context, args cmdArgs) *exec.Cmd { if args.shell == nil { args.shell = []string{"sh", "-c"} } @@ -42,65 +49,115 @@ func runInShell(ctx context.Context, args runArgs) error { // cargs := append(args.shell[1:], f.Name()) cargs := append(args.shell[1:], args.cmd) c := exec.CommandContext(ctx, shell, cargs...) + c.Dir = args.workingDir c.Env = args.env c.Stdout = args.stdout c.Stderr = args.stderr - return c.Run() + return c } -func (r *RunFile) Run(ctx context.Context, taskName string) error { - task, ok := r.Tasks[taskName] - if !ok { - return fmt.Errorf("task %s not found", taskName) +func (rf *Runfile) runTask(ctx context.Context, task Task) error { + shell := task.Shell + if shell == nil { + shell = []string{"sh", "-c"} } - env := make([]string, len(task.Env)) - for k, v := range task.Env { - switch v := v.(type) { - case string: - env = append(env, fmt.Sprintf("%s=%s", k, v)) - case map[string]any: - shcmd, ok := v["sh"] - if !ok { - return fmt.Errorf("env %s is not a string", k) - } - - s, ok := shcmd.(string) - if !ok { - return fmt.Errorf("shell cmd is not a string") - } - - value := new(bytes.Buffer) - - if err := runInShell(ctx, runArgs{ - shell: task.Shell, - env: os.Environ(), - cmd: s, - stdout: value, - }); err != nil { - return err - } - env = append(env, fmt.Sprintf("%s=%v", k, value.String())) - default: - panic(fmt.Sprintf("env %s is not a string (%T)", k, v)) + dotenvPaths := make([]string, len(task.DotEnv)) + for i, v := range task.DotEnv { + dotenvPath := filepath.Join(filepath.Dir(rf.attrs.RunfilePath), v) + fi, err := os.Stat(dotenvPath) + if err != nil { + return err } + + if fi.IsDir() { + return fmt.Errorf("dotenv file must be a file, but %s is a directory", v) + } + + dotenvPaths[i] = dotenvPath } // parsing dotenv - s, err := parseDotEnv(task.DotEnv...) + dotEnvVars, err := parseDotEnvFiles(dotenvPaths...) if err != nil { return err } + env := make([]string, 0, len(os.Environ())+len(dotEnvVars)) + env = append(env, os.Environ()...) + for k, v := range dotEnvVars { + env = append(env, fmt.Sprintf("%s=%v", k, v)) + } + // INFO: keys from task.Env will override those coming from dotenv files, when duplicated - env = append(s, env...) - - for _, cmd := range task.Commands { - runInShell(ctx, runArgs{ - shell: task.Shell, - env: append(os.Environ(), env...), - cmd: cmd, - }) + envVars, err := parseEnvVars(ctx, task.Env, EvaluationArgs{ + Shell: task.Shell, + Environ: env, + }) + if err != nil { + return err + } + + for k, v := range envVars { + env = append(env, fmt.Sprintf("%s=%v", k, v)) + } + + script := make([]string, 0, len(task.Commands)) + + for _, command := range task.Commands { + script = append(script, command) + } + + cmd := createCommand(ctx, cmdArgs{ + shell: task.Shell, + env: env, + cmd: strings.Join(script, "\n"), + workingDir: fn.DefaultIfNil(task.Dir, fn.Must(os.Getwd())), + }) + + if err := cmd.Run(); err != nil { + return err } return nil } + +type RunArgs struct { + Tasks []string + ExecuteInParallel bool + Watch bool + Debug bool +} + +func (rf *Runfile) Run(ctx context.Context, args RunArgs) error { + for _, v := range args.Tasks { + if _, ok := rf.Tasks[v]; !ok { + return ErrTaskNotFound{TaskName: v, RunfilePath: rf.attrs.RunfilePath} + } + } + + if args.ExecuteInParallel { + slog.Default().Debug("running in parallel mode", "tasks", args.Tasks) + g := new(errgroup.Group) + + for _, tn := range args.Tasks { + g.Go(func() error { + return rf.runTask(ctx, rf.Tasks[tn]) + }) + } + + // Wait for all tasks to finish + if err := g.Wait(); err != nil { + return err + } + + return nil + } + + for _, tn := range args.Tasks { + if err := rf.runTask(ctx, rf.Tasks[tn]); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/runfile/run_test.go b/pkg/runfile/run_test.go new file mode 100644 index 0000000..2f84915 --- /dev/null +++ b/pkg/runfile/run_test.go @@ -0,0 +1,38 @@ +package runfile + +import ( + "context" + "testing" +) + +func TestRunFile_Run(t *testing.T) { + type fields struct { + attrs attrs + Version string + Tasks map[string]TaskSpec + } + type args struct { + ctx context.Context + tasks []string + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Runfile{ + attrs: tt.fields.attrs, + Version: tt.fields.Version, + Tasks: tt.fields.Tasks, + } + if err := r.Run(tt.args.ctx, tt.args.tasks); (err != nil) != tt.wantErr { + t.Errorf("RunFile.Run() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/pkg/runfile/runfile.go b/pkg/runfile/runfile.go new file mode 100644 index 0000000..bc483da --- /dev/null +++ b/pkg/runfile/runfile.go @@ -0,0 +1,35 @@ +package runfile + +import ( + "os" + "path/filepath" + + fn "github.com/nxtcoder17/runfile/pkg/functions" + "sigs.k8s.io/yaml" +) + +type attrs struct { + RunfilePath string +} + +type Runfile struct { + attrs attrs + + Version string + Tasks map[string]Task `json:"tasks"` +} + +func Parse(file string) (*Runfile, error) { + var runfile Runfile + f, err := os.ReadFile(file) + if err != nil { + return &runfile, err + } + err = yaml.Unmarshal(f, &runfile) + if err != nil { + return nil, err + } + + runfile.attrs.RunfilePath = fn.Must(filepath.Abs(file)) + return &runfile, nil +} diff --git a/pkg/runfile/task.go b/pkg/runfile/task.go new file mode 100644 index 0000000..044485c --- /dev/null +++ b/pkg/runfile/task.go @@ -0,0 +1,36 @@ +package runfile + +import ( + "fmt" +) + +type Task struct { + // load env vars from [.env](https://www.google.com/search?q=sample+dotenv+files&udm=2) files + DotEnv []string `json:"dotenv"` + + // working directory for the task + Dir *string `json:"dir,omitempty"` + + Env map[string]any `json:"env"` + + // List of commands to be executed in given shell (default: sh) + Commands []string `json:"cmd"` + + // Shell in which above commands will be executed + // Default: ["sh", "-c"] + /* Common Usecases could be: + - ["bash", "-c"] + - ["python", "-c"] + - ["node", "-e"] + */ + Shell []string `json:"shell"` +} + +type ErrTaskNotFound struct { + TaskName string + RunfilePath string +} + +func (e ErrTaskNotFound) Error() string { + return fmt.Sprintf("[task] %s, not found in [Runfile] at %s", e.TaskName, e.RunfilePath) +} diff --git a/pkg/runfile/type.go b/pkg/runfile/type.go index f62304d..0cb4f6c 100644 --- a/pkg/runfile/type.go +++ b/pkg/runfile/type.go @@ -1,15 +1,63 @@ package runfile -type RunFile struct { - Version string +import ( + "bytes" + "context" + "fmt" +) - Tasks map[string]TaskSpec `json:"tasks"` +/* +EnvVar Values could take multiple forms: + +- my_key: "value" + +or + + - my_key: + "sh": "echo hello hi" + +Object values with `sh` key, such that the output of this command will be the value of the top-level key +*/ +type EnvVar map[string]any + +type EvaluationArgs struct { + Shell []string + Environ []string } -type TaskSpec struct { - // load env vars from [.env](https://www.google.com/search?q=sample+dotenv+files&udm=2) files - DotEnv []string `json:"dotenv"` - Env map[string]any `json:"env"` - Commands []string `json:"cmd"` - Shell []string `json:"shell"` +func parseEnvVars(ctx context.Context, ev EnvVar, args EvaluationArgs) (map[string]string, error) { + env := make(map[string]string, len(ev)) + for k, v := range ev { + switch v := v.(type) { + case string: + env[k] = v + case map[string]any: + shcmd, ok := v["sh"] + if !ok { + return nil, fmt.Errorf("sh key is missing") + } + + s, ok := shcmd.(string) + if !ok { + return nil, fmt.Errorf("shell cmd is not a string") + } + + value := new(bytes.Buffer) + + cmd := createCommand(ctx, cmdArgs{ + shell: args.Shell, + env: args.Environ, + cmd: s, + stdout: value, + }) + if err := cmd.Run(); err != nil { + return nil, err + } + env[k] = value.String() + default: + panic(fmt.Sprintf("env %s is not a string (%T)", k, v)) + } + } + + return env, nil }