diff --git a/cli/cmd/cmds/devx.go b/cli/cmd/cmds/devx.go new file mode 100644 index 0000000..0b4955b --- /dev/null +++ b/cli/cmd/cmds/devx.go @@ -0,0 +1,29 @@ +package cmds + +import ( + "fmt" + "log/slog" + "os" + + "github.com/input-output-hk/catalyst-forge/cli/pkg/command" + "github.com/input-output-hk/catalyst-forge/cli/pkg/run" +) + +type DevX struct { + MarkdownPath string `arg:"" help:"Path to the markdown file."` + CommandName string `arg:"" help:"Command to be executed."` +} + +func (c *DevX) Run(ctx run.RunContext, logger *slog.Logger) error { + raw, err := os.ReadFile(c.MarkdownPath) + if err != nil { + return fmt.Errorf("could not read file at %s: %v", c.MarkdownPath, err) + } + + prog, err := command.ExtractDevXMarkdown(raw) + if err != nil { + return err + } + + return prog.ProcessCmd(c.CommandName, logger) +} diff --git a/cli/cmd/main.go b/cli/cmd/main.go index ecbe746..cf0619a 100644 --- a/cli/cmd/main.go +++ b/cli/cmd/main.go @@ -21,6 +21,7 @@ var cli struct { Deploy cmds.DeployCmd `cmd:"" help:"Deploy a project."` Dump cmds.DumpCmd `cmd:"" help:"Dumps a project's blueprint to JSON."` + Devx cmds.DevX `cmd:"" help:"Reads a forge markdown file and executes a command."` CI cmds.CICmd `cmd:"" help:"Simulate a CI run."` Run cmds.RunCmd `cmd:"" help:"Run an Earthly target."` Scan cmds.ScanCmd `cmd:"" help:"Scan for Earthfiles."` diff --git a/cli/cmd/main_test.go b/cli/cmd/main_test.go index 76dfe04..8d36b7a 100644 --- a/cli/cmd/main_test.go +++ b/cli/cmd/main_test.go @@ -32,6 +32,12 @@ func TestScan(t *testing.T) { }) } +func TestDevX(t *testing.T) { + testscript.Run(t, testscript.Params{ + Dir: "testdata/devx", + }) +} + func mockEarthly() int { for _, arg := range os.Args { fmt.Println(arg) diff --git a/cli/cmd/testdata/devx/1.txt b/cli/cmd/testdata/devx/1.txt new file mode 100644 index 0000000..713adf2 --- /dev/null +++ b/cli/cmd/testdata/devx/1.txt @@ -0,0 +1,19 @@ +exec git init . + +exec forge devx ./Developer.md run-some-command +cmp stdout expect.txt + +-- Developer.md -- +# My cool dev docs + +### Run Some Command !! + +``` sh + echo "should run this command (1)" +``` + +``` sh + echo "should not run this command (1)" +``` +-- expect.txt -- +should run this command (1) \ No newline at end of file diff --git a/cli/cmd/testdata/devx/Developer.md b/cli/cmd/testdata/devx/Developer.md new file mode 100644 index 0000000..264a854 --- /dev/null +++ b/cli/cmd/testdata/devx/Developer.md @@ -0,0 +1,22 @@ +# My cool dev docs + +### Run Some Command !! + +``` sh + echo "should run this command (1)" +``` + +``` sh + echo "should not run this command (1)" +``` + +### Extra $Cool$ Command + +``` sh + echo "should run this command (2)" + echo "should run another command (2)" +``` + +``` sh + echo "should not run this command (2)" +``` \ No newline at end of file diff --git a/cli/go.mod b/cli/go.mod index 0d2d895..dc09f00 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -70,6 +70,7 @@ require ( github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.2.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/yuin/goldmark v1.7.4 // indirect golang.org/x/crypto v0.26.0 // indirect golang.org/x/mod v0.20.0 // indirect golang.org/x/net v0.28.0 // indirect diff --git a/cli/go.sum b/cli/go.sum index 7cb2bc5..fe6a278 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -185,6 +185,8 @@ github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8 github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= +github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= diff --git a/cli/pkg/command/executor.go b/cli/pkg/command/executor.go new file mode 100644 index 0000000..6857476 --- /dev/null +++ b/cli/pkg/command/executor.go @@ -0,0 +1,29 @@ +package command + +type LanguageExecutor struct { + executor map[string]Executor +} + +func NewDefaultLanguageExecutor() LanguageExecutor { + return LanguageExecutor{ + executor: map[string]Executor{ + "sh": ShellLanguageExecutor{}, + }, + } +} + +type Executor interface { + GetExecutorCommand() string + GetExecutorArgs(content string) []string +} + +// shell +type ShellLanguageExecutor struct{} + +func (e ShellLanguageExecutor) GetExecutorCommand() string { + return "sh" +} + +func (e ShellLanguageExecutor) GetExecutorArgs(content string) []string { + return formatArgs([]string{"-c", "$"}, content) +} diff --git a/cli/pkg/command/program.go b/cli/pkg/command/program.go new file mode 100644 index 0000000..e226fd6 --- /dev/null +++ b/cli/pkg/command/program.go @@ -0,0 +1,85 @@ +package command + +import ( + "fmt" + "log/slog" + "os/exec" + "regexp" + "strings" + "unicode" + + "github.com/input-output-hk/catalyst-forge/cli/pkg/executor" +) + +type Program struct { + name string + groups []CommandGroup +} + +type CommandGroup struct { + name string + commands []Command +} + +type Command struct { + content string + lang *string + platform *string +} + +func (prog *Program) ProcessCmd(cmd string, logger *slog.Logger) error { + var foundCmd *Command + for _, v := range prog.groups { + if v.GetId() == cmd { + // TODO: should get the fisrt (most specified) command corresponding to the current host platform + foundCmd = &v.commands[0] + } + } + + if foundCmd == nil { + return fmt.Errorf("command '%s' not found in markdown", cmd) + } + + return foundCmd.exec(logger) +} + +func (cmd *Command) exec(logger *slog.Logger) error { + if cmd.lang == nil { + return fmt.Errorf("command block without specified language") + } + + lang, ok := NewDefaultLanguageExecutor().executor[*cmd.lang] + if !ok { + return fmt.Errorf("only commands running with `sh` can be executed") + } + if _, err := exec.LookPath(lang.GetExecutorCommand()); err != nil { + return fmt.Errorf("command '%s' is unavailable", lang.GetExecutorCommand()) + } + + localExec := executor.NewLocalExecutor( + logger, + executor.WithRedirect(), + ) + _, err := localExec.Execute(lang.GetExecutorCommand(), lang.GetExecutorArgs(cmd.content)) + + return err +} + +func (cg *CommandGroup) GetId() string { + var result []rune + + for _, char := range cg.name { + if unicode.IsLetter(char) || unicode.IsDigit(char) { + result = append(result, unicode.ToLower(char)) + } else if unicode.IsSpace(char) { + result = append(result, '-') + } + } + + joined := string(result) + + re := regexp.MustCompile(`-+`) + joined = re.ReplaceAllString(joined, "-") + + return strings.Trim(joined, "-") +} diff --git a/cli/pkg/command/utils.go b/cli/pkg/command/utils.go new file mode 100644 index 0000000..ddd7a7e --- /dev/null +++ b/cli/pkg/command/utils.go @@ -0,0 +1,93 @@ +package command + +import ( + "bytes" + "errors" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" +) + +func ExtractDevXMarkdown(data []byte) (*Program, error) { + md := goldmark.New() + reader := text.NewReader(data) + doc := md.Parser().Parse(reader) + + // store the command groups and commands + groups := []CommandGroup{} + var progName *string + var currentPlatform *string + + // walk through the ast nodes + ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + // look up for headers + if heading, ok := n.(*ast.Heading); ok && entering { + if heading.Level == 1 { + title := string(heading.Text(data)) + + progName = &title + } + + if heading.Level == 3 { + currentPlatform = nil + commandName := string(heading.Text(data)) + + groups = append(groups, CommandGroup{ + name: commandName, + commands: []Command{}, + }) + } + + /* if heading.Level == 4 && len(groups) > 0 { + platform := string(heading.Text(data)) + currentPlatform = &platform + } */ + } + + // look up for code blocks + if block, ok := n.(*ast.FencedCodeBlock); ok && entering && len(groups) > 0 { + i := len(groups) - 1 + lang := string(block.Language(data)) + + var buf bytes.Buffer + for i := 0; i < block.Lines().Len(); i++ { + line := block.Lines().At(i) + buf.Write(line.Value(data)) + } + + groups[i].commands = append(groups[i].commands, Command{ + content: buf.String(), + lang: &lang, + platform: currentPlatform, + }) + } + + return ast.WalkContinue, nil + }) + + if len(groups) == 0 { + return nil, errors.New("no command groups found in the markdown") + } + if progName == nil { + return nil, errors.New("no title found in the markdown") + } + + prog := Program{ + name: *progName, + groups: groups, + } + + return &prog, nil +} + +func formatArgs(base []string, replacement string) []string { + replaced := make([]string, len(base)) + + for i, str := range base { + replaced[i] = strings.ReplaceAll(str, "$", replacement) + } + + return replaced +}