diff --git a/cli/cmd/cmds/devx.go b/cli/cmd/cmds/devx.go new file mode 100644 index 00000000..0b4955b3 --- /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 0b6e04fc..a9075f9f 100644 --- a/cli/cmd/main.go +++ b/cli/cmd/main.go @@ -26,6 +26,7 @@ var cli struct { Deploy cmds.DeployCmd `kong:"cmd" help:"Deploy a project."` Dump cmds.DumpCmd `kong:"cmd" help:"Dumps a project's blueprint to JSON."` + Devx cmds.DevX `kong:"cmd" help:"Reads a forge markdown file and executes a command."` CI cmds.CICmd `kong:"cmd" help:"Simulate a CI run."` Release cmds.ReleaseCmd `kong:"cmd" help:"Release a project."` Run cmds.RunCmd `kong:"cmd" help:"Run an Earthly target."` diff --git a/cli/cmd/main_test.go b/cli/cmd/main_test.go index c48fcf73..9a8e3ad1 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 00000000..713adf25 --- /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 00000000..264a8549 --- /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 f4ff7d35..a7216291 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -80,6 +80,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 60532119..1384d994 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -208,6 +208,8 @@ github.com/willabides/kongplete v0.4.0/go.mod h1:0P0jtWD9aTsqPSUAl4de35DLghrr57X 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 00000000..68574762 --- /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 00000000..871da2ae --- /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 00000000..ddd7a7e1 --- /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 +} diff --git a/docs/src/reference/devx/Developer.md b/docs/src/reference/devx/Developer.md new file mode 100644 index 00000000..7f9befdf --- /dev/null +++ b/docs/src/reference/devx/Developer.md @@ -0,0 +1,150 @@ +# Example Developer.md file + +This is an example of a Developer.md file. +It is used to help explain, informally, the structure of Developer.md files. + +First level headings are just documentation. + +## Second level headings are command names + +They are normalized, so this command would become: `second-level-headings-are-command-names`. + +Only the first matching code block that has a recognized type is used. + +This command uses `sh` which means, run the command in the shell of the caller. +Because it's not possible to know exactly what shell the caller is using, +these kinds of commands should be simple and not have any logic. + +```sh +echo "This is a command run in the shell of the caller" +``` + +## A command that uses bash + +To ensure that a known shell is used, `bash` can be used to ensure the command is run inside a `bash` shell. +The system must have `bash` installed, or it will fail. + +Scripts are run together, not as a distinct set of commands. +So it's easy to do loops or multi-line statements without using backslash. +Comments can be used to better explain the script. + +```bash +for i in 1 2 3; do + echo "Executing command $i" + # Pause for half a second to make it easier to see the output. + sleep 0.5 +done +``` + +## A command that uses python 3 + +Currently, only `sh`, `bash` and `python` are intended to be supported. + +This would never get executed. +Its just documentation. +The `python` script below could be written in `rust` like so: + +```rust +use std::thread; +use std::time::Duration; + +fn main() { + for i in 1..=3 { + println!("Executing command {}", i); + // Pause for half a second to make it easier to see the output. + thread::sleep(Duration::from_secs_f64(0.5)); + } +} +``` + +Similar to bash, commands can be run inside a python interpreter. +The system must have `python` installed, or it will fail. +These scripts should restrict themselves to the python standard library. + +```python +import time +print("This is a command run inside python") + +for i in range(1, 4): + print("Executing command", i) + # Pause for half a second to make it easier to see the output. + time.sleep(0.5) +``` + +The list of supported interpreted/scripting languages could grow. +It will never include complied languages like C, Rust, Go, etc. + +## What about parameters + +Currently, parameters are not defined. +However, Environment variables will be passed through from the caller to a command. + +Environment variables can be used to parameterize any command. +If they are, they should be documented in the Developer.md file with the command. + +This command will show all the current caller's environment variables. + +```python +import os + +for key, value in os.environ.items(): + print(f"Key: {key}, Value: {value}") +``` + +## System-specific commands + +Sometimes different systems require different commands. +This can be accommodated by placing a `platforms` table before a command. + +IF the current platform matches one in the list, the command is used. +Otherwise, it is skipped as being documentation. + +The ***FIRST*** command to match the users platform will run. +All others are ignored. +Therefore, specific platforms should be listed first. + +### Linux/Mac on ARM + +| Platforms | +| --- | +| linux/aarch64 | +| darwin/aarch64 | + +This only is executed on Linux and Mac if the CPU is ARM-Based. + +If we had specified `linux` or `darwin` by themselves, then the CPU type would not matter for that platform. + +If we specified `aarch64` by itself, then any platform using an Arm processor would match. + +```sh +echo "This only runs on Linux or Mac if the CPU is ARM Based." +``` + +### That somewhat popular OS from Redmond + +| Platforms | +| --- | +| windows | + +This is executed on all variants of Windows. + +```sh +@echo off +echo This only runs on Windows. +``` + +### Default + +Third level headings are just documentation, they have no special meaning. + +But in this case, they can be used to break up the platforms to make the intention clearer. +This would still execute the same without the third level headings. + +As this is the last command block, if none of the above executed, it will execute. + +It has to be listed last. +Otherwise it will match first and none of the platform-specific commands will run. + +```sh +echo "This will run on all other platforms." +```