From 57bcd5a2c94fd551b52c82f8118d89d569d56d86 Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Sun, 22 Sep 2024 20:29:31 -0400 Subject: [PATCH] feat!: templates v2 and tui stability upgrades (#158) --- cmd/internal/exec.go | 108 ++++++++--- cmd/internal/flags/types.go | 22 ++- cmd/internal/get.go | 74 ++++++-- cmd/internal/helpers.go | 95 ++++++++++ cmd/internal/init.go | 158 ++++++---------- cmd/internal/interactive/interactive.go | 66 ------- cmd/internal/library.go | 9 +- cmd/internal/list.go | 78 ++++++-- cmd/internal/logs.go | 23 +-- cmd/internal/remove.go | 49 +++-- cmd/internal/set.go | 35 ++-- cmd/internal/sync.go | 3 +- docs/cli/flow_get.md | 3 +- docs/cli/flow_get_template.md | 29 +++ docs/cli/flow_get_workspace.md | 2 +- docs/cli/flow_init_executables.md | 22 +-- docs/cli/flow_list.md | 1 + docs/cli/flow_list_templates.md | 27 +++ docs/guides/exec.md | 0 docs/guides/inputs.md | 0 docs/guides/secrets.md | 0 docs/guides/templating.md | 0 docs/quickstart.md | 0 docs/types/template.md | 112 ++++++++++++ examples/exec-template.flow.tmpl | 23 +-- flow.yaml | 4 +- go.mod | 22 ++- go.sum | 85 ++++++++- internal/cache/executables_cache_test.go | 4 +- internal/context/context.go | 62 +++++-- internal/filesystem/cache.go | 2 +- internal/filesystem/config.go | 2 +- internal/filesystem/executables.go | 18 +- internal/filesystem/executables_test.go | 8 +- internal/filesystem/helpers.go | 53 +++--- internal/filesystem/templates.go | 137 ++------------ internal/filesystem/templates_test.go | 111 +++++++----- internal/io/common/common.go | 2 + internal/io/config/views.go | 18 +- internal/io/executable/output.go | 57 +++++- internal/io/executable/views.go | 116 +++++++++--- internal/io/library/init.go | 6 +- internal/io/library/library.go | 7 +- internal/io/library/update.go | 9 +- internal/io/library/view.go | 4 +- internal/io/secret/views.go | 73 ++++---- internal/io/workspace/views.go | 38 ++-- internal/runner/render/render.go | 11 +- internal/templates/artifacts.go | 135 ++++++++++++++ internal/templates/form.go | 51 ++++++ internal/templates/templates.go | 221 +++++++++++++++++++++++ internal/vault/secret.go | 6 +- schemas/flowfile_schema.json | 1 - schemas/template_schema.json | 163 +++++++++++++++++ tests/e2e_test.go | 14 +- tests/secret_cmds_e2e_test.go | 24 ++- tests/template_cmds_e2e_test.go | 166 +++++++++++++++++ tests/utils/runner.go | 15 +- tools/docsgen/json.go | 7 +- tools/docsgen/main.go | 6 +- tools/docsgen/markdown.go | 1 + tools/docsgen/schema/consts.go | 3 + tools/docsgen/schema/schema.go | 41 ++++- tools/docsgen/schema/schema_test.go | 20 ++ tools/docsgen/schema/types.go | 29 ++- types/executable/executable.go | 6 +- types/executable/executable_md.go | 62 +++++++ types/executable/flowfile.go | 114 ++---------- types/executable/template.gen.go | 112 ++++++++++++ types/executable/template.go | 166 +++++++++++++++++ types/executable/template_schema.yaml | 136 ++++++++++++++ types/executable/template_test.go | 140 ++++++++++++++ types/workspace/workspace.go | 6 +- types/workspace/workspace_md.go | 8 +- 74 files changed, 2599 insertions(+), 842 deletions(-) delete mode 100644 cmd/internal/interactive/interactive.go create mode 100644 docs/cli/flow_get_template.md create mode 100644 docs/cli/flow_list_templates.md create mode 100644 docs/guides/exec.md create mode 100644 docs/guides/inputs.md create mode 100644 docs/guides/secrets.md create mode 100644 docs/guides/templating.md create mode 100644 docs/quickstart.md create mode 100644 docs/types/template.md create mode 100644 internal/templates/artifacts.go create mode 100644 internal/templates/form.go create mode 100644 internal/templates/templates.go create mode 100644 schemas/template_schema.json create mode 100644 tests/template_cmds_e2e_test.go create mode 100644 types/executable/template.gen.go create mode 100644 types/executable/template.go create mode 100644 types/executable/template_schema.yaml create mode 100644 types/executable/template_test.go diff --git a/cmd/internal/exec.go b/cmd/internal/exec.go index ce699a7..4107d52 100644 --- a/cmd/internal/exec.go +++ b/cmd/internal/exec.go @@ -8,10 +8,9 @@ import ( "time" "github.com/gen2brain/beeep" - "github.com/jahvon/tuikit/components" + "github.com/jahvon/tuikit/views" "github.com/spf13/cobra" - "github.com/jahvon/flow/cmd/internal/interactive" "github.com/jahvon/flow/internal/cache" "github.com/jahvon/flow/internal/context" "github.com/jahvon/flow/internal/io" @@ -62,14 +61,13 @@ func RegisterExecCmd(ctx *context.Context, rootCmd *cobra.Command) { rootCmd.AddCommand(subCmd) } -func execPreRun(ctx *context.Context, cmd *cobra.Command, _ []string) { +func execPreRun(_ *context.Context, _ *cobra.Command, _ []string) { runner.RegisterRunner(exec.NewRunner()) runner.RegisterRunner(launch.NewRunner()) runner.RegisterRunner(request.NewRunner()) runner.RegisterRunner(render.NewRunner()) runner.RegisterRunner(serial.NewRunner()) runner.RegisterRunner(parallel.NewRunner()) - interactive.InitInteractiveCommand(ctx, cmd) } //nolint:gocognit @@ -114,15 +112,18 @@ func execFunc(ctx *context.Context, cmd *cobra.Command, verb executable.Verb, ar envMap = make(map[string]string) } - setAuthEnv(ctx, e) - textInputs := pendingTextInputs(ctx, e) + setAuthEnv(ctx, cmd, e) + textInputs := pendingFormFields(ctx, e) if len(textInputs) > 0 { - inputs, err := components.ProcessInputs(io.Theme(), textInputs...) + form, err := views.NewForm(io.Theme(), ctx.StdIn(), ctx.StdOut(), textInputs...) if err != nil { logger.FatalErr(err) } - for _, input := range inputs { - envMap[input.Key] = input.Value() + if err := form.Run(ctx.Ctx); err != nil { + logger.FatalErr(err) + } + for key, val := range form.ValueMap() { + envMap[key] = fmt.Sprintf("%v", val) } } startTime := time.Now() @@ -131,7 +132,7 @@ func execFunc(ctx *context.Context, cmd *cobra.Command, verb executable.Verb, ar } dur := time.Since(startTime) logger.Infox(fmt.Sprintf("%s flow completed", ref), "Elapsed", dur.Round(time.Millisecond)) - if interactive.UIEnabled(ctx, cmd) { + if TUIEnabled(ctx, cmd) { if dur > 1*time.Minute && ctx.Config.SendSoundNotification() { _ = beeep.Beep(beeep.DefaultFreq, beeep.DefaultDuration) } @@ -172,19 +173,24 @@ func runByRef(ctx *context.Context, cmd *cobra.Command, argsStr string) error { return nil } -func setAuthEnv(ctx *context.Context, executable *executable.Executable) { +func setAuthEnv(ctx *context.Context, _ *cobra.Command, executable *executable.Executable) { if authRequired(ctx, executable) { - resp, err := components.ProcessInputs( + form, err := views.NewForm( io.Theme(), - &components.TextInput{ - Key: vault.EncryptionKeyEnvVar, - Prompt: "Enter vault encryption key", - Hidden: true, + ctx.StdIn(), + ctx.StdOut(), + &views.FormField{ + Key: vault.EncryptionKeyEnvVar, + Title: "Enter vault encryption key", + Type: views.PromptTypeMasked, }) if err != nil { ctx.Logger.FatalErr(err) } - val := resp.ValueMap()[vault.EncryptionKeyEnvVar] + if err := form.Run(ctx.Ctx); err != nil { + ctx.Logger.FatalErr(err) + } + val := form.FindByKey(vault.EncryptionKeyEnvVar).Value() if val == "" { ctx.Logger.FatalErr(fmt.Errorf("vault encryption key required")) } @@ -194,7 +200,9 @@ func setAuthEnv(ctx *context.Context, executable *executable.Executable) { } } -//nolint:gocognit +// TODO: refactor this function to simplify the logic +// +//nolint:all func authRequired(ctx *context.Context, rootExec *executable.Executable) bool { if os.Getenv(vault.EncryptionKeyEnvVar) != "" { return false @@ -239,6 +247,17 @@ func authRequired(ctx *context.Context, rootExec *executable.Executable) bool { return true } } + for _, e := range rootExec.Serial.Execs { + if e.Ref != "" { + childExec, err := ctx.ExecutableCache.GetExecutableByRef(ctx.Logger, e.Ref) + if err != nil { + continue + } + if authRequired(ctx, childExec) { + return true + } + } + } case rootExec.Parallel != nil: for _, param := range rootExec.Parallel.Params { if param.SecretRef != "" { @@ -254,42 +273,53 @@ func authRequired(ctx *context.Context, rootExec *executable.Executable) bool { return true } } + for _, e := range rootExec.Parallel.Execs { + if e.Ref != "" { + childExec, err := ctx.ExecutableCache.GetExecutableByRef(ctx.Logger, e.Ref) + if err != nil { + continue + } + if authRequired(ctx, childExec) { + return true + } + } + } } return false } -//nolint:gocognit -func pendingTextInputs(ctx *context.Context, rootExec *executable.Executable) []*components.TextInput { - pending := make([]*components.TextInput, 0) +//nolint:gocognit,funlen +func pendingFormFields(ctx *context.Context, rootExec *executable.Executable) []*views.FormField { + pending := make([]*views.FormField, 0) switch { case rootExec.Exec != nil: for _, param := range rootExec.Exec.Params { if param.Prompt != "" { - pending = append(pending, &components.TextInput{Key: param.EnvKey, Prompt: param.Prompt}) + pending = append(pending, &views.FormField{Key: param.EnvKey, Title: param.Prompt}) } } case rootExec.Launch != nil: for _, param := range rootExec.Launch.Params { if param.Prompt != "" { - pending = append(pending, &components.TextInput{Key: param.EnvKey, Prompt: param.Prompt}) + pending = append(pending, &views.FormField{Key: param.EnvKey, Title: param.Prompt}) } } case rootExec.Request != nil: for _, param := range rootExec.Request.Params { if param.Prompt != "" { - pending = append(pending, &components.TextInput{Key: param.EnvKey, Prompt: param.Prompt}) + pending = append(pending, &views.FormField{Key: param.EnvKey, Title: param.Prompt}) } } case rootExec.Render != nil: for _, param := range rootExec.Render.Params { if param.Prompt != "" { - pending = append(pending, &components.TextInput{Key: param.EnvKey, Prompt: param.Prompt}) + pending = append(pending, &views.FormField{Key: param.EnvKey, Title: param.Prompt}) } } case rootExec.Serial != nil: for _, param := range rootExec.Serial.Params { if param.Prompt != "" { - pending = append(pending, &components.TextInput{Key: param.EnvKey, Prompt: param.Prompt}) + pending = append(pending, &views.FormField{Key: param.EnvKey, Title: param.Prompt}) } } for _, child := range rootExec.Serial.Refs { @@ -297,13 +327,23 @@ func pendingTextInputs(ctx *context.Context, rootExec *executable.Executable) [] if err != nil { continue } - childPending := pendingTextInputs(ctx, childExec) + childPending := pendingFormFields(ctx, childExec) pending = append(pending, childPending...) } + for _, child := range rootExec.Serial.Execs { + if child.Ref != "" { + childExec, err := ctx.ExecutableCache.GetExecutableByRef(ctx.Logger, child.Ref) + if err != nil { + continue + } + childPending := pendingFormFields(ctx, childExec) + pending = append(pending, childPending...) + } + } case rootExec.Parallel != nil: for _, param := range rootExec.Parallel.Params { if param.Prompt != "" { - pending = append(pending, &components.TextInput{Key: param.EnvKey, Prompt: param.Prompt}) + pending = append(pending, &views.FormField{Key: param.EnvKey, Title: param.Prompt}) } } for _, child := range rootExec.Parallel.Refs { @@ -311,9 +351,19 @@ func pendingTextInputs(ctx *context.Context, rootExec *executable.Executable) [] if err != nil { continue } - childPending := pendingTextInputs(ctx, childExec) + childPending := pendingFormFields(ctx, childExec) pending = append(pending, childPending...) } + for _, child := range rootExec.Parallel.Execs { + if child.Ref != "" { + childExec, err := ctx.ExecutableCache.GetExecutableByRef(ctx.Logger, child.Ref) + if err != nil { + continue + } + childPending := pendingFormFields(ctx, childExec) + pending = append(pending, childPending...) + } + } } return pending } diff --git a/cmd/internal/flags/types.go b/cmd/internal/flags/types.go index 3e7ae6a..3035039 100644 --- a/cmd/internal/flags/types.go +++ b/cmd/internal/flags/types.go @@ -124,10 +124,18 @@ var LastLogEntryFlag = &Metadata{ Required: false, } -var SubPathFlag = &Metadata{ - Name: "subPath", - Shorthand: "p", - Usage: "Sub-path within the workspace to create the executable definition and its artifacts.", +var TemplateWorkspaceFlag = &Metadata{ + Name: "workspace", + Shorthand: "w", + Usage: "Workspace to create the flow file and its artifacts. Defaults to the current workspace.", + Default: "", + Required: false, +} + +var TemplateOutputPathFlag = &Metadata{ + Name: "output", + Shorthand: "o", + Usage: "Output directory (within the workspace) to create the flow file and its artifacts. If the directory does not exist, it will be created.", Default: "", Required: false, } @@ -135,15 +143,15 @@ var SubPathFlag = &Metadata{ var TemplateFlag = &Metadata{ Name: "template", Shorthand: "t", - Usage: "Template to use as the template for the executables. Templates are registered in the flow configuration file.", + Usage: "Registered template name. Templates can be registered in the flow configuration file or with `flow set template`.", Default: "", Required: false, } -var FileFlag = &Metadata{ +var TemplateFilePathFlag = &Metadata{ Name: "file", Shorthand: "f", - Usage: "File to use as the template for the executables. It must be a valid executable definition template.", + Usage: "Path to the template file. It must be a valid flow file template.", Default: "", Required: false, } diff --git a/cmd/internal/get.go b/cmd/internal/get.go index ed05094..5b30af4 100644 --- a/cmd/internal/get.go +++ b/cmd/internal/get.go @@ -4,13 +4,12 @@ import ( "fmt" "github.com/atotto/clipboard" - "github.com/jahvon/tuikit/components" + "github.com/jahvon/tuikit/types" "github.com/pkg/errors" "github.com/spf13/cobra" "golang.org/x/exp/maps" "github.com/jahvon/flow/cmd/internal/flags" - "github.com/jahvon/flow/cmd/internal/interactive" "github.com/jahvon/flow/internal/cache" "github.com/jahvon/flow/internal/context" "github.com/jahvon/flow/internal/filesystem" @@ -32,6 +31,7 @@ func RegisterGetCmd(ctx *context.Context, rootCmd *cobra.Command) { registerGetWsCmd(ctx, getCmd) registerGetExecCmd(ctx, getCmd) registerGetSecretCmd(ctx, getCmd) + registerGetTemplateCmd(ctx, getCmd) rootCmd.AddCommand(getCmd) } @@ -41,8 +41,8 @@ func registerGetConfigCmd(ctx *context.Context, getCmd *cobra.Command) { Aliases: []string{"cfg"}, Short: "Print the current global configuration values.", Args: cobra.NoArgs, - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveContainer(ctx, cmd) }, - PostRun: func(cmd *cobra.Command, args []string) { interactive.WaitForExit(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { StartTUI(ctx, cmd) }, + PostRun: func(cmd *cobra.Command, args []string) { WaitForTUI(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { getConfigFunc(ctx, cmd, args) }, } RegisterFlag(ctx, configCmd, *flags.OutputFormatFlag) @@ -53,9 +53,9 @@ func getConfigFunc(ctx *context.Context, cmd *cobra.Command, _ []string) { logger := ctx.Logger userConfig := ctx.Config outputFormat := flags.ValueFor[string](ctx, cmd, *flags.OutputFormatFlag, false) - if interactive.UIEnabled(ctx, cmd) { - view := configio.NewUserConfigView(ctx.InteractiveContainer, *userConfig, components.Format(outputFormat)) - ctx.InteractiveContainer.SetView(view) + if TUIEnabled(ctx, cmd) { + view := configio.NewUserConfigView(ctx.TUIContainer, *userConfig, types.Format(outputFormat)) + SetView(ctx, cmd, view) } else { configio.PrintUserConfig(logger, outputFormat, userConfig) } @@ -65,13 +65,13 @@ func registerGetWsCmd(ctx *context.Context, getCmd *cobra.Command) { wsCmd := &cobra.Command{ Use: "workspace [NAME]", Aliases: []string{"ws"}, - Short: "Print a workspace's configuration. If the name is omitted, the current workspace is used.", + Short: "Print a workspaces configuration. If the name is omitted, the current workspace is used.", Args: cobra.MaximumNArgs(1), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return maps.Keys(ctx.Config.Workspaces), cobra.ShellCompDirectiveNoFileComp }, - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveContainer(ctx, cmd) }, - PostRun: func(cmd *cobra.Command, args []string) { interactive.WaitForExit(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { StartTUI(ctx, cmd) }, + PostRun: func(cmd *cobra.Command, args []string) { WaitForTUI(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { getWsFunc(ctx, cmd, args) }, } RegisterFlag(ctx, wsCmd, *flags.OutputFormatFlag) @@ -97,9 +97,9 @@ func getWsFunc(ctx *context.Context, cmd *cobra.Command, args []string) { } outputFormat := flags.ValueFor[string](ctx, cmd, *flags.OutputFormatFlag, false) - if interactive.UIEnabled(ctx, cmd) { - view := workspaceio.NewWorkspaceView(ctx, wsCfg, components.Format(outputFormat)) - ctx.InteractiveContainer.SetView(view) + if TUIEnabled(ctx, cmd) { + view := workspaceio.NewWorkspaceView(ctx, wsCfg, types.Format(outputFormat)) + SetView(ctx, cmd, view) } else { workspaceio.PrintWorkspaceConfig(logger, outputFormat, wsCfg) } @@ -119,8 +119,8 @@ func registerGetExecCmd(ctx *context.Context, getCmd *cobra.Command) { io.TypesDocsURL("flowfile", "ExecutableRef"), ), Args: cobra.ExactArgs(2), - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveContainer(ctx, cmd) }, - PostRun: func(cmd *cobra.Command, args []string) { interactive.WaitForExit(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { StartTUI(ctx, cmd) }, + PostRun: func(cmd *cobra.Command, args []string) { WaitForTUI(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { getExecFunc(ctx, cmd, args) }, } RegisterFlag(ctx, execCmd, *flags.OutputFormatFlag) @@ -160,10 +160,10 @@ func getExecFunc(ctx *context.Context, cmd *cobra.Command, args []string) { } outputFormat := flags.ValueFor[string](ctx, cmd, *flags.OutputFormatFlag, false) - if interactive.UIEnabled(ctx, cmd) { + if TUIEnabled(ctx, cmd) { runFunc := func(ref string) error { return runByRef(ctx, cmd, ref) } - view := executableio.NewExecutableView(ctx, *exec, components.Format(outputFormat), runFunc) - ctx.InteractiveContainer.SetView(view) + view := executableio.NewExecutableView(ctx, exec, types.Format(outputFormat), runFunc) + SetView(ctx, cmd, view) } else { executableio.PrintExecutable(logger, outputFormat, exec) } @@ -175,7 +175,7 @@ func registerGetSecretCmd(ctx *context.Context, getCmd *cobra.Command) { Aliases: []string{"scrt"}, Short: "Print the value of a secret in the flow secret vault.", Args: cobra.ExactArgs(1), - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveCommand(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { printContext(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { getSecretFunc(ctx, cmd, args) }, } RegisterFlag(ctx, secretCmd, *flags.OutputSecretAsPlainTextFlag) @@ -209,3 +209,39 @@ func getSecretFunc(ctx *context.Context, cmd *cobra.Command, args []string) { } } } + +func registerGetTemplateCmd(ctx *context.Context, getCmd *cobra.Command) { + templateCmd := &cobra.Command{ + Use: "template", + Aliases: []string{"tmpl"}, + Short: "Print a flowfile template using it's registered name or file path.", + PreRun: func(cmd *cobra.Command, args []string) { StartTUI(ctx, cmd) }, + PostRun: func(cmd *cobra.Command, args []string) { WaitForTUI(ctx, cmd) }, + Run: func(cmd *cobra.Command, args []string) { getTemplateFunc(ctx, cmd, args) }, + } + RegisterFlag(ctx, templateCmd, *flags.TemplateFlag) + RegisterFlag(ctx, templateCmd, *flags.TemplateFilePathFlag) + MarkOneFlagRequired(templateCmd, flags.TemplateFlag.Name, flags.TemplateFilePathFlag.Name) + RegisterFlag(ctx, templateCmd, *flags.OutputFormatFlag) + getCmd.AddCommand(templateCmd) +} + +func getTemplateFunc(ctx *context.Context, cmd *cobra.Command, _ []string) { + logger := ctx.Logger + template := flags.ValueFor[string](ctx, cmd, *flags.TemplateFlag, false) + templateFilePath := flags.ValueFor[string](ctx, cmd, *flags.TemplateFilePathFlag, false) + + tmpl := loadFlowfileTemplate(ctx, template, templateFilePath) + if tmpl == nil { + logger.Fatalf("unable to load flowfile template") + } + + outputFormat := flags.ValueFor[string](ctx, cmd, *flags.OutputFormatFlag, false) + if TUIEnabled(ctx, cmd) { + runFunc := func(ref string) error { return runByRef(ctx, cmd, ref) } + view := executableio.NewTemplateView(ctx, tmpl, types.Format(outputFormat), runFunc) + SetView(ctx, cmd, view) + } else { + executableio.PrintTemplate(logger, outputFormat, tmpl) + } +} diff --git a/cmd/internal/helpers.go b/cmd/internal/helpers.go index aa67650..c442cb0 100644 --- a/cmd/internal/helpers.go +++ b/cmd/internal/helpers.go @@ -1,10 +1,18 @@ package internal import ( + "os" + "strconv" + + "github.com/jahvon/tuikit" "github.com/spf13/cobra" "github.com/jahvon/flow/cmd/internal/flags" "github.com/jahvon/flow/internal/context" + "github.com/jahvon/flow/internal/filesystem" + "github.com/jahvon/flow/internal/io" + "github.com/jahvon/flow/types/executable" + "github.com/jahvon/flow/types/workspace" ) func RegisterFlag(ctx *context.Context, cmd *cobra.Command, flag flags.Metadata) { @@ -13,6 +21,9 @@ func RegisterFlag(ctx *context.Context, cmd *cobra.Command, flag flags.Metadata) ctx.Logger.FatalErr(err) } cmd.Flags().AddFlagSet(flagSet) + if flag.Required { + MarkFlagRequired(ctx, cmd, flag.Name) + } } func RegisterPersistentFlag(ctx *context.Context, cmd *cobra.Command, flag flags.Metadata) { @@ -42,3 +53,87 @@ func MarkFlagFilename(ctx *context.Context, cmd *cobra.Command, name string) { ctx.Logger.FatalErr(err) } } + +func TUIEnabled(ctx *context.Context, cmd *cobra.Command) bool { + disabled := flags.ValueFor[bool](ctx, cmd.Root(), *flags.NonInteractiveFlag, true) + envDisabled, _ := strconv.ParseBool(os.Getenv("DISABLE_FLOW_INTERACTIVE")) + return !disabled && !envDisabled && ctx.Config.ShowTUI() +} + +func SetView(ctx *context.Context, cmd *cobra.Command, view tuikit.View) { + if TUIEnabled(ctx, cmd) { + if err := ctx.SetView(view); err != nil { + ctx.Logger.Fatalx("unable to set view", "view", view.Type(), "error", err) + } + } else { + ctx.Logger.Errorx("interactive mode is disabled", "view", view.Type()) + } +} + +func StartTUI(ctx *context.Context, cmd *cobra.Command) { + if !TUIEnabled(ctx, cmd) { + return + } + if err := ctx.TUIContainer.Start(); err != nil { + ctx.Logger.FatalErr(err) + } +} + +func WaitForTUI(ctx *context.Context, cmd *cobra.Command) { + if !TUIEnabled(ctx, cmd) { + return + } + ctx.TUIContainer.WaitForExit() +} + +func printContext(ctx *context.Context, cmd *cobra.Command) { + if TUIEnabled(ctx, cmd) { + ctx.Logger.Println(io.Theme().RenderHeader(context.AppName, context.HeaderCtxKey, ctx.String(), 0)) + } +} + +func workspaceOrCurrent(ctx *context.Context, workspaceName string) *workspace.Workspace { + var ws *workspace.Workspace + if workspaceName == "" { + ws = ctx.CurrentWorkspace + workspaceName = ws.AssignedName() + } else { + wsPath, wsFound := ctx.Config.Workspaces[workspaceName] + if !wsFound { + return nil + } + var err error + ws, err = filesystem.LoadWorkspaceConfig(workspaceName, wsPath) + if err != nil { + ctx.Logger.Error(err, "unable to load workspace config") + } + ws.SetContext(workspaceName, wsPath) + } + ctx.Logger.Debugf("'%s' workspace set", workspaceName) + return ws +} + +func loadFlowfileTemplate(ctx *context.Context, name, path string) *executable.Template { + if name != "" { + if ctx.Config.Templates == nil { + ctx.Logger.Errorf("template %s not found", name) + return nil + } + var found bool + if path, found = ctx.Config.Templates[name]; !found { + ctx.Logger.Errorf("template %s not found", name) + return nil + } + } else { + if _, err := os.Stat(path); os.IsNotExist(err) { + ctx.Logger.Errorf("flowfile template at %s not found", path) + return nil + } + } + tmpl, err := filesystem.LoadFlowFileTemplate(name, path) + if err != nil { + ctx.Logger.Error(err, "unable to load flowfile template") + return nil + } + return tmpl +} diff --git a/cmd/internal/init.go b/cmd/internal/init.go index 44d223b..3533a82 100644 --- a/cmd/internal/init.go +++ b/cmd/internal/init.go @@ -4,19 +4,22 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" - "github.com/jahvon/tuikit/components" + "github.com/jahvon/tuikit/views" "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/jahvon/flow/cmd/internal/flags" - "github.com/jahvon/flow/cmd/internal/interactive" "github.com/jahvon/flow/internal/cache" "github.com/jahvon/flow/internal/context" "github.com/jahvon/flow/internal/crypto" "github.com/jahvon/flow/internal/filesystem" "github.com/jahvon/flow/internal/io" + "github.com/jahvon/flow/internal/runner" + "github.com/jahvon/flow/internal/runner/exec" + "github.com/jahvon/flow/internal/templates" "github.com/jahvon/flow/internal/vault" ) @@ -38,7 +41,7 @@ func registerInitConfigCmd(ctx *context.Context, initCmd *cobra.Command) { Aliases: []string{"cfg"}, Short: "Initialize the flow global configuration.", Args: cobra.NoArgs, - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveCommand(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { printContext(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { initConfigFunc(ctx, cmd, args) }, } initCmd.AddCommand(cfgCmd) @@ -46,16 +49,24 @@ func registerInitConfigCmd(ctx *context.Context, initCmd *cobra.Command) { func initConfigFunc(ctx *context.Context, _ *cobra.Command, _ []string) { logger := ctx.Logger - inputs, err := components.ProcessInputs(io.Theme(), &components.TextInput{ - Key: "confirm", - Prompt: "This will overwrite your current flow configurations. Are you sure you want to continue? (y/n)", - }) + form, err := views.NewForm( + io.Theme(), + ctx.StdIn(), + ctx.StdOut(), + &views.FormField{ + Key: "confirm", + Type: views.PromptTypeConfirm, + Title: "This will overwrite your current flow configurations. Are you sure you want to continue?", + }) if err != nil { logger.FatalErr(err) } - resp := inputs.FindByKey("confirm").Value() - if strings.ToLower(resp) != "y" && strings.ToLower(resp) != "yes" { - logger.Warnf("Aborting", resp) + if err := form.Run(ctx.Ctx); err != nil { + logger.FatalErr(err) + } + resp := form.FindByKey("confirm").Value() + if truthy, _ := strconv.ParseBool(resp); !truthy { + logger.Warnf("Aborting") return } @@ -71,7 +82,7 @@ func registerInitWorkspaceCmd(ctx *context.Context, initCmd *cobra.Command) { Aliases: []string{"ws"}, Short: "Initialize and add a workspace to the list of known workspaces.", Args: cobra.ExactArgs(2), - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveCommand(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { printContext(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { initWorkspaceFunc(ctx, cmd, args) }, } RegisterFlag(ctx, wsCmd, *flags.SetAfterCreateFlag) @@ -139,105 +150,54 @@ func initWorkspaceFunc(ctx *context.Context, cmd *cobra.Command, args []string) func registerInitExecsCmd(ctx *context.Context, initCmd *cobra.Command) { subCmd := &cobra.Command{ - Use: "executables WORKSPACE_NAME DEFINITION_NAME [-p SUB_PATH] [-f FILE] [-t TEMPLATE]", - Aliases: []string{"execs", "definitions", "defs"}, + Use: "executables FLOWFILE_NAME [-w WORKSPACE ] [-o OUTPUT_DIR] [-f FILE | -t TEMPLATE]", + Aliases: []string{"execs", "flowfile", "template"}, Short: "Add rendered executables from an executable definition template to a workspace", Long: initExecLong, - Args: cobra.ExactArgs(2), - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveCommand(ctx, cmd) }, - Run: func(cmd *cobra.Command, args []string) { initExecFunc(ctx, cmd, args) }, - } - RegisterFlag(ctx, subCmd, *flags.SubPathFlag) + Args: cobra.MaximumNArgs(1), + PreRun: func(cmd *cobra.Command, args []string) { + runner.RegisterRunner(exec.NewRunner()) + printContext(ctx, cmd) + }, + Run: func(cmd *cobra.Command, args []string) { initExecFunc(ctx, cmd, args) }, + } + RegisterFlag(ctx, subCmd, *flags.TemplateOutputPathFlag) RegisterFlag(ctx, subCmd, *flags.TemplateFlag) - RegisterFlag(ctx, subCmd, *flags.FileFlag) - MarkFlagMutuallyExclusive(subCmd, flags.TemplateFlag.Name, flags.FileFlag.Name) - MarkOneFlagRequired(subCmd, flags.TemplateFlag.Name, flags.FileFlag.Name) - MarkFlagFilename(ctx, subCmd, flags.FileFlag.Name) - MarkFlagFilename(ctx, subCmd, flags.SubPathFlag.Name) + RegisterFlag(ctx, subCmd, *flags.TemplateFilePathFlag) + RegisterFlag(ctx, subCmd, *flags.TemplateWorkspaceFlag) + MarkFlagMutuallyExclusive(subCmd, flags.TemplateFlag.Name, flags.TemplateFilePathFlag.Name) + MarkOneFlagRequired(subCmd, flags.TemplateFlag.Name, flags.TemplateFilePathFlag.Name) + MarkFlagFilename(ctx, subCmd, flags.TemplateFilePathFlag.Name) + MarkFlagFilename(ctx, subCmd, flags.TemplateOutputPathFlag.Name) initCmd.AddCommand(subCmd) } -//nolint:gocognit func initExecFunc(ctx *context.Context, cmd *cobra.Command, args []string) { logger := ctx.Logger - workspaceName := args[0] - definitionName := args[1] - subPath := flags.ValueFor[string](ctx, cmd, *flags.SubPathFlag, false) + outputPath := flags.ValueFor[string](ctx, cmd, *flags.TemplateOutputPathFlag, false) template := flags.ValueFor[string](ctx, cmd, *flags.TemplateFlag, false) - fileVal := flags.ValueFor[string](ctx, cmd, *flags.FileFlag, false) - - logger.Infof("Adding '%s' executables to '%s' workspace", definitionName, workspaceName) - var flowFilePath string - switch { - case template == "" && fileVal == "": - logger.Fatalf("one of -f or -t must be provided") - case template != "" && fileVal != "": - logger.Fatalf("only one of -f or -t can be provided") - case template != "": - if ctx.Config.Templates == nil { - logger.Fatalf("template %s not found", template) - } - if path, found := ctx.Config.Templates[template]; !found { - logger.Fatalf("template %s not found", template) - } else { - flowFilePath = path - } - case fileVal != "": - if _, err := os.Stat(fileVal); os.IsNotExist(err) { - logger.Fatalf("fileVal %s not found", fileVal) - } - flowFilePath = fileVal - } - - execTemplate, err := filesystem.LoadFlowFileTemplate(flowFilePath) - if err != nil { - logger.FatalErr(err) - } - if err := execTemplate.Validate(); err != nil { - logger.FatalErr(err) - } - execTemplate.SetContext(flowFilePath) + templateFilePath := flags.ValueFor[string](ctx, cmd, *flags.TemplateFilePathFlag, false) + workspaceName := flags.ValueFor[string](ctx, cmd, *flags.TemplateWorkspaceFlag, false) - wsPath, wsFound := ctx.Config.Workspaces[workspaceName] - if !wsFound { + ws := workspaceOrCurrent(ctx, workspaceName) + if ws == nil { logger.Fatalf("workspace %s not found", workspaceName) } - ws, err := filesystem.LoadWorkspaceConfig(workspaceName, wsPath) - if err != nil { - logger.FatalErr(err) - } - ws.SetContext(workspaceName, wsPath) - if len(execTemplate.Data) != 0 { - var inputs []*components.TextInput - for _, entry := range execTemplate.Data { - inputs = append(inputs, &components.TextInput{ - Key: entry.Key, - Prompt: entry.Prompt, - Placeholder: entry.Default, - }) - } - inputs, err = components.ProcessInputs(io.Theme(), inputs...) - if err != nil { - logger.FatalErr(err) - } - for _, input := range inputs { - execTemplate.Data.Set(input.Key, input.Value()) - } - if err := execTemplate.Data.ValidateValues(); err != nil { - logger.FatalErr(err) - } + tmpl := loadFlowfileTemplate(ctx, template, templateFilePath) + if tmpl == nil { + logger.Fatalf("unable to load flowfile template") } - if err := filesystem.InitExecutables(execTemplate, ws, definitionName, subPath); err != nil { + flowFilename := tmpl.Name() + if len(args) == 1 { + flowFilename = args[0] + } + if err := templates.ProcessTemplate(ctx, tmpl, ws, flowFilename, outputPath); err != nil { logger.FatalErr(err) } - logger.PlainTextSuccess( - fmt.Sprintf( - "Executables from %s added to %s\nPath: %s", - definitionName, workspaceName, flowFilePath, - )) + logger.PlainTextSuccess(fmt.Sprintf("Template '%s' rendered successfully", flowFilename)) } func registerInitVaultCmd(ctx *context.Context, initCmd *cobra.Command) { @@ -245,7 +205,7 @@ func registerInitVaultCmd(ctx *context.Context, initCmd *cobra.Command) { Use: "vault", Short: "Create a new flow secret vault.", Args: cobra.NoArgs, - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveCommand(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { printContext(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { initVaultFunc(ctx, cmd, args) }, } initCmd.AddCommand(subCmd) @@ -274,12 +234,10 @@ func initVaultFunc(ctx *context.Context, cmd *cobra.Command, _ []string) { } } -//nolint:lll -var initExecLong = `Add rendered executables from an executable definition template to a workspace. +var initExecLong = `Add rendered executables from a flowfile template to a workspace. -The WORKSPACE_NAME is the name of the workspace to initialize the executables in. -The DEFINITION_NAME is the name of the definition to use in rendering the template. -This name will become the name of the file containing the copied executable definition. +The WORKSPACE_NAME is the name of the workspace to initialize the flowfile template in. +The FLOWFILE_NAME is the name to give the flowfile (if applicable) when rendering its template. -One one of -f or -t must be provided and must point to a valid executable definition template. -The -p flag can be used to specify a sub-path within the workspace to create the executable definition and its artifacts.` +One one of -f or -t must be provided and must point to a valid flowfile template. +The -o flag can be used to specify an output path within the workspace to create the flowfile and its artifacts in.` diff --git a/cmd/internal/interactive/interactive.go b/cmd/internal/interactive/interactive.go deleted file mode 100644 index 3211a5b..0000000 --- a/cmd/internal/interactive/interactive.go +++ /dev/null @@ -1,66 +0,0 @@ -package interactive - -import ( - "fmt" - "os" - "strconv" - "time" - - "github.com/jahvon/tuikit/components" - "github.com/spf13/cobra" - - "github.com/jahvon/flow/cmd/internal/flags" - "github.com/jahvon/flow/internal/context" - "github.com/jahvon/flow/internal/io" -) - -const ( - appName = "flow" - headerCtxKey = "ctx" -) - -func UIEnabled(ctx *context.Context, cmd *cobra.Command) bool { - disabled := flags.ValueFor[bool](ctx, cmd.Root(), *flags.NonInteractiveFlag, true) - envDisabled, _ := strconv.ParseBool(os.Getenv("DISABLE_FLOW_INTERACTIVE")) - return !disabled && !envDisabled && ctx.Config.ShowTUI() -} - -func InitInteractiveCommand(ctx *context.Context, cmd *cobra.Command) { - if UIEnabled(ctx, cmd) { - ctx.Logger.Println(io.Theme().RenderHeader(appName, headerCtxKey, headerCtxVal(ctx), 0)) - } -} - -func InitInteractiveContainer(ctx *context.Context, cmd *cobra.Command) { - enabled := UIEnabled(ctx, cmd) - if enabled && ctx.InteractiveContainer == nil { - container := components.InitalizeContainer( - ctx.Ctx, ctx.CancelFunc, appName, headerCtxKey, headerCtxVal(ctx), io.Theme(), - ) - ctx.InteractiveContainer = container - } -} - -func headerCtxVal(ctx *context.Context) string { - ws := ctx.CurrentWorkspace.AssignedName() - ns := ctx.Config.CurrentNamespace - if ws == "" { - ws = "unk" - } - if ns == "" { - ns = "*" - } - return fmt.Sprintf("%s/%s", ws, ns) -} - -func WaitForExit(ctx *context.Context, cmd *cobra.Command) { - if UIEnabled(ctx, cmd) && ctx.InteractiveContainer != nil { - timeout := time.After(60 * time.Minute) - select { - case <-ctx.Ctx.Done(): - return - case <-timeout: - panic("interactive wait timeout") - } - } -} diff --git a/cmd/internal/library.go b/cmd/internal/library.go index 4aa6885..16b0379 100644 --- a/cmd/internal/library.go +++ b/cmd/internal/library.go @@ -6,7 +6,6 @@ import ( "github.com/spf13/cobra" "github.com/jahvon/flow/cmd/internal/flags" - "github.com/jahvon/flow/cmd/internal/interactive" "github.com/jahvon/flow/internal/context" "github.com/jahvon/flow/internal/io" "github.com/jahvon/flow/internal/io/library" @@ -19,8 +18,8 @@ func RegisterLibraryCmd(ctx *context.Context, rootCmd *cobra.Command) { Short: "View and manage your library of workspaces and executables.", Aliases: []string{"lib"}, Args: cobra.NoArgs, - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveContainer(ctx, cmd) }, - PostRun: func(cmd *cobra.Command, args []string) { interactive.WaitForExit(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { StartTUI(ctx, cmd) }, + PostRun: func(cmd *cobra.Command, args []string) { WaitForTUI(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { libraryFunc(ctx, cmd, args) }, } RegisterFlag(ctx, libraryCmd, *flags.FilterWorkspaceFlag) @@ -33,7 +32,7 @@ func RegisterLibraryCmd(ctx *context.Context, rootCmd *cobra.Command) { func libraryFunc(ctx *context.Context, cmd *cobra.Command, _ []string) { logger := ctx.Logger - if !interactive.UIEnabled(ctx, cmd) { + if !TUIEnabled(ctx, cmd) { logger.FatalErr(errors.New("library command requires an interactive terminal")) } @@ -73,5 +72,5 @@ func libraryFunc(ctx *context.Context, cmd *cobra.Command, _ []string) { io.Theme(), runFunc, ) - ctx.InteractiveContainer.SetView(libraryModel) + SetView(ctx, cmd, libraryModel) } diff --git a/cmd/internal/list.go b/cmd/internal/list.go index f86ea55..fd15d98 100644 --- a/cmd/internal/list.go +++ b/cmd/internal/list.go @@ -3,15 +3,16 @@ package internal import ( "fmt" - "github.com/jahvon/tuikit/components" + "github.com/jahvon/tuikit/types" "github.com/spf13/cobra" "github.com/jahvon/flow/cmd/internal/flags" - "github.com/jahvon/flow/cmd/internal/interactive" "github.com/jahvon/flow/internal/context" + "github.com/jahvon/flow/internal/filesystem" executableio "github.com/jahvon/flow/internal/io/executable" secretio "github.com/jahvon/flow/internal/io/secret" workspaceio "github.com/jahvon/flow/internal/io/workspace" + "github.com/jahvon/flow/internal/templates" "github.com/jahvon/flow/internal/vault" "github.com/jahvon/flow/types/common" "github.com/jahvon/flow/types/executable" @@ -27,6 +28,7 @@ func RegisterListCmd(ctx *context.Context, rootCmd *cobra.Command) { registerListWorkspaceCmd(ctx, listCmd) registerListExecutableCmd(ctx, listCmd) registerListSecretCmd(ctx, listCmd) + registerListTemplateCmd(ctx, listCmd) rootCmd.AddCommand(listCmd) } @@ -36,8 +38,8 @@ func registerListWorkspaceCmd(ctx *context.Context, listCmd *cobra.Command) { Aliases: []string{"ws"}, Short: "Print a list of the registered flow workspaces.", Args: cobra.NoArgs, - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveContainer(ctx, cmd) }, - PostRun: func(cmd *cobra.Command, args []string) { interactive.WaitForExit(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { StartTUI(ctx, cmd) }, + PostRun: func(cmd *cobra.Command, args []string) { WaitForTUI(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { listWorkspaceFunc(ctx, cmd, args) }, } RegisterFlag(ctx, workspaceCmd, *flags.OutputFormatFlag) @@ -70,13 +72,13 @@ func listWorkspaceFunc(ctx *context.Context, cmd *cobra.Command, _ []string) { logger.Fatalf("no workspaces found") } - if interactive.UIEnabled(ctx, cmd) { + if TUIEnabled(ctx, cmd) { view := workspaceio.NewWorkspaceListView( ctx, filteredWorkspaces, - components.Format(outputFormat), + types.Format(outputFormat), ) - ctx.InteractiveContainer.SetView(view) + SetView(ctx, cmd, view) } else { workspaceio.PrintWorkspaceList(logger, outputFormat, filteredWorkspaces) } @@ -88,8 +90,8 @@ func registerListExecutableCmd(ctx *context.Context, listCmd *cobra.Command) { Aliases: []string{"execs"}, Short: "Print a list of executable flows.", Args: cobra.NoArgs, - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveContainer(ctx, cmd) }, - PostRun: func(cmd *cobra.Command, args []string) { interactive.WaitForExit(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { StartTUI(ctx, cmd) }, + PostRun: func(cmd *cobra.Command, args []string) { WaitForTUI(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { listExecutableFunc(ctx, cmd, args) }, } RegisterFlag(ctx, executableCmd, *flags.OutputFormatFlag) @@ -130,15 +132,15 @@ func listExecutableFunc(ctx *context.Context, cmd *cobra.Command, _ []string) { FilterByTags(tagsFilter). FilterBySubstring(substr) - if interactive.UIEnabled(ctx, cmd) { + if TUIEnabled(ctx, cmd) { runFunc := func(ref string) error { return runByRef(ctx, cmd, ref) } view := executableio.NewExecutableListView( ctx, filteredExec, - components.Format(outputFormat), + types.Format(outputFormat), runFunc, ) - ctx.InteractiveContainer.SetView(view) + SetView(ctx, cmd, view) } else { executableio.PrintExecutableList(logger, outputFormat, filteredExec) } @@ -150,8 +152,8 @@ func registerListSecretCmd(ctx *context.Context, listCmd *cobra.Command) { Aliases: []string{"scrt"}, Short: "Print a list of secrets in the flow vault.", Args: cobra.NoArgs, - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveContainer(ctx, cmd) }, - PostRun: func(cmd *cobra.Command, args []string) { interactive.WaitForExit(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { StartTUI(ctx, cmd) }, + PostRun: func(cmd *cobra.Command, args []string) { WaitForTUI(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { listSecretFunc(ctx, cmd, args) }, } RegisterFlag(ctx, vaultSecretListCmd, *flags.OutputSecretAsPlainTextFlag) @@ -168,7 +170,7 @@ func listSecretFunc(ctx *context.Context, cmd *cobra.Command, _ []string) { logger.FatalErr(err) } - interactiveUI := interactive.UIEnabled(ctx, cmd) + interactiveUI := TUIEnabled(ctx, cmd) if interactiveUI { secretio.LoadSecretListView(ctx, asPlainText) } else { @@ -181,3 +183,49 @@ func listSecretFunc(ctx *context.Context, cmd *cobra.Command, _ []string) { } } } + +func registerListTemplateCmd(ctx *context.Context, listCmd *cobra.Command) { + templateCmd := &cobra.Command{ + Use: "templates", + Aliases: []string{"tmpl"}, + Short: "Print a list of registered flowfile templates.", + Args: cobra.NoArgs, + PreRun: func(cmd *cobra.Command, args []string) { StartTUI(ctx, cmd) }, + PostRun: func(cmd *cobra.Command, args []string) { WaitForTUI(ctx, cmd) }, + Run: func(cmd *cobra.Command, args []string) { listTemplateFunc(ctx, cmd, args) }, + } + RegisterFlag(ctx, templateCmd, *flags.OutputFormatFlag) + listCmd.AddCommand(templateCmd) +} + +func listTemplateFunc(ctx *context.Context, cmd *cobra.Command, _ []string) { + logger := ctx.Logger + // TODO: include unregistered templates within the current ws + tmpls, err := filesystem.LoadFlowFileTemplates(ctx.Config.Templates) + if err != nil { + logger.FatalErr(err) + } + + outputFormat := flags.ValueFor[string](ctx, cmd, *flags.OutputFormatFlag, false) + if TUIEnabled(ctx, cmd) { + view := executableio.NewTemplateListView( + ctx, tmpls, types.Format(outputFormat), + func(name string) error { + tmpl := tmpls.Find(name) + if tmpl == nil { + return fmt.Errorf("template %s not found", name) + } + ws := ctx.CurrentWorkspace + // TODO: support specifying a path/name + if err := templates.ProcessTemplate(ctx, tmpl, ws, tmpl.Name(), "//"); err != nil { + return err + } + logger.PlainTextSuccess("Template rendered successfully") + return nil + }, + ) + SetView(ctx, cmd, view) + } else { + executableio.PrintTemplateList(logger, outputFormat, tmpls) + } +} diff --git a/cmd/internal/logs.go b/cmd/internal/logs.go index f07a152..7c37634 100644 --- a/cmd/internal/logs.go +++ b/cmd/internal/logs.go @@ -4,15 +4,13 @@ import ( "fmt" "time" - "github.com/jahvon/tuikit/components" tuikitIO "github.com/jahvon/tuikit/io" + "github.com/jahvon/tuikit/views" "github.com/spf13/cobra" "github.com/jahvon/flow/cmd/internal/flags" - "github.com/jahvon/flow/cmd/internal/interactive" "github.com/jahvon/flow/internal/context" "github.com/jahvon/flow/internal/filesystem" - "github.com/jahvon/flow/internal/io" ) func RegisterLogsCmd(ctx *context.Context, rootCmd *cobra.Command) { @@ -21,12 +19,8 @@ func RegisterLogsCmd(ctx *context.Context, rootCmd *cobra.Command) { Aliases: []string{"log"}, Short: "List and view logs for previous flow executions.", Args: cobra.NoArgs, - PreRun: func(cmd *cobra.Command, args []string) { - interactive.InitInteractiveContainer(ctx, cmd) - }, - PostRun: func(cmd *cobra.Command, args []string) { - interactive.WaitForExit(ctx, cmd) - }, + PreRun: func(cmd *cobra.Command, args []string) { StartTUI(ctx, cmd) }, + PostRun: func(cmd *cobra.Command, args []string) { WaitForTUI(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { logFunc(ctx, cmd, args) }, @@ -40,14 +34,9 @@ func logFunc(ctx *context.Context, cmd *cobra.Command, _ []string) { if err := filesystem.EnsureLogsDir(); err != nil { ctx.Logger.FatalErr(err) } - if interactive.UIEnabled(ctx, cmd) { - state := &components.TerminalState{ - Theme: io.Theme(), - Height: ctx.InteractiveContainer.Height(), - Width: ctx.InteractiveContainer.Width(), - } - view := components.NewLogArchiveView(state, filesystem.LogsDir(), lastEntry) - ctx.InteractiveContainer.SetView(view) + if TUIEnabled(ctx, cmd) { + view := views.NewLogArchiveView(ctx.TUIContainer.RenderState(), filesystem.LogsDir(), lastEntry) + SetView(ctx, cmd, view) return } entries, err := tuikitIO.ListArchiveEntries(filesystem.LogsDir()) diff --git a/cmd/internal/remove.go b/cmd/internal/remove.go index b3d3b6e..d9f7b87 100644 --- a/cmd/internal/remove.go +++ b/cmd/internal/remove.go @@ -4,12 +4,11 @@ import ( "fmt" "strconv" - "github.com/jahvon/tuikit/components" + "github.com/jahvon/tuikit/views" "github.com/pkg/errors" "github.com/spf13/cobra" "golang.org/x/exp/maps" - "github.com/jahvon/flow/cmd/internal/interactive" "github.com/jahvon/flow/internal/cache" "github.com/jahvon/flow/internal/context" "github.com/jahvon/flow/internal/filesystem" @@ -40,7 +39,7 @@ func registerRemoveWsCmd(ctx *context.Context, removeCmd *cobra.Command) { return maps.Keys(ctx.Config.Workspaces), cobra.ShellCompDirectiveNoFileComp }, PreRun: func(cmd *cobra.Command, args []string) { - interactive.InitInteractiveCommand(ctx, cmd) + printContext(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { removeWsFunc(ctx, cmd, args) @@ -53,16 +52,23 @@ func removeWsFunc(ctx *context.Context, _ *cobra.Command, args []string) { logger := ctx.Logger name := args[0] - inputs, err := components.ProcessInputs(io.Theme(), &components.TextInput{ - Key: "confirm", - Prompt: fmt.Sprintf("Are you sure you want to remove the workspace '%s'? (y/n)", name), - }) + form, err := views.NewForm( + io.Theme(), + ctx.StdIn(), + ctx.StdOut(), + &views.FormField{ + Key: "confirm", + Type: views.PromptTypeConfirm, + Title: fmt.Sprintf("Are you sure you want to remove the workspace '%s'?", name), + }) if err != nil { logger.FatalErr(err) } - resp := inputs.FindByKey("confirm").Value() - confirmed, _ := strconv.ParseBool(resp) - if !confirmed { + if err := form.Run(ctx.Ctx); err != nil { + logger.FatalErr(err) + } + resp := form.FindByKey("confirm").Value() + if truthy, _ := strconv.ParseBool(resp); !truthy { logger.Warnf("Aborting") return } @@ -94,7 +100,7 @@ func registerRemoveSecretCmd(ctx *context.Context, removeCmd *cobra.Command) { Short: "Remove a secret from the vault.", Args: cobra.ExactArgs(1), PreRun: func(cmd *cobra.Command, args []string) { - interactive.InitInteractiveCommand(ctx, cmd) + printContext(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { removeSecretFunc(ctx, cmd, args) @@ -107,16 +113,23 @@ func removeSecretFunc(ctx *context.Context, _ *cobra.Command, args []string) { logger := ctx.Logger reference := args[0] - inputs, err := components.ProcessInputs(io.Theme(), &components.TextInput{ - Key: "confirm", - Prompt: fmt.Sprintf("Are you sure you want to remove the secret '%s'? (y/n)", reference), - }) + form, err := views.NewForm( + io.Theme(), + ctx.StdIn(), + ctx.StdOut(), + &views.FormField{ + Key: "confirm", + Type: views.PromptTypeConfirm, + Title: fmt.Sprintf("Are you sure you want to remove the secret '%s'?", reference), + }) if err != nil { logger.FatalErr(err) } - resp := inputs.FindByKey("confirm").Value() - confirmed, _ := strconv.ParseBool(resp) - if !confirmed { + if err := form.Run(ctx.Ctx); err != nil { + logger.FatalErr(err) + } + resp := form.FindByKey("confirm").Value() + if truthy, _ := strconv.ParseBool(resp); !truthy { logger.Warnf("Aborting") return } diff --git a/cmd/internal/set.go b/cmd/internal/set.go index b23581a..1f2de1b 100644 --- a/cmd/internal/set.go +++ b/cmd/internal/set.go @@ -5,13 +5,12 @@ import ( "strconv" "strings" - "github.com/jahvon/tuikit/components" tuiKitIO "github.com/jahvon/tuikit/io" + "github.com/jahvon/tuikit/views" "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/jahvon/flow/cmd/internal/flags" - "github.com/jahvon/flow/cmd/internal/interactive" "github.com/jahvon/flow/internal/context" "github.com/jahvon/flow/internal/filesystem" "github.com/jahvon/flow/internal/io" @@ -41,7 +40,7 @@ func registerSetWorkspaceCmd(ctx *context.Context, setCmd *cobra.Command) { Aliases: []string{"ws"}, Short: "Change the current workspace.", Args: cobra.ExactArgs(1), - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveCommand(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { printContext(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { setWorkspaceFunc(ctx, cmd, args) }, } RegisterFlag(ctx, workspaceCmd, *flags.FixedWsModeFlag) @@ -73,7 +72,7 @@ func registerSetNamespaceCmd(ctx *context.Context, setCmd *cobra.Command) { Aliases: []string{"ns"}, Short: "Change the current namespace.", Args: cobra.ExactArgs(1), - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveCommand(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { printContext(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { setNamespaceFunc(ctx, cmd, args) }, } setCmd.AddCommand(namespaceCmd) @@ -96,7 +95,7 @@ func registerSetWorkspaceModeCmd(ctx *context.Context, setCmd *cobra.Command) { Short: "Switch between fixed and dynamic workspace modes.", Args: cobra.ExactArgs(1), ValidArgs: []string{"fixed", "dynamic"}, - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveCommand(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { printContext(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { setWorkspaceModeFunc(ctx, cmd, args) }, } setCmd.AddCommand(workspaceModeCmd) @@ -123,7 +122,7 @@ func registerSetLogModeCmd(ctx *context.Context, setCmd *cobra.Command) { Short: "Set the default log mode.", Args: cobra.ExactArgs(1), ValidArgs: []string{"logfmt", "json", "text", "hidden"}, - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveCommand(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { printContext(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { setLogModeFunc(ctx, cmd, args) }, } setCmd.AddCommand(logModeCmd) @@ -147,7 +146,7 @@ func registerSetInteractiveCmd(ctx *context.Context, setCmd *cobra.Command) { Short: "Enable or disable the interactive terminal UI experience.", Args: cobra.ExactArgs(1), ValidArgs: []string{"true", "false"}, - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveCommand(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { printContext(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { setInteractiveFunc(ctx, cmd, args) }, } setCmd.AddCommand(interactiveCmd) @@ -180,7 +179,7 @@ func registerSetTemplateCmd(ctx *context.Context, setCmd *cobra.Command) { Use: "template NAME DEFINITION_TEMPLATE_PATH", Short: "Set a template definition for use in flow.", Args: cobra.ExactArgs(2), - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveCommand(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { printContext(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { setTemplateFunc(ctx, cmd, args) }, } setCmd.AddCommand(templateCmd) @@ -190,7 +189,7 @@ func setTemplateFunc(ctx *context.Context, _ *cobra.Command, args []string) { logger := ctx.Logger name := args[0] flowFilePath := args[1] - loadedTemplates, err := filesystem.LoadFlowFileTemplate(flowFilePath) + loadedTemplates, err := filesystem.LoadFlowFileTemplate(name, flowFilePath) if err != nil { logger.FatalErr(err) } @@ -214,7 +213,7 @@ func registerSetSecretCmd(ctx *context.Context, setCmd *cobra.Command) { Aliases: []string{"scrt"}, Short: "Update or create a secret in the flow secret vault.", Args: cobra.MinimumNArgs(1), - PreRun: func(cmd *cobra.Command, args []string) { interactive.InitInteractiveCommand(ctx, cmd) }, + PreRun: func(cmd *cobra.Command, args []string) { printContext(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { setSecretFunc(ctx, cmd, args) }, } setCmd.AddCommand(secretCmd) @@ -227,12 +226,22 @@ func setSecretFunc(ctx *context.Context, _ *cobra.Command, args []string) { var value string switch { case len(args) == 1: - in := components.TextInput{Key: "value", Prompt: "Enter the secret value"} - inputs, err := components.ProcessInputs(io.Theme(), &in) + form, err := views.NewForm( + io.Theme(), + ctx.StdIn(), + ctx.StdOut(), + &views.FormField{ + Key: "value", + Type: views.PromptTypeMasked, + Title: "Enter the secret value", + }) if err != nil { logger.FatalErr(err) } - value = inputs.FindByKey("value").Value() + if err := form.Run(ctx.Ctx); err != nil { + logger.FatalErr(err) + } + value = form.FindByKey("value").Value() case len(args) == 2: value = args[1] default: diff --git a/cmd/internal/sync.go b/cmd/internal/sync.go index 9f125c7..39641cd 100644 --- a/cmd/internal/sync.go +++ b/cmd/internal/sync.go @@ -3,7 +3,6 @@ package internal import ( "github.com/spf13/cobra" - "github.com/jahvon/flow/cmd/internal/interactive" "github.com/jahvon/flow/internal/cache" "github.com/jahvon/flow/internal/context" ) @@ -14,7 +13,7 @@ func RegisterSyncCmd(ctx *context.Context, rootCmd *cobra.Command) { Short: "Scan workspaces and update flow cache.", Args: cobra.NoArgs, PreRun: func(cmd *cobra.Command, args []string) { - interactive.InitInteractiveCommand(ctx, cmd) + printContext(ctx, cmd) }, Run: func(cmd *cobra.Command, args []string) { syncFunc(ctx, cmd, args) diff --git a/docs/cli/flow_get.md b/docs/cli/flow_get.md index df23a3b..e1ac37d 100644 --- a/docs/cli/flow_get.md +++ b/docs/cli/flow_get.md @@ -22,5 +22,6 @@ Print a flow entity. * [flow get config](flow_get_config.md) - Print the current global configuration values. * [flow get executable](flow_get_executable.md) - Print an executable flow by reference. * [flow get secret](flow_get_secret.md) - Print the value of a secret in the flow secret vault. -* [flow get workspace](flow_get_workspace.md) - Print a workspace's configuration. If the name is omitted, the current workspace is used. +* [flow get template](flow_get_template.md) - Print a flowfile template using it's registered name or file path. +* [flow get workspace](flow_get_workspace.md) - Print a workspaces configuration. If the name is omitted, the current workspace is used. diff --git a/docs/cli/flow_get_template.md b/docs/cli/flow_get_template.md new file mode 100644 index 0000000..32ea6ef --- /dev/null +++ b/docs/cli/flow_get_template.md @@ -0,0 +1,29 @@ +## flow get template + +Print a flowfile template using it's registered name or file path. + +``` +flow get template [flags] +``` + +### Options + +``` + -f, --file string Path to the template file. It must be a valid flow file template. + -h, --help help for template + -o, --output string Output format. One of: yaml, json, doc, or list. + -t, --template flow set template Registered template name. Templates can be registered in the flow configuration file or with flow set template. +``` + +### Options inherited from parent commands + +``` + -x, --non-interactive Disable displaying flow output via terminal UI rendering. This is only needed if the interactive output is enabled by default in flow's configuration. + --sync Sync flow cache and workspaces + --verbosity int Log verbosity level (-1 to 1) +``` + +### SEE ALSO + +* [flow get](flow_get.md) - Print a flow entity. + diff --git a/docs/cli/flow_get_workspace.md b/docs/cli/flow_get_workspace.md index 768d5d1..6953cda 100644 --- a/docs/cli/flow_get_workspace.md +++ b/docs/cli/flow_get_workspace.md @@ -1,6 +1,6 @@ ## flow get workspace -Print a workspace's configuration. If the name is omitted, the current workspace is used. +Print a workspaces configuration. If the name is omitted, the current workspace is used. ``` flow get workspace [NAME] [flags] diff --git a/docs/cli/flow_init_executables.md b/docs/cli/flow_init_executables.md index f2103b3..5acd781 100644 --- a/docs/cli/flow_init_executables.md +++ b/docs/cli/flow_init_executables.md @@ -4,26 +4,26 @@ Add rendered executables from an executable definition template to a workspace ### Synopsis -Add rendered executables from an executable definition template to a workspace. +Add rendered executables from a flowfile template to a workspace. -The WORKSPACE_NAME is the name of the workspace to initialize the executables in. -The DEFINITION_NAME is the name of the definition to use in rendering the template. -This name will become the name of the file containing the copied executable definition. +The WORKSPACE_NAME is the name of the workspace to initialize the flowfile template in. +The FLOWFILE_NAME is the name to give the flowfile (if applicable) when rendering its template. -One one of -f or -t must be provided and must point to a valid executable definition template. -The -p flag can be used to specify a sub-path within the workspace to create the executable definition and its artifacts. +One one of -f or -t must be provided and must point to a valid flowfile template. +The -o flag can be used to specify an output path within the workspace to create the flowfile and its artifacts in. ``` -flow init executables WORKSPACE_NAME DEFINITION_NAME [-p SUB_PATH] [-f FILE] [-t TEMPLATE] [flags] +flow init executables FLOWFILE_NAME [-w WORKSPACE ] [-o OUTPUT_DIR] [-f FILE | -t TEMPLATE] [flags] ``` ### Options ``` - -f, --file string File to use as the template for the executables. It must be a valid executable definition template. - -h, --help help for executables - -p, --subPath string Sub-path within the workspace to create the executable definition and its artifacts. - -t, --template string Template to use as the template for the executables. Templates are registered in the flow configuration file. + -f, --file string Path to the template file. It must be a valid flow file template. + -h, --help help for executables + -o, --output string Output directory (within the workspace) to create the flow file and its artifacts. If the directory does not exist, it will be created. + -t, --template flow set template Registered template name. Templates can be registered in the flow configuration file or with flow set template. + -w, --workspace string Workspace to create the flow file and its artifacts. Defaults to the current workspace. ``` ### Options inherited from parent commands diff --git a/docs/cli/flow_list.md b/docs/cli/flow_list.md index 594877a..c0f2cac 100644 --- a/docs/cli/flow_list.md +++ b/docs/cli/flow_list.md @@ -21,5 +21,6 @@ Print a list of flow entities. * [flow](flow.md) - flow is a command line interface designed to make managing and running development workflows easier. * [flow list executables](flow_list_executables.md) - Print a list of executable flows. * [flow list secrets](flow_list_secrets.md) - Print a list of secrets in the flow vault. +* [flow list templates](flow_list_templates.md) - Print a list of registered flowfile templates. * [flow list workspaces](flow_list_workspaces.md) - Print a list of the registered flow workspaces. diff --git a/docs/cli/flow_list_templates.md b/docs/cli/flow_list_templates.md new file mode 100644 index 0000000..32a02a3 --- /dev/null +++ b/docs/cli/flow_list_templates.md @@ -0,0 +1,27 @@ +## flow list templates + +Print a list of registered flowfile templates. + +``` +flow list templates [flags] +``` + +### Options + +``` + -h, --help help for templates + -o, --output string Output format. One of: yaml, json, doc, or list. +``` + +### Options inherited from parent commands + +``` + -x, --non-interactive Disable displaying flow output via terminal UI rendering. This is only needed if the interactive output is enabled by default in flow's configuration. + --sync Sync flow cache and workspaces + --verbosity int Log verbosity level (-1 to 1) +``` + +### SEE ALSO + +* [flow list](flow_list.md) - Print a list of flow entities. + diff --git a/docs/guides/exec.md b/docs/guides/exec.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/guides/inputs.md b/docs/guides/inputs.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/guides/secrets.md b/docs/guides/secrets.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/guides/templating.md b/docs/guides/templating.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/types/template.md b/docs/types/template.md new file mode 100644 index 0000000..7c5adaa --- /dev/null +++ b/docs/types/template.md @@ -0,0 +1,112 @@ +[comment]: # (Documentation autogenerated by docsgen. Do not edit directly.) + +# Template + +Configuration for a flowfile template; templates can be used to generate flow files. + +## Properties + +**Required:** +- `template` + +**Properties:** + +| Field | Description | Type | Default | Required | +| ----- | ----------- | ---- | ------- | -------- | +| `artifacts` | A list of artifacts to be copied after generating the flow file. | `array` ([Artifact](#Artifact)) | | [] | +| `form` | Form fields to be displayed to the user when generating a flow file from a template. The form will be rendered first, and the user's input can be used to render the template. For example, a form field with the key `name` can be used in the template as `{{.name}}`. | `array` ([Field](#Field)) | [] | [] | +| `postRun` | A list of exec executables to run after generating the flow file. | `array` ([ExecutableExecExecutableType](#ExecutableExecExecutableType)) | | [] | +| `preRun` | A list of exec executables to run before generating the flow file. | `array` ([ExecutableExecExecutableType](#ExecutableExecExecutableType)) | | [] | +| `template` | The flow file template to generate. The template must be a valid flow file after rendering. | `string` | | [] | + + +## Definitions + +### Artifact + +File source and destination configuration. +Go templating from form data is supported in all fields. + + +**Type:** `object` + + + +**Properties:** + +| Field | Description | Type | Default | Required | +| ----- | ----------- | ---- | ------- | -------- | +| `asTemplate` | If true, the artifact will be copied as a template file. The file will be rendered using Go templating from the form data. [Sprig functions](https://masterminds.github.io/sprig/) are available for use in the template. | `boolean` | false | [] | +| `dstDir` | The directory to copy the file to. If not set, the file will be copied to the root of the flow file directory. The directory will be created if it does not exist. | `string` | | [] | +| `dstName` | The name of the file to copy to. If not set, the file will be copied with the same name. | `string` | | [] | +| `if` | A condition to determine if the artifact should be copied. The condition is evaluated using Go templating from the form data. If the condition is not met, the artifact will not be copied. [Sprig functions](https://masterminds.github.io/sprig/) are available for use in the condition. For example, to copy the artifact only if the `name` field is set: ``` {{ if .name }}true{{ end }} ``` | `string` | | [] | +| `srcDir` | The directory to copy the file from. If not set, the file will be copied from the directory of the template file. | `string` | | [] | +| `srcName` | The name of the file to copy. | `string` | | [] | + +### ExecutableArgumentList + + + + + + + + +### ExecutableDirectory + + + + + + + + +### ExecutableExecExecutableType + +Standard executable type. Runs a command/file in a subprocess. + +**Type:** `object` + + + +**Properties:** + +| Field | Description | Type | Default | Required | +| ----- | ----------- | ---- | ------- | -------- | +| `args` | | [ExecutableArgumentList](#ExecutableArgumentList) | | [] | +| `cmd` | The command to execute. Only one of `cmd` or `file` must be set. | `string` | | [] | +| `dir` | | [ExecutableDirectory](#ExecutableDirectory) | | [] | +| `file` | The file to execute. Only one of `cmd` or `file` must be set. | `string` | | [] | +| `logMode` | The log mode to use when running the executable. This can either be `hidden`, `json`, `logfmt` or `text` | `string` | logfmt | [] | +| `params` | | [ExecutableParameterList](#ExecutableParameterList) | | [] | + +### ExecutableParameterList + + + + + + + + +### Field + +A field to be displayed to the user when generating a flow file from a template. + +**Type:** `object` + + + +**Properties:** + +| Field | Description | Type | Default | Required | +| ----- | ----------- | ---- | ------- | -------- | +| `default` | The default value to use if a value is not set. | `string` | | [] | +| `description` | A description of the field. | `string` | | [] | +| `group` | The group to display the field in. Fields with the same group will be displayed together. | `integer` | 0 | [] | +| `key` | The key to associate the data with. This is used as the key in the template data map. | `string` | | [] | +| `prompt` | A prompt to be displayed to the user when collecting an input value. | `string` | | [] | +| `required` | If true, a value must be set. If false, the default value will be used if a value is not set. | `boolean` | false | [] | +| `validate` | A regular expression to validate the input value against. | `string` | | [] | + + diff --git a/examples/exec-template.flow.tmpl b/examples/exec-template.flow.tmpl index 871a3d1..a595d2d 100644 --- a/examples/exec-template.flow.tmpl +++ b/examples/exec-template.flow.tmpl @@ -1,15 +1,16 @@ -data: +form: - key: Color prompt: What is your favorite color? default: blue artifacts: - - message.txt - -visibility: private -namespace: examples -executables: - - verb: run - name: "{{ .Color }}-msg" - description: Created from a template in {{ .Workspace }} - exec: - cmd: cat message.txt + - srcName: message.txt + dstName: message.txt +template: | + visibility: private + namespace: examples + executables: + - verb: run + name: "{{ .Color }}-msg" + description: Created from a template in {{ .FlowWorkspace }} + exec: + cmd: cat message.txt diff --git a/flow.yaml b/flow.yaml index e5deb77..150ce37 100644 --- a/flow.yaml +++ b/flow.yaml @@ -1,3 +1,5 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/jahvon/flow/HEAD/schemas/workspace_schema.json displayName: flow repository -descriptionFile: README.md +description: | + Source code for flow + GitHub Repo: github.com/jahvon/flow diff --git a/go.mod b/go.mod index 3da6a43..a2d7abc 100644 --- a/go.mod +++ b/go.mod @@ -3,18 +3,20 @@ module github.com/jahvon/flow go 1.23 require ( + github.com/Masterminds/sprig/v3 v3.2.3 github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v0.20.0 - github.com/charmbracelet/bubbletea v1.1.0 + github.com/charmbracelet/bubbletea v1.1.1 github.com/charmbracelet/lipgloss v0.13.0 github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 github.com/itchyny/gojq v0.12.16 github.com/jahvon/glamour v0.8.1-patch1 github.com/jahvon/open-golang v0.0.0-20240522004812-68511c3bc9ef - github.com/jahvon/tuikit v0.0.21 + github.com/jahvon/tuikit v0.0.23 github.com/mattn/go-runewidth v0.0.16 github.com/onsi/ginkgo/v2 v2.20.2 github.com/onsi/gomega v1.34.2 + github.com/otiai10/copy v1.14.0 github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 @@ -27,14 +29,20 @@ require ( ) require ( + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.2.0 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/catppuccin/go v0.2.0 // indirect + github.com/charmbracelet/huh v0.6.0 // indirect github.com/charmbracelet/log v0.4.0 // indirect - github.com/charmbracelet/x/ansi v0.2.3 // indirect + github.com/charmbracelet/x/ansi v0.3.2 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.2 // indirect @@ -43,13 +51,19 @@ require ( github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 // indirect + github.com/google/uuid v1.1.1 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/huandu/xstrings v1.3.3 // indirect + github.com/imdario/mergo v0.3.11 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/mitchellh/copystructure v1.0.0 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/mitchellh/reflectwalk v1.0.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect @@ -58,6 +72,8 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/shopspring/decimal v1.2.0 // indirect + github.com/spf13/cast v1.3.1 // indirect github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/yuin/goldmark v1.7.4 // indirect github.com/yuin/goldmark-emoji v1.0.3 // indirect diff --git a/go.sum b/go.sum index c4f85d0..5d49957 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,11 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= +github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= +github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= @@ -12,28 +20,39 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= -github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= -github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= +github.com/charmbracelet/bubbletea v1.1.1 h1:KJ2/DnmpfqFtDNVTvYZ6zpPFL9iRCRr0qqKOCvppbPY= +github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= +github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= +github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= 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.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY= -github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/ansi v0.3.2 h1:wsEwgAN+C9U06l9dCVMX0/L3x7ptvY1qmjMwyfE6USY= +github.com/charmbracelet/x/ansi v0.3.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/exp/teatest v0.0.0-20240919170804-a4978c8e603a h1:sS42HbmCab8rCehUwNO/bQEZQoJ6GavhZyO+245mBwA= +github.com/charmbracelet/x/exp/teatest v0.0.0-20240919170804-a4978c8e603a/go.mod h1:NDRRSMP6bZbCs4jyc4i1/4UG4M+0PEiQdpivQgD0Mio= github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 h1:ygs9POGDQpQGLJPlq4+0LBUmMBNox1N4JSpw+OETcvI= @@ -54,10 +73,16 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA= github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= +github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/itchyny/gojq v0.12.16 h1:yLfgLxhIr/6sJNVmYfQjTIv0jGctu6/DgDoivmxTr7g= @@ -68,8 +93,8 @@ github.com/jahvon/glamour v0.8.1-patch1 h1:ahDSYbmhdKtrl0q/QxyshaFXnhUUBmuDCyJk1 github.com/jahvon/glamour v0.8.1-patch1/go.mod h1:iGeJCQECnGJXk6X9D9UDUrtJ4hEUhYTIACdyNA2CAUk= github.com/jahvon/open-golang v0.0.0-20240522004812-68511c3bc9ef h1:4PS/MNVT6Rsv15x5Rtwaw971e6kFvNUAf9nvUsZ5hcc= github.com/jahvon/open-golang v0.0.0-20240522004812-68511c3bc9ef/go.mod h1:dUmuT5CN6osIeLSRtTPJOf0Yz+qAbcyU6omnCzI+ZfQ= -github.com/jahvon/tuikit v0.0.21 h1:updW22PXmFo1mdHA6EUEVmG/DeIwPrkUu0qQmThoLWk= -github.com/jahvon/tuikit v0.0.21/go.mod h1:1d22nVrCwzxeKomFJAZMUa4mqBbtOfd3bmLWjsY6PrM= +github.com/jahvon/tuikit v0.0.23 h1:mvgFU0yFHYPqdTr+kFYmA6qUD3drh40KGhiNoC6ZXMk= +github.com/jahvon/tuikit v0.0.23/go.mod h1:trQ4HzTqTwYkYinDSYIa/8XML9lxIuzYQpALJ0Y+p3w= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -87,6 +112,12 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -101,6 +132,10 @@ github.com/onsi/ginkgo/v2 v2.20.2 h1:7NVCeyIWROIAheY21RLS+3j2bb52W0W82tkberYytp4 github.com/onsi/ginkgo/v2 v2.20.2/go.mod h1:K9gyxPIlb+aIvnZ8bd9Ak+YP18w3APlR+5coaZoE2ag= github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8= github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc= +github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= +github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= +github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks= +github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -115,14 +150,22 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 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/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= @@ -130,28 +173,58 @@ github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhb github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e h1:I88y4caeGeuDQxgdoFPUq097j7kNfw6uvuiNxUBfcBk= golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 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.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= mvdan.cc/sh/v3 v3.9.0 h1:it14fyjCdQUk4jf/aYxLO3FG8jFarR9GzMCtnlvvD7c= diff --git a/internal/cache/executables_cache_test.go b/internal/cache/executables_cache_test.go index 264b1e5..3193b10 100644 --- a/internal/cache/executables_cache_test.go +++ b/internal/cache/executables_cache_test.go @@ -52,7 +52,7 @@ var _ = Describe("ExecutableCacheImpl", func() { {Verb: "run", Name: "exec"}, }, } - execCfg.SetContext(wsName, wsPath, filepath.Join(wsPath, "test"+filesystem.FlowFileExt)) + execCfg.SetContext(wsName, wsPath, filepath.Join(wsPath, "test"+executable.FlowFileExt)) err = filesystem.WriteFlowFile(execCfg.ConfigPath(), execCfg) Expect(err).NotTo(HaveOccurred()) execCacheData := &cache.ExecutableCacheData{ @@ -106,7 +106,7 @@ var _ = Describe("ExecutableCacheImpl", func() { Visibility: &v, FromFile: []string{"from-file.sh"}, } - execCfg.SetContext(wsName, wsPath, filepath.Join(wsPath, "test"+filesystem.FlowFileExt)) + execCfg.SetContext(wsName, wsPath, filepath.Join(wsPath, "test"+executable.FlowFileExt)) err = filesystem.WriteFlowFile(execCfg.ConfigPath(), execCfg) Expect(err).NotTo(HaveOccurred()) diff --git a/internal/context/context.go b/internal/context/context.go index 1c2a1dc..1a01af7 100644 --- a/internal/context/context.go +++ b/internal/context/context.go @@ -7,27 +7,32 @@ import ( "path/filepath" "strings" - "github.com/jahvon/tuikit/components" + "github.com/jahvon/tuikit" "github.com/jahvon/tuikit/io" - "github.com/jahvon/tuikit/styles" "github.com/pkg/errors" "github.com/jahvon/flow/internal/cache" "github.com/jahvon/flow/internal/filesystem" + flowIO "github.com/jahvon/flow/internal/io" "github.com/jahvon/flow/types/config" "github.com/jahvon/flow/types/executable" "github.com/jahvon/flow/types/workspace" ) +const ( + AppName = "flow" + HeaderCtxKey = "ctx" +) + type Context struct { - Ctx context.Context - CancelFunc context.CancelFunc - Logger io.Logger - Config *config.Config - CurrentWorkspace *workspace.Workspace - InteractiveContainer *components.ContainerView - WorkspacesCache cache.WorkspaceCache - ExecutableCache cache.ExecutableCache + Ctx context.Context + CancelFunc context.CancelFunc + Logger io.Logger + Config *config.Config + CurrentWorkspace *workspace.Workspace + TUIContainer *tuikit.Container + WorkspacesCache cache.WorkspaceCache + ExecutableCache cache.ExecutableCache // ProcessTmpDir is the temporary directory for the current process. If set, it will be // used to store temporary files all executable runs when the tmpDir value is specified. @@ -60,19 +65,46 @@ func NewContext(ctx context.Context, stdIn, stdOut *os.File) *Context { } ctxx, cancel := context.WithCancel(ctx) - theme := styles.EverforestTheme() logMode := cfg.DefaultLogMode - return &Context{ + c := &Context{ Ctx: ctxx, CancelFunc: cancel, Config: cfg, CurrentWorkspace: wsConfig, WorkspacesCache: workspaceCache, ExecutableCache: executableCache, - Logger: io.NewLogger(stdOut, theme, logMode, filesystem.LogsDir()), + Logger: io.NewLogger(stdOut, flowIO.Theme(), logMode, filesystem.LogsDir()), stdOut: stdOut, stdIn: stdIn, } + + app := tuikit.NewApplication( + AppName, + tuikit.WithState(HeaderCtxKey, c.String()), + tuikit.WithLoadingMsg("thinking..."), + ) + c.TUIContainer, err = tuikit.NewContainer( + ctx, app, + tuikit.WithInput(stdIn), + tuikit.WithOutput(stdOut), + tuikit.WithTheme(flowIO.Theme()), + ) + if err != nil { + panic(errors.Wrap(err, "TUI container initialization error")) + } + return c +} + +func (ctx *Context) String() string { + ws := ctx.CurrentWorkspace.AssignedName() + ns := ctx.Config.CurrentNamespace + if ws == "" { + ws = "unk" + } + if ns == "" { + ns = "*" + } + return fmt.Sprintf("%s/%s", ws, ns) } func (ctx *Context) StdOut() *os.File { @@ -91,6 +123,10 @@ func (ctx *Context) SetIO(stdIn, stdOut *os.File) { ctx.stdOut = stdOut } +func (ctx *Context) SetView(view tuikit.View) error { + return ctx.TUIContainer.SetView(view) +} + func (ctx *Context) Finalize() { _ = ctx.stdIn.Close() _ = ctx.stdOut.Close() diff --git a/internal/filesystem/cache.go b/internal/filesystem/cache.go index 320abf8..f292e0a 100644 --- a/internal/filesystem/cache.go +++ b/internal/filesystem/cache.go @@ -20,7 +20,7 @@ func CachedDataDirPath() string { if err != nil { panic(errors.Wrap(err, "unable to get cache directory")) } - return filepath.Join(dirname, flowDirName) + return filepath.Join(dirname, dataDirName) } func LatestCachedDataDir() string { diff --git a/internal/filesystem/config.go b/internal/filesystem/config.go index 415d731..e901331 100644 --- a/internal/filesystem/config.go +++ b/internal/filesystem/config.go @@ -21,7 +21,7 @@ func ConfigDirPath() string { if err != nil { panic(errors.Wrap(err, "unable to get config directory")) } - return filepath.Join(dirname, flowDirName) + return filepath.Join(dirname, dataDirName) } func UserConfigFilePath() string { diff --git a/internal/filesystem/executables.go b/internal/filesystem/executables.go index 94b4353..32b108f 100644 --- a/internal/filesystem/executables.go +++ b/internal/filesystem/executables.go @@ -16,8 +16,6 @@ import ( "github.com/jahvon/flow/types/workspace" ) -const FlowFileExt = ".flow" - func EnsureExecutableDir(workspacePath, subPath string) error { if _, err := os.Stat(filepath.Join(workspacePath, subPath)); os.IsNotExist(err) { err = os.MkdirAll(filepath.Join(workspacePath, subPath), 0750) @@ -28,20 +26,6 @@ func EnsureExecutableDir(workspacePath, subPath string) error { return nil } -func InitExecutables( - template *executable.FlowFileTemplate, - ws *workspace.Workspace, - name, subPath string, -) error { - if err := EnsureExecutableDir(ws.Location(), subPath); err != nil { - return errors.Wrap(err, "unable to ensure executable directory") - } - if err := WriteFlowFileFromTemplate(template, ws, name, subPath); err != nil { - return errors.Wrap(err, "unable to write executable config template") - } - return nil -} - func WriteFlowFile(cfgFile string, cfg *executable.FlowFile) error { file, err := os.OpenFile(filepath.Clean(cfgFile), os.O_WRONLY|os.O_CREATE, 0600) if err != nil { @@ -132,7 +116,7 @@ func findFlowFiles(logger io.Logger, workspaceCfg *workspace.Workspace) ([]strin return filepath.SkipDir } - if filepath.Ext(entry.Name()) == FlowFileExt { + if filepath.Ext(entry.Name()) == executable.FlowFileExt { cfgPaths = append(cfgPaths, path) } } diff --git a/internal/filesystem/executables_test.go b/internal/filesystem/executables_test.go index 84cee58..e139b5a 100644 --- a/internal/filesystem/executables_test.go +++ b/internal/filesystem/executables_test.go @@ -49,7 +49,7 @@ var _ = Describe("Executables", func() { }, } - definitionFile := filepath.Join(tmpDir, "test"+filesystem.FlowFileExt) + definitionFile := filepath.Join(tmpDir, "test"+executable.FlowFileExt) Expect(filesystem.WriteFlowFile(definitionFile, executableDefinition)).To(Succeed()) readDefinition, err := filesystem.LoadFlowFile(definitionFile) @@ -70,7 +70,7 @@ var _ = Describe("Executables", func() { }, } - definitionFile := filepath.Join(tmpDir, "test"+filesystem.FlowFileExt) + definitionFile := filepath.Join(tmpDir, "test"+executable.FlowFileExt) Expect(filesystem.WriteFlowFile(definitionFile, executableDefinition)).To(Succeed()) workspaceCfg := &workspace.Workspace{} @@ -96,7 +96,7 @@ var _ = Describe("Executables", func() { }, } - definitionFile := filepath.Join(tmpDir, "test"+filesystem.FlowFileExt) + definitionFile := filepath.Join(tmpDir, "test"+executable.FlowFileExt) Expect(filesystem.WriteFlowFile(definitionFile, executableDefinition)).To(Succeed()) workspaceCfg := &workspace.Workspace{ @@ -131,7 +131,7 @@ var _ = Describe("Executables", func() { excludedDir, err := os.MkdirTemp(tmpDir, "excluded") Expect(err).NotTo(HaveOccurred()) - definitionFile := filepath.Join(excludedDir, "test"+filesystem.FlowFileExt) + definitionFile := filepath.Join(excludedDir, "test"+executable.FlowFileExt) Expect(filesystem.WriteFlowFile(definitionFile, executableDefinition)).To(Succeed()) workspaceCfg := &workspace.Workspace{ diff --git a/internal/filesystem/helpers.go b/internal/filesystem/helpers.go index 9548e89..ae6d76d 100644 --- a/internal/filesystem/helpers.go +++ b/internal/filesystem/helpers.go @@ -1,43 +1,34 @@ package filesystem import ( - "bufio" - "fmt" "os" - "path/filepath" + "strings" - "github.com/pkg/errors" + cp "github.com/otiai10/copy" ) -const flowDirName = "flow" +const dataDirName = "flow" func CopyFile(src, dst string) error { - in, err := os.Open(filepath.Clean(src)) - if err != nil { - return errors.Wrap(err, "unable to open source file") - } - defer in.Close() - - data := make([]byte, 0) - reader := bufio.NewReader(in) - for { - var b []byte - b, err = reader.ReadBytes('\n') - if err != nil { - if err.Error() == "EOF" { - break + opts := cp.Options{ + PreserveTimes: true, + PreserveOwner: true, + OnError: func(src, dest string, err error) error { + switch { + case err == nil: + return nil + case strings.Contains(err.Error(), src): + return err + case os.IsExist(err): + return nil + case os.IsNotExist(err): + if _, err := os.Create(dest); err != nil { + return err + } + return nil } - return errors.Wrap(err, "unable to read source file") - } - data = append(data, b...) - } - - if _, err = os.Stat(dst); err == nil { - return fmt.Errorf("file already exists: %s", dst) - } - if err = os.WriteFile(filepath.Clean(dst), data, 0600); err != nil { - return errors.Wrap(err, "unable to write file") + return err + }, } - - return nil + return cp.Copy(src, dst, opts) } diff --git a/internal/filesystem/templates.go b/internal/filesystem/templates.go index e8dd4d4..8a48e44 100644 --- a/internal/filesystem/templates.go +++ b/internal/filesystem/templates.go @@ -1,151 +1,40 @@ package filesystem import ( - "bytes" - "io/fs" "os" "path/filepath" - "strings" - "text/template" "github.com/pkg/errors" "gopkg.in/yaml.v3" "github.com/jahvon/flow/types/executable" - "github.com/jahvon/flow/types/workspace" ) -func WriteFlowFileTemplate(templatePath string, template *executable.FlowFileTemplate) error { - file, err := os.Create(filepath.Clean(templatePath)) - if err != nil { - return errors.Wrap(err, "unable to create template file") - } - defer file.Close() - - if err := yaml.NewEncoder(file).Encode(template); err != nil { - return errors.Wrap(err, "unable to encode template file") - } - return nil -} - -func WriteFlowFileFromTemplate( - cfgTemplate *executable.FlowFileTemplate, - ws *workspace.Workspace, - name, subPath string, -) error { - if err := EnsureExecutableDir(ws.Location(), subPath); err != nil { - return errors.Wrap(err, "unable to ensure existence of executable directory") - } - - executablesPath := filepath.Join(ws.Location(), subPath) - cfgYaml, err := yaml.Marshal(cfgTemplate.FlowFile) - if err != nil { - return errors.Wrap(err, "unable to marshal executable config") - } - templateData := cfgTemplate.Data.MapInterface() - templateData["Workspace"] = ws.AssignedName() - templateData["WorkspaceLocation"] = ws.Location() - templateData["ExecutablePath"] = executablesPath - t, err := template.New("config").Parse(string(cfgYaml)) - if err != nil { - return errors.Wrap(err, "unable to parse config template") - } - - var buf bytes.Buffer - if err := t.Execute(&buf, templateData); err != nil { - return errors.Wrap(err, "unable to execute config template") - } - - filename := strings.ToLower(name) - filename = strings.ReplaceAll(filename, " ", "_") - if !strings.HasSuffix(filename, FlowFileExt) { - filename += FlowFileExt - } - file, err := os.Create(filepath.Clean(filepath.Join(executablesPath, filename))) - if err != nil { - return errors.Wrap(err, "unable to create rendered config file") - } - defer file.Close() - - if _, err := file.Write(buf.Bytes()); err != nil { - return errors.Wrap(err, "unable to write rendered config file") - } - - if err := copyFlowFileTemplateAssets(cfgTemplate, executablesPath); err != nil { - return errors.Wrap(err, "unable to copy template assets") - } - - return nil -} - -func LoadFlowFileTemplate(templateFile string) (*executable.FlowFileTemplate, error) { - file, err := os.Open(filepath.Clean(templateFile)) +func LoadFlowFileTemplate(flowfileName, templatePath string) (*executable.Template, error) { + file, err := os.Open(filepath.Clean(templatePath)) if err != nil { return nil, errors.Wrap(err, "unable to open template file") } defer file.Close() - cfgTemplate := &executable.FlowFileTemplate{} - err = yaml.NewDecoder(file).Decode(cfgTemplate) + flowfileTmpl := &executable.Template{} + err = yaml.NewDecoder(file).Decode(flowfileTmpl) if err != nil { return nil, errors.Wrap(err, "unable to decode template file") } - cfgTemplate.SetContext(templateFile) + flowfileTmpl.SetContext(flowfileName, templatePath) - return cfgTemplate, nil + return flowfileTmpl, nil } -func copyFlowFileTemplateAssets(cfgTemplate *executable.FlowFileTemplate, cfgPath string) error { - sourcePath := filepath.Dir(cfgTemplate.Location()) - sourceFiles, err := expandArtifactFiles(sourcePath, cfgTemplate.Artifacts) - if err != nil { - return errors.Wrap(err, "unable to expand artifact files") - } - - for _, file := range sourceFiles { - relPath, err := filepath.Rel(sourcePath, file) +func LoadFlowFileTemplates(templatePaths map[string]string) (executable.TemplateList, error) { + templates := make(executable.TemplateList, 0, len(templatePaths)) + for name, path := range templatePaths { + tmpl, err := LoadFlowFileTemplate(name, path) if err != nil { - return errors.Wrap(err, "unable to get relative path") - } - destPath := filepath.Join(cfgPath, filepath.Base(relPath)) - if err := os.MkdirAll(filepath.Dir(destPath), 0750); err != nil { - if !os.IsExist(err) { - return errors.Wrap(err, "unable to create destination directory") - } - return errors.Wrap(err, "unable to create destination directory") - } - if err := CopyFile(file, destPath); err != nil { - return errors.Wrap(err, "unable to copy file") - } - } - return nil -} - -func expandArtifactFiles(rootPath string, artifacts []string) ([]string, error) { - var collectedFiles []string - for _, file := range artifacts { - fullPath := filepath.Join(rootPath, file) - //nolint:gocritic,nestif - if info, err := os.Stat(fullPath); os.IsNotExist(err) { - return nil, errors.Errorf("file does not exist: %s", fullPath) - } else if err != nil { - return nil, errors.Wrap(err, "unable to stat file") - } else if info.IsDir() { - err := filepath.WalkDir(fullPath, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } else if entry.IsDir() { - return nil - } - collectedFiles = append(collectedFiles, path) - return nil - }) - if err != nil { - return nil, errors.Wrap(err, "unable to walk directory") - } - } else { - collectedFiles = append(collectedFiles, fullPath) + return nil, errors.Wrap(err, "unable to load flowfile templates") } + templates = append(templates, tmpl) } - return collectedFiles, nil + return templates, nil } diff --git a/internal/filesystem/templates_test.go b/internal/filesystem/templates_test.go index ab1076d..4acb29a 100644 --- a/internal/filesystem/templates_test.go +++ b/internal/filesystem/templates_test.go @@ -3,9 +3,12 @@ package filesystem_test import ( "os" "path/filepath" + "strings" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/pkg/errors" + "gopkg.in/yaml.v3" "github.com/jahvon/flow/internal/filesystem" "github.com/jahvon/flow/types/executable" @@ -27,75 +30,83 @@ var _ = Describe("Templates", func() { Expect(os.RemoveAll(tmpDir)).To(Succeed()) }) - Describe("WriteFlowFileFromTemplate", func() { - It("renders and writes the template correctly", func() { - definitionTemplate := &executable.FlowFileTemplate{ - FlowFile: &executable.FlowFile{ - Namespace: "test", - Executables: executable.ExecutableList{ - {Verb: "run", Name: "test-executable", Description: "{{ .key }}"}, - }, + Describe("WriteFlowFileTemplate", func() { + It("writes the flowfile successfully", func() { + ff := &executable.FlowFile{ + Namespace: "test", + Executables: executable.ExecutableList{ + {Verb: "run", Name: "test-executable", Description: "{{ .key }}"}, }, - Data: executable.TemplateData{{Key: "key", Default: "value"}}, } - definitionTemplate.SetContext(filepath.Join(tmpDir, "test.tmpl"+filesystem.FlowFileExt)) - - workspaceConfig := workspace.DefaultWorkspaceConfig("test") - workspaceConfig.SetContext("test", tmpDir) - - err := filesystem.WriteFlowFileFromTemplate(definitionTemplate, workspaceConfig, "test", "") - Expect(err).ToNot(HaveOccurred()) - _, err = os.Stat(filepath.Join(tmpDir, "test"+filesystem.FlowFileExt)) + ffStr, err := ff.YAML() Expect(err).NotTo(HaveOccurred()) - }) - - It("renders and writes the template with artifacts correctly", func() { - definitionTemplate := &executable.FlowFileTemplate{ - FlowFile: &executable.FlowFile{ - Namespace: "test", - Executables: executable.ExecutableList{ - {Verb: "run", Name: "test-executable", Description: "{{ .key }}"}, - }, + template := &executable.Template{ + Template: ffStr, + Form: executable.FormFields{ + {Key: "key", Prompt: "enter key", Default: "value"}, }, - Data: executable.TemplateData{{Key: "key", Default: "value"}}, - Artifacts: []string{"subpath/test-artifact"}, } - definitionTemplate.SetContext(filepath.Join(tmpDir, "test.tmpl"+filesystem.FlowFileExt)) - - err := os.MkdirAll(filepath.Join(tmpDir, "subpath"), 0750) - Expect(err).NotTo(HaveOccurred()) - _, err = os.Create(filepath.Join(tmpDir, "subpath", "test-artifact")) - Expect(err).NotTo(HaveOccurred()) + templatePath := templateFullPath(tmpDir, "test") + template.SetContext("test", templatePath) workspaceConfig := workspace.DefaultWorkspaceConfig("test") workspaceConfig.SetContext("test", tmpDir) - Expect(filesystem.WriteFlowFileFromTemplate(definitionTemplate, workspaceConfig, "test", "")).To(Succeed()) - _, err = os.Stat(filepath.Join(tmpDir, "test"+filesystem.FlowFileExt)) - Expect(err).NotTo(HaveOccurred()) - _, err = os.Stat(filepath.Join(tmpDir, "test-artifact")) + err = WriteFlowFileTemplate(template.Location(), template) + Expect(err).ToNot(HaveOccurred()) + _, err = os.Stat(filepath.Join(tmpDir, "test"+executable.FlowFileTemplateExt)) Expect(err).NotTo(HaveOccurred()) }) }) Describe("LoadFlowFileTemplate", func() { It("loads the template correctly", func() { - definitionTemplate := &executable.FlowFileTemplate{ - FlowFile: &executable.FlowFile{ - Namespace: "test", - Executables: executable.ExecutableList{ - {Verb: "exec", Name: "test-executable", Description: "{{ .key }}"}, - }, + ff := &executable.FlowFile{ + Namespace: "test", + Executables: executable.ExecutableList{ + {Verb: "run", Name: "test-executable", Description: "{{ .key }}"}, }, - Data: executable.TemplateData{{Key: "key", Default: "value"}}, } - definitionTemplate.SetContext(filepath.Join(tmpDir, "test.tmpl"+filesystem.FlowFileExt)) - - Expect(filesystem.WriteFlowFileTemplate(definitionTemplate.Location(), definitionTemplate)).To(Succeed()) + ffStr, err := ff.YAML() + Expect(err).NotTo(HaveOccurred()) + template := &executable.Template{ + Template: ffStr, + Form: executable.FormFields{ + {Key: "key", Prompt: "enter key", Default: "value"}, + }, + } + templatePath := templateFullPath(tmpDir, "test") + template.SetContext("test", templatePath) + Expect(WriteFlowFileTemplate(templatePath, template)).To(Succeed()) - readTemplate, err := filesystem.LoadFlowFileTemplate(definitionTemplate.Location()) + readTemplate, err := filesystem.LoadFlowFileTemplate("test", templatePath) Expect(err).NotTo(HaveOccurred()) - Expect(readTemplate).To(Equal(definitionTemplate)) + Expect(readTemplate).To(Equal(template)) + Expect(readTemplate.Location()).To(Equal(templatePath)) + Expect(readTemplate.Name()).To(Equal("test")) }) }) }) + +func WriteFlowFileTemplate(templateFilePath string, template *executable.Template) error { + file, err := os.Create(filepath.Clean(templateFilePath)) + if err != nil { + return errors.Wrap(err, "unable to create template file") + } + defer file.Close() + + if err := yaml.NewEncoder(file).Encode(template); err != nil { + return errors.Wrap(err, "unable to encode template file") + } + return nil +} + +func templateFullPath(templateDir, templateName string) string { + templatePath := filepath.Join(templateDir, templateName) + if strings.HasSuffix(templateName, executable.FlowFileTemplateExt) { + return templatePath + } else if strings.HasSuffix(templatePath, executable.FlowFileExt) { + return strings.TrimSuffix(templatePath, executable.FlowFileExt) + executable.FlowFileTemplateExt + } + return templatePath + executable.FlowFileTemplateExt +} diff --git a/internal/io/common/common.go b/internal/io/common/common.go index 2912adf..ad7bb43 100644 --- a/internal/io/common/common.go +++ b/internal/io/common/common.go @@ -8,6 +8,8 @@ import ( "github.com/jahvon/flow/internal/services/open" ) +const HeaderContextKey = "ctx" + var termEditors = []string{"vim", "nvim", "emacs", "nano"} func OpenInEditor(path string, stdIn, stdOut *os.File) error { diff --git a/internal/io/config/views.go b/internal/io/config/views.go index 6dcac21..81eb6c9 100644 --- a/internal/io/config/views.go +++ b/internal/io/config/views.go @@ -1,21 +1,17 @@ package config import ( - "github.com/jahvon/tuikit/components" + "github.com/jahvon/tuikit" + "github.com/jahvon/tuikit/types" + "github.com/jahvon/tuikit/views" - "github.com/jahvon/flow/internal/io" "github.com/jahvon/flow/types/config" ) func NewUserConfigView( - container *components.ContainerView, + container *tuikit.Container, cfg config.Config, - format components.Format, -) components.TeaModel { - state := &components.TerminalState{ - Theme: io.Theme(), - Height: container.Height(), - Width: container.Width(), - } - return components.NewEntityView(state, &cfg, format) + format types.Format, +) tuikit.View { + return views.NewEntityView(container.RenderState(), &cfg, format) } diff --git a/internal/io/executable/output.go b/internal/io/executable/output.go index 91878e7..53a6b2d 100644 --- a/internal/io/executable/output.go +++ b/internal/io/executable/output.go @@ -9,16 +9,22 @@ import ( "github.com/jahvon/flow/types/executable" ) +const ( + yamlFormat = "yaml" + ymlFormat = "yml" + jsonFormat = "json" +) + func PrintExecutableList(logger tuikitIO.Logger, format string, executables executable.ExecutableList) { logger.Infof("listing %d executables", len(executables)) switch strings.ToLower(format) { - case "", "yaml", "yml": + case "", yamlFormat, ymlFormat: str, err := executables.YAML() if err != nil { logger.Fatalf("Failed to marshal executable list - %v", err) } logger.Println(str) - case "json": + case jsonFormat: str, err := executables.JSON() if err != nil { logger.Fatalf("Failed to marshal executable list - %v", err) @@ -35,13 +41,13 @@ func PrintExecutable(logger tuikitIO.Logger, format string, exec *executable.Exe } logger.Infox(fmt.Sprintf("Executable %s", exec.ID()), "Location", exec.FlowFilePath()) switch strings.ToLower(format) { - case "", "yaml", "yml": + case "", yamlFormat, ymlFormat: str, err := exec.YAML() if err != nil { logger.Fatalf("Failed to marshal executable - %v", err) } logger.Println(str) - case "json": + case jsonFormat: str, err := exec.JSON() if err != nil { logger.Fatalf("Failed to marshal executable - %v", err) @@ -51,3 +57,46 @@ func PrintExecutable(logger tuikitIO.Logger, format string, exec *executable.Exe logger.Fatalf("Unsupported output format %s", format) } } + +func PrintTemplate(logger tuikitIO.Logger, format string, template *executable.Template) { + if template == nil { + logger.Fatalf("Template is nil") + } + logger.Infof("Template %s", template.Name()) + switch strings.ToLower(format) { + case "", yamlFormat, ymlFormat: + str, err := template.YAML() + if err != nil { + logger.Fatalf("Failed to marshal template - %v", err) + } + logger.Println(str) + case jsonFormat: + str, err := template.JSON() + if err != nil { + logger.Fatalf("Failed to marshal template - %v", err) + } + logger.Println(str) + default: + logger.Fatalf("Unsupported output format %s", format) + } +} + +func PrintTemplateList(logger tuikitIO.Logger, format string, templates executable.TemplateList) { + logger.Infof("listing %d templates", len(templates)) + switch strings.ToLower(format) { + case "", yamlFormat, ymlFormat: + str, err := templates.YAML() + if err != nil { + logger.Fatalf("Failed to marshal template list - %v", err) + } + logger.Println(str) + case jsonFormat: + str, err := templates.JSON() + if err != nil { + logger.Fatalf("Failed to marshal template list - %v", err) + } + logger.Println(str) + default: + logger.Fatalf("Unsupported output format %s", format) + } +} diff --git a/internal/io/executable/views.go b/internal/io/executable/views.go index c041e53..6d444d1 100644 --- a/internal/io/executable/views.go +++ b/internal/io/executable/views.go @@ -5,28 +5,34 @@ import ( "strings" "github.com/atotto/clipboard" - "github.com/jahvon/tuikit/components" + "github.com/jahvon/tuikit" "github.com/jahvon/tuikit/styles" + "github.com/jahvon/tuikit/types" + "github.com/jahvon/tuikit/views" "github.com/jahvon/flow/internal/context" - "github.com/jahvon/flow/internal/io" "github.com/jahvon/flow/internal/io/common" "github.com/jahvon/flow/types/executable" ) func NewExecutableView( ctx *context.Context, - exec executable.Executable, - format components.Format, + exec *executable.Executable, + format types.Format, runFunc func(string) error, -) components.TeaModel { - container := ctx.InteractiveContainer - var executableKeyCallbacks = []components.KeyCallback{ +) tuikit.View { + container := ctx.TUIContainer + var executableKeyCallbacks = []types.KeyCallback{ { Key: "r", Label: "run", Callback: func() error { - ctx.InteractiveContainer.Shutdown() - return runFunc(exec.Ref().String()) + ctx.TUIContainer.Shutdown(func() { + err := runFunc(exec.Ref().String()) + if err != nil { + ctx.Logger.Error(err, "executable view runner error") + } + }) + return nil }, }, { @@ -50,14 +56,9 @@ func NewExecutableView( }, }, } - state := &components.TerminalState{ - Theme: io.Theme(), - Height: container.Height(), - Width: container.Width(), - } - return components.NewEntityView( - state, - &exec, + return views.NewEntityView( + container.RenderState(), + exec, format, executableKeyCallbacks..., ) @@ -66,10 +67,10 @@ func NewExecutableView( func NewExecutableListView( ctx *context.Context, executables executable.ExecutableList, - format components.Format, + format types.Format, runFunc func(string) error, -) components.TeaModel { - container := ctx.InteractiveContainer +) tuikit.View { + container := ctx.TUIContainer if len(executables.Items()) == 0 { container.HandleError(fmt.Errorf("no workspaces found")) } @@ -84,14 +85,75 @@ func NewExecutableListView( if err != nil { return fmt.Errorf("executable not found") } - container.SetView(NewExecutableView(ctx, *exec, format, runFunc)) - return nil + return ctx.SetView(NewExecutableView(ctx, exec, format, runFunc)) + } + + return views.NewCollectionView(container.RenderState(), executables, format, selectFunc) +} + +func NewTemplateView( + ctx *context.Context, + template *executable.Template, + format types.Format, + runFunc func(string) error, +) tuikit.View { + container := ctx.TUIContainer + var templateKeyCallbacks = []types.KeyCallback{ + { + Key: "r", Label: "run", + Callback: func() error { + ctx.TUIContainer.Shutdown() + return runFunc(template.Name()) + }, + }, + { + Key: "c", Label: "copy location", + Callback: func() error { + if err := clipboard.WriteAll(template.Location()); err != nil { + container.HandleError(fmt.Errorf("unable to copy location to clipboard: %w", err)) + } else { + container.SetNotice("copied location to clipboard", styles.NoticeLevelInfo) + } + return nil + }, + }, + { + Key: "e", Label: "edit", + Callback: func() error { + if err := common.OpenInEditor(template.Location(), ctx.StdIn(), ctx.StdOut()); err != nil { + container.HandleError(fmt.Errorf("unable to open template: %w", err)) + } + return nil + }, + }, } + return views.NewEntityView( + container.RenderState(), + template, + format, + templateKeyCallbacks..., + ) +} - state := &components.TerminalState{ - Theme: io.Theme(), - Height: container.Height(), - Width: container.Width(), +func NewTemplateListView( + ctx *context.Context, + templates executable.TemplateList, + format types.Format, + runFunc func(string) error, +) tuikit.View { + container := ctx.TUIContainer + if len(templates.Items()) == 0 { + container.HandleError(fmt.Errorf("no templates found")) } - return components.NewCollectionView(state, executables, format, selectFunc) + + selectFunc := func(filterVal string) error { + template := templates.Find(filterVal) + if template == nil { + return fmt.Errorf("template %s not found", filterVal) + } + + return ctx.SetView(NewTemplateView(ctx, template, format, runFunc)) + } + + return views.NewCollectionView(container.RenderState(), templates, format, selectFunc) } diff --git a/internal/io/library/init.go b/internal/io/library/init.go index ad91d04..d1ad68c 100644 --- a/internal/io/library/init.go +++ b/internal/io/library/init.go @@ -7,7 +7,7 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" - "github.com/jahvon/tuikit/components" + "github.com/jahvon/tuikit/types" "github.com/jahvon/flow/types/executable" "github.com/jahvon/flow/types/workspace" @@ -19,7 +19,7 @@ func (l *Library) Init() tea.Cmd { cmds, tea.SetWindowTitle("flow library"), tea.Tick(time.Millisecond*250, func(t time.Time) tea.Msg { - return components.TickMsg(t) + return types.Tick() }), ) cmds = append( @@ -29,7 +29,7 @@ func (l *Library) Init() tea.Cmd { l.paneTwoViewport.Init(), ) - if l.ctx.InteractiveContainer.Width() >= 150 { + if l.ctx.TUIContainer.Width() >= 150 { l.splitView = true } l.setSize() diff --git a/internal/io/library/library.go b/internal/io/library/library.go index c7f97a2..305cb37 100644 --- a/internal/io/library/library.go +++ b/internal/io/library/library.go @@ -4,8 +4,9 @@ import ( "fmt" "github.com/charmbracelet/bubbles/viewport" - "github.com/jahvon/tuikit/components" + "github.com/jahvon/tuikit" "github.com/jahvon/tuikit/styles" + "github.com/jahvon/tuikit/views" "github.com/jahvon/flow/internal/context" "github.com/jahvon/flow/types/common" @@ -79,9 +80,9 @@ func NewLibraryView( filter Filter, theme styles.Theme, runFunc func(string) error, -) components.TeaModel { +) tuikit.View { l := NewLibrary(ctx, workspaces, execs, filter, theme, runFunc) - return components.NewFrameView(l) + return views.NewFrameView(l) } func ctxVal(ws, ns string) string { diff --git a/internal/io/library/update.go b/internal/io/library/update.go index 9e491f0..29ae688 100644 --- a/internal/io/library/update.go +++ b/internal/io/library/update.go @@ -246,10 +246,11 @@ func (l *Library) updateExecPanes(msg tea.Msg) (viewport.Model, tea.Cmd) { } go func() { - l.ctx.InteractiveContainer.Shutdown() - if err := l.cmdRunFunc(curExec.Ref().String()); err != nil { - l.ctx.Logger.Fatalx("unable to execute command", "error", err) - } + l.ctx.TUIContainer.Shutdown(func() { + if err := l.cmdRunFunc(curExec.Ref().String()); err != nil { + l.ctx.Logger.Fatalx("unable to execute command", "error", err) + } + }) }() case "f": if l.currentPane == 1 { diff --git a/internal/io/library/view.go b/internal/io/library/view.go index bdca86b..b8aa1b8 100644 --- a/internal/io/library/view.go +++ b/internal/io/library/view.go @@ -83,8 +83,8 @@ func (l *Library) SetNotice(notice string, level styles.NoticeLevel) { } func (l *Library) setSize() { - l.termWidth = l.ctx.InteractiveContainer.Width() - l.termHeight = l.ctx.InteractiveContainer.FullHeight() + l.termWidth = l.ctx.TUIContainer.Width() + l.termHeight = l.ctx.TUIContainer.Height() p0, p1, p2 := calculateViewportWidths(l.termWidth-widthPadding, l.splitView) l.paneZeroViewport.Width = p0 l.paneOneViewport.Width = p1 diff --git a/internal/io/secret/views.go b/internal/io/secret/views.go index 727ff69..a66ae01 100644 --- a/internal/io/secret/views.go +++ b/internal/io/secret/views.go @@ -3,11 +3,12 @@ package secret import ( "fmt" - "github.com/jahvon/tuikit/components" + "github.com/jahvon/tuikit" "github.com/jahvon/tuikit/styles" + "github.com/jahvon/tuikit/types" + "github.com/jahvon/tuikit/views" "github.com/jahvon/flow/internal/context" - "github.com/jahvon/flow/internal/io" "github.com/jahvon/flow/internal/vault" ) @@ -15,19 +16,29 @@ func NewSecretView( ctx *context.Context, secret vault.Secret, asPlainText bool, -) components.TeaModel { - container := ctx.InteractiveContainer +) tuikit.View { + container := ctx.TUIContainer v := vault.NewVault(ctx.Logger) - var secretKeyCallbacks = []components.KeyCallback{ + var secretKeyCallbacks = []types.KeyCallback{ { Key: "r", Label: "rename", Callback: func() error { - in := components.TextInput{Key: "value", Prompt: "Enter the new secret name"} - inputs, err := components.ProcessInputs(io.Theme(), &in) + form, err := views.NewFormView( + container.RenderState(), + &views.FormField{ + Key: "value", + Type: views.PromptTypeText, + Title: "Enter the new secret name", + }) if err != nil { - ctx.Logger.FatalErr(err) + container.HandleError(fmt.Errorf("encountered error creating the form: %w", err)) + return nil + } + if err := ctx.SetView(form); err != nil { + container.HandleError(fmt.Errorf("unable to set view: %w", err)) + return nil } - newName := inputs.FindByKey("value").Value() + newName := form.FindByKey("value").Value() if err := v.RenameSecret(secret.Reference, newName); err != nil { container.HandleError(fmt.Errorf("unable to rename secret: %w", err)) return nil @@ -40,12 +51,22 @@ func NewSecretView( { Key: "e", Label: "edit", Callback: func() error { - in := components.TextInput{Key: "value", Prompt: "Enter the new secret value"} - inputs, err := components.ProcessInputs(io.Theme(), &in) + form, err := views.NewFormView( + container.RenderState(), + &views.FormField{ + Key: "value", + Type: views.PromptTypeMasked, + Title: "Enter the new secret value", + }) if err != nil { - ctx.Logger.FatalErr(err) + container.HandleError(fmt.Errorf("encountered error creating the form: %w", err)) + return nil + } + if err := ctx.SetView(form); err != nil { + container.HandleError(fmt.Errorf("unable to set view: %w", err)) + return nil } - newValue := inputs.FindByKey("value").Value() + newValue := form.FindByKey("value").Value() secretValue := vault.SecretValue(newValue) if err := v.SetSecret(secret.Reference, secretValue); err != nil { container.HandleError(fmt.Errorf("unable to edit secret: %w", err)) @@ -70,20 +91,15 @@ func NewSecretView( }, } - state := &components.TerminalState{ - Theme: io.Theme(), - Height: container.Height(), - Width: container.Width(), - } - return components.NewEntityView(state, &secret, components.FormatDocument, secretKeyCallbacks...) + return views.NewEntityView(container.RenderState(), &secret, types.EntityFormatDocument, secretKeyCallbacks...) } func NewSecretListView( ctx *context.Context, secrets vault.SecretList, asPlainText bool, -) components.TeaModel { - container := ctx.InteractiveContainer +) tuikit.View { + container := ctx.TUIContainer if len(secrets.Items()) == 0 { container.HandleError(fmt.Errorf("no secrets found")) } @@ -102,23 +118,16 @@ func NewSecretListView( return fmt.Errorf("secret not found") } - container.SetView(NewSecretView(ctx, secret, asPlainText)) - return nil + return container.SetView(NewSecretView(ctx, secret, asPlainText)) } - state := &components.TerminalState{ - Theme: io.Theme(), - Height: container.Height(), - Width: container.Width(), - } - return components.NewCollectionView(state, secrets, components.FormatList, selectFunc) + return views.NewCollectionView(container.RenderState(), secrets, types.CollectionFormatList, selectFunc) } func LoadSecretListView( ctx *context.Context, asPlainText bool, ) { - container := ctx.InteractiveContainer v := vault.NewVault(ctx.Logger) secrets, err := v.GetAllSecrets() if err != nil { @@ -137,5 +146,7 @@ func LoadSecretListView( secretList, asPlainText, ) - container.SetView(view) + if err := ctx.SetView(view); err != nil { + ctx.Logger.FatalErr(err) + } } diff --git a/internal/io/workspace/views.go b/internal/io/workspace/views.go index c5cdc7b..ffe090e 100644 --- a/internal/io/workspace/views.go +++ b/internal/io/workspace/views.go @@ -4,12 +4,13 @@ import ( "fmt" "path/filepath" - "github.com/jahvon/tuikit/components" + "github.com/jahvon/tuikit" "github.com/jahvon/tuikit/styles" + "github.com/jahvon/tuikit/types" + "github.com/jahvon/tuikit/views" "github.com/jahvon/flow/internal/context" "github.com/jahvon/flow/internal/filesystem" - "github.com/jahvon/flow/internal/io" "github.com/jahvon/flow/internal/io/common" "github.com/jahvon/flow/internal/services/open" "github.com/jahvon/flow/types/workspace" @@ -18,10 +19,10 @@ import ( func NewWorkspaceView( ctx *context.Context, ws *workspace.Workspace, - format components.Format, -) components.TeaModel { - container := ctx.InteractiveContainer - var workspaceKeyCallbacks = []components.KeyCallback{ + format types.Format, +) tuikit.View { + container := ctx.TUIContainer + var workspaceKeyCallbacks = []types.KeyCallback{ { Key: "o", Label: "open", Callback: func() error { @@ -53,27 +54,22 @@ func NewWorkspaceView( if err := filesystem.WriteConfig(curCfg); err != nil { container.HandleError(err) } - container.SetContext(fmt.Sprintf("%s/*", ws.AssignedName())) + container.SetState(common.HeaderContextKey, fmt.Sprintf("%s/*", ws.AssignedName())) container.SetNotice("workspace updated", styles.NoticeLevelInfo) return nil }, }, } - state := &components.TerminalState{ - Theme: io.Theme(), - Height: container.Height(), - Width: container.Width(), - } - return components.NewEntityView(state, ws, format, workspaceKeyCallbacks...) + return views.NewEntityView(container.RenderState(), ws, format, workspaceKeyCallbacks...) } func NewWorkspaceListView( ctx *context.Context, workspaces workspace.WorkspaceList, - format components.Format, -) components.TeaModel { - container := ctx.InteractiveContainer + format types.Format, +) tuikit.View { + container := ctx.TUIContainer if len(workspaces.Items()) == 0 { container.HandleError(fmt.Errorf("no workspaces found")) } @@ -90,14 +86,8 @@ func NewWorkspaceListView( return fmt.Errorf("workspace not found") } - container.SetView(NewWorkspaceView(ctx, ws, format)) - return nil + return ctx.SetView(NewWorkspaceView(ctx, ws, format)) } - state := &components.TerminalState{ - Theme: io.Theme(), - Height: container.Height(), - Width: container.Width(), - } - return components.NewCollectionView(state, workspaces, format, selectFunc) + return views.NewCollectionView(container.RenderState(), workspaces, format, selectFunc) } diff --git a/internal/runner/render/render.go b/internal/runner/render/render.go index 4f87a3c..3f4c770 100644 --- a/internal/runner/render/render.go +++ b/internal/runner/render/render.go @@ -9,18 +9,15 @@ import ( "path/filepath" "text/template" - "github.com/jahvon/tuikit/components" + "github.com/jahvon/tuikit/views" "github.com/pkg/errors" "gopkg.in/yaml.v3" "github.com/jahvon/flow/internal/context" - "github.com/jahvon/flow/internal/io" "github.com/jahvon/flow/internal/runner" "github.com/jahvon/flow/types/executable" ) -const appName = "flow renderer" - type renderRunner struct{} func NewRunner() runner.Runner { @@ -93,10 +90,8 @@ func (r *renderRunner) Exec(ctx *context.Context, e *executable.Executable, inpu ctx.Logger.Infof("Rendering content from file %s", contentFile) filename := filepath.Base(contentFile) - if err = components.RunMarkdownView(io.Theme(), appName, "file", filename, buff.String()); err != nil { - return errors.Wrap(err, "unable to render content") - } - return nil + ctx.TUIContainer.SetState("file", filename) + return ctx.TUIContainer.SetView(views.NewMarkdownView(ctx.TUIContainer.RenderState(), buff.String())) } func readDataFile(dir, path string) (map[string]interface{}, error) { diff --git a/internal/templates/artifacts.go b/internal/templates/artifacts.go new file mode 100644 index 0000000..7c7d3fd --- /dev/null +++ b/internal/templates/artifacts.go @@ -0,0 +1,135 @@ +package templates + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + tuikitIO "github.com/jahvon/tuikit/io" + "github.com/pkg/errors" + + "github.com/jahvon/flow/internal/filesystem" + "github.com/jahvon/flow/types/executable" +) + +func copyAllArtifacts( + logger tuikitIO.Logger, + artifacts []executable.Artifact, + wsDir, srcDir, dstDir string, + data, envMap map[string]string, +) error { + var errs []error + for i, a := range artifacts { + if err := copyArtifact( + logger, fmt.Sprintf("artifact-%d", i), wsDir, srcDir, dstDir, a, data, envMap, + ); err != nil { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return errors.Errorf("errors copying artifacts: %v", errs) + } + return nil +} + +//nolint:gocognit +func copyArtifact( + logger tuikitIO.Logger, + name, wsPath, srcDir, dstDir string, + artifact executable.Artifact, + data, envMap map[string]string, +) error { + srcPath, err := parseSourcePath(logger, name, srcDir, wsPath, artifact, data, envMap) + if err != nil { + return errors.Wrap(err, "unable to parse source path") + } + + if artifact.If != "" { + eval, err := goTemplateEvaluatedTrue(name, artifact.If, data) + if err != nil { + return errors.Wrap(err, "unable to evaluate if condition") + } + if !eval { + logger.Debugf("skipping artifact %s", name) + return nil + } + } + + srcName := filepath.Base(srcPath) + if strings.Contains(srcName, "*") { + matches, err := filepath.Glob(srcPath) + if err != nil { + return errors.Wrap(err, "unable to glob source path") + } + var errs []error + for i, match := range matches { + m := artifact + m.SrcName = filepath.Base(match) + m.SrcDir = filepath.Dir(match) + mErr := copyArtifact(logger, fmt.Sprintf("%s-%d", name, i), wsPath, srcDir, dstDir, m, data, envMap) + if mErr != nil { + errs = append(errs, mErr) + } + } + if len(errs) > 0 { + return errors.Errorf("errors copying artifact from pattern: %v", errs) + } + } + + info, err := os.Stat(srcPath) + switch { + case os.IsNotExist(err): + return errors.Errorf("file does not exist: %s", srcPath) + case err != nil: + return errors.Wrap(err, "unable to stat src file") + case info.IsDir(): + err := filepath.WalkDir(srcPath, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + a := artifact + a.SrcName = filepath.Base(path) + a.SrcDir = filepath.Dir(path) + aName := fmt.Sprintf("%s-%s", name, a.SrcName) + return copyArtifact(logger, aName, wsPath, srcDir, dstDir, a, data, envMap) + }) + if err != nil { + return errors.Wrap(err, "unable to walk directory") + } + } + if artifact.DstName == "" { + artifact.DstName = srcName + } + dstPath, err := parseDestinationPath( + logger, + name, + dstDir, srcDir, wsPath, + artifact, + data, envMap, + ) + if err != nil { + return errors.Wrap(err, "unable to parse destination path") + } + + if err := os.MkdirAll(dstDir, 0750); err != nil { + if !os.IsExist(err) { + return errors.Wrap(err, "unable to create destination directory") + } + return errors.Wrap(err, "unable to create destination directory") + } + + logger.Debugx("copying artifact", "name", name, "src", srcPath, "dst", dstPath) + if _, e := os.Stat(dstPath); e == nil { + // TODO: Add a flag to overwrite existing files + logger.Warnx("Overwriting existing file", "dst", dstPath) + } + if err := filesystem.CopyFile(srcPath, dstPath); err != nil { + return errors.Wrap(err, "unable to copy artifact") + } + return nil +} diff --git a/internal/templates/form.go b/internal/templates/form.go new file mode 100644 index 0000000..3ff0255 --- /dev/null +++ b/internal/templates/form.go @@ -0,0 +1,51 @@ +package templates + +import ( + "fmt" + + "github.com/jahvon/tuikit/views" + + "github.com/jahvon/flow/internal/context" + "github.com/jahvon/flow/internal/io" + "github.com/jahvon/flow/types/executable" +) + +func showForm(ctx *context.Context, fields executable.FormFields) error { + if len(fields) == 0 { + return nil + } + in := ctx.StdIn() + out := ctx.StdOut() + + if err := fields.Validate(); err != nil { + return fmt.Errorf("invalid form fields: %w", err) + } + var ff []*views.FormField + for _, f := range fields { + ff = append(ff, &views.FormField{ + Key: f.Key, + Group: uint(f.Group), + Description: f.Description, + Default: f.Default, + Title: f.Prompt, + Placeholder: f.Default, + Required: f.Required, + ValidationExpr: f.Validate, + }) + } + form, err := views.NewForm(io.Theme(), in, out, ff...) + if err != nil { + return fmt.Errorf("encountered form init error: %w", err) + } + if err = form.Run(ctx.Ctx); err != nil { + return fmt.Errorf("encountered form run error: %w", err) + } + for _, f := range fields { + v, ok := form.ValueMap()[f.Key] + if !ok { + continue + } + f.Set(fmt.Sprintf("%v", v)) + } + return nil +} diff --git a/internal/templates/templates.go b/internal/templates/templates.go new file mode 100644 index 0000000..c781351 --- /dev/null +++ b/internal/templates/templates.go @@ -0,0 +1,221 @@ +package templates + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "text/template" + "time" + + "github.com/Masterminds/sprig/v3" + tuikitIO "github.com/jahvon/tuikit/io" + "github.com/pkg/errors" + "gopkg.in/yaml.v3" + + "github.com/jahvon/flow/internal/context" + "github.com/jahvon/flow/internal/filesystem" + "github.com/jahvon/flow/internal/runner" + "github.com/jahvon/flow/internal/utils" + "github.com/jahvon/flow/types/executable" + "github.com/jahvon/flow/types/workspace" +) + +func ProcessTemplate( + ctx *context.Context, + template *executable.Template, + ws *workspace.Workspace, + flowfileName, flowfileDir string, +) error { + logger := ctx.Logger + if flowfileName == "" { + flowfileName = fmt.Sprintf("executables_%s", time.Now().Format("20060102150405")) + } + flowfileName = strings.ReplaceAll(strings.ToLower(flowfileName), " ", "_") + if !strings.HasSuffix(flowfileName, executable.FlowFileExt) { + flowfileName += executable.FlowFileExt + } + + data := make(map[string]string) + if template.Form != nil { + if err := showForm(ctx, template.Form); err != nil { + return err + } + data = template.Form.ValueMap() + } + + env := os.Environ() + envMap := make(map[string]string) + for _, e := range env { + pair := strings.SplitN(e, "=", 2) + envMap[pair[0]] = pair[1] + } + flowfileDir = utils.ExpandDirectory(logger, flowfileDir, ws.Location(), template.Location(), envMap) + fullPath := filepath.Join(flowfileDir, flowfileName) + logger.Debugx( + fmt.Sprintf("processing %s template", flowfileName), + "template", template.Location(), "output", fullPath, + ) + + data["FlowWorkspace"] = ws.AssignedName() + data["FlowWorkspacePath"] = ws.Location() + data["FlowFileName"] = flowfileName + data["FlowFilePath"] = fullPath + + var preRun []executable.ExecExecutableType + for _, e := range template.PreRun { + preRun = append(preRun, executable.ExecExecutableType(e)) + } + if err := runExecutables(ctx, "pre-run", flowfileDir, preRun, envMap); err != nil { + return err + } + + if err := copyAllArtifacts( + logger, + template.Artifacts, + ws.Location(), + filepath.Dir(template.Location()), + flowfileDir, + data, envMap, + ); err != nil { + return err + } + + if template.Template != "" { + flowfile, err := templateToFlowfile(template, data) + if err != nil { + return err + } + + if _, e := os.Stat(fullPath); e == nil { + // TODO: Add a flag to overwrite existing files + logger.Warnx("Overwriting existing file", "dst", fullPath) + } + + if err := filesystem.WriteFlowFile(fullPath, flowfile); err != nil { + return errors.Wrap(err, fmt.Sprintf("unable to write flowfile %s from template", flowfileName)) + } + } + + var postRun []executable.ExecExecutableType + for _, e := range template.PostRun { + postRun = append(postRun, executable.ExecExecutableType(e)) + } + if err := runExecutables(ctx, "post-run", flowfileDir, postRun, envMap); err != nil { + return err + } + + return nil +} + +func runExecutables( + ctx *context.Context, + stage, flowfileDir string, + execs []executable.ExecExecutableType, + envMap map[string]string, +) error { + ctx.Logger.Debugf("running %d %s executables", len(execs), stage) + for i, exec := range execs { + exec.SetLogFields(map[string]interface{}{ + "stage": stage, + "step": i + 1, + }) + eCopy := exec + e := executable.Executable{ + Verb: "exec", + Name: fmt.Sprintf("%s-exec-%d", stage, i), + Exec: &eCopy, + } + e.SetContext( + ctx.CurrentWorkspace.AssignedName(), ctx.CurrentWorkspace.Location(), + "flow-internal", flowfileDir, + ) + if err := runner.Exec(ctx, &e, envMap); err != nil { + return errors.Wrap(err, fmt.Sprintf("unable to execute %s executable %d", stage, i)) + } + } + return nil +} + +func parseSourcePath( + logger tuikitIO.Logger, + name, flowFileSrc, wsDir string, + artifact executable.Artifact, + data, envMap map[string]string, +) (string, error) { + var err error + if artifact.SrcDir != "" { + flowFileSrc = utils.ExpandDirectory(logger, artifact.SrcDir, wsDir, flowFileSrc, envMap) + } + var sb *bytes.Buffer + sb, err = processAsGoTemplate(name, filepath.Join(flowFileSrc, artifact.SrcName), data) + if err != nil { + return "", errors.Wrap(err, "unable to process artifact as template") + } + return sb.String(), nil +} + +func parseDestinationPath( + logger tuikitIO.Logger, + name, dstDir, flowFileSrc, wsDir string, + artifact executable.Artifact, + data, envMap map[string]string, +) (string, error) { + var err error + if artifact.DstDir != "" { + dstDir = utils.ExpandDirectory(logger, artifact.DstDir, wsDir, flowFileSrc, envMap) + } + dstName := artifact.DstName + var db *bytes.Buffer + db, err = processAsGoTemplate(name, dstName, data) + if err != nil { + return "", errors.Wrap(err, "unable to process artifact as template") + } + dstName = db.String() + return filepath.Join(dstDir, dstName), nil +} + +func templateToFlowfile( + t *executable.Template, + data map[string]string, +) (*executable.FlowFile, error) { + buf, err := processAsGoTemplate(t.Name(), t.Template, data) + if err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("flowfile template %s", t.Name())) + } + + flowfile := &executable.FlowFile{} + if err := yaml.NewDecoder(buf).Decode(flowfile); err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("unable to decode %s flowfile template", t.Name())) + } + + return flowfile, nil +} + +func processAsGoTemplate(fileName, txt string, data map[string]string) (*bytes.Buffer, error) { + tmpl, err := template.New(fileName).Funcs(sprig.TxtFuncMap()).Parse(txt) + if err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("unable to parse %s template", fileName)) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("unable to execute %s template", fileName)) + } + + return &buf, nil +} + +func goTemplateEvaluatedTrue(fileName, txt string, data map[string]string) (bool, error) { + t, err := template.New(fileName).Funcs(sprig.FuncMap()).Parse(txt) + if err != nil { + return false, errors.Wrap(err, fmt.Sprintf("unable to parse %s template", fileName)) + } + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + return false, errors.Wrap(err, "unable to evaluate template") + } + return strconv.ParseBool(buf.String()) +} diff --git a/internal/vault/secret.go b/internal/vault/secret.go index f35a5a4..f88bde5 100644 --- a/internal/vault/secret.go +++ b/internal/vault/secret.go @@ -97,10 +97,10 @@ func (l SecretList) FindByName(name string) *Secret { return nil } -func (l SecretList) Items() []*types.CollectionItem { - items := make([]*types.CollectionItem, 0) +func (l SecretList) Items() []*types.EntityInfo { + items := make([]*types.EntityInfo, 0) for _, secret := range l { - item := types.CollectionItem{ + item := types.EntityInfo{ Header: secret.Reference, ID: secret.Reference, } diff --git a/schemas/flowfile_schema.json b/schemas/flowfile_schema.json index c34d2b0..e510489 100644 --- a/schemas/flowfile_schema.json +++ b/schemas/flowfile_schema.json @@ -30,7 +30,6 @@ ] }, "Executable": { - "$schema": "http://json-schema.org/draft-07/schema#", "description": "The executable schema defines the structure of an executable in the Flow CLI.\nExecutables are the building blocks of workflows and are used to define the actions that can be performed in a workspace.\n", "type": "object", "required": [ diff --git a/schemas/template_schema.json b/schemas/template_schema.json new file mode 100644 index 0000000..b9bf886 --- /dev/null +++ b/schemas/template_schema.json @@ -0,0 +1,163 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/jahvon/flow/HEAD/schemas/template_schema.json", + "description": "Configuration for a flowfile template; templates can be used to generate flow files.", + "type": "object", + "required": [ + "template" + ], + "definitions": { + "Artifact": { + "description": "File source and destination configuration.\nGo templating from form data is supported in all fields.\n", + "type": "object", + "required": [ + "srcName" + ], + "properties": { + "asTemplate": { + "description": "If true, the artifact will be copied as a template file. The file will be rendered using Go templating from \nthe form data. [Sprig functions](https://masterminds.github.io/sprig/) are available for use in the template.\n", + "type": "boolean", + "default": false + }, + "dstDir": { + "description": "The directory to copy the file to. If not set, the file will be copied to the root of the flow file directory.\nThe directory will be created if it does not exist.\n", + "type": "string", + "default": "" + }, + "dstName": { + "description": "The name of the file to copy to. If not set, the file will be copied with the same name.", + "type": "string", + "default": "" + }, + "if": { + "description": "A condition to determine if the artifact should be copied. The condition is evaluated using Go templating \nfrom the form data. If the condition is not met, the artifact will not be copied.\n[Sprig functions](https://masterminds.github.io/sprig/) are available for use in the condition.\n\nFor example, to copy the artifact only if the `name` field is set:\n```\n{{ if .name }}true{{ end }}\n```\n", + "type": "string", + "default": "" + }, + "srcDir": { + "description": "The directory to copy the file from. \nIf not set, the file will be copied from the directory of the template file.\n", + "type": "string", + "default": "" + }, + "srcName": { + "description": "The name of the file to copy.", + "type": "string" + } + } + }, + "ExecutableArgumentList": {}, + "ExecutableDirectory": { + "default": "" + }, + "ExecutableExecExecutableType": { + "description": "Standard executable type. Runs a command/file in a subprocess.", + "type": "object", + "properties": { + "args": { + "$ref": "#/definitions/ExecutableArgumentList" + }, + "cmd": { + "description": "The command to execute.\nOnly one of `cmd` or `file` must be set.\n", + "type": "string", + "default": "" + }, + "dir": { + "$ref": "#/definitions/ExecutableDirectory", + "default": "" + }, + "file": { + "description": "The file to execute.\nOnly one of `cmd` or `file` must be set.\n", + "type": "string", + "default": "" + }, + "logMode": { + "description": "The log mode to use when running the executable.\nThis can either be `hidden`, `json`, `logfmt` or `text`\n", + "type": "string", + "default": "logfmt" + }, + "params": { + "$ref": "#/definitions/ExecutableParameterList" + } + } + }, + "ExecutableParameterList": {}, + "Field": { + "description": "A field to be displayed to the user when generating a flow file from a template.", + "type": "object", + "required": [ + "key", + "prompt" + ], + "properties": { + "default": { + "description": "The default value to use if a value is not set.", + "type": "string", + "default": "" + }, + "description": { + "description": "A description of the field.", + "type": "string", + "default": "" + }, + "group": { + "description": "The group to display the field in. Fields with the same group will be displayed together.", + "type": "integer", + "default": 0 + }, + "key": { + "description": "The key to associate the data with. This is used as the key in the template data map.", + "type": "string" + }, + "prompt": { + "description": "A prompt to be displayed to the user when collecting an input value.", + "type": "string" + }, + "required": { + "description": "If true, a value must be set. If false, the default value will be used if a value is not set.", + "type": "boolean", + "default": false + }, + "validate": { + "description": "A regular expression to validate the input value against.", + "type": "string", + "default": "" + } + } + } + }, + "properties": { + "artifacts": { + "description": "A list of artifacts to be copied after generating the flow file.", + "type": "array", + "items": { + "$ref": "#/definitions/Artifact" + } + }, + "form": { + "description": "Form fields to be displayed to the user when generating a flow file from a template. \nThe form will be rendered first, and the user's input can be used to render the template.\nFor example, a form field with the key `name` can be used in the template as `{{.name}}`.\n", + "type": "array", + "default": [], + "items": { + "$ref": "#/definitions/Field" + } + }, + "postRun": { + "description": "A list of exec executables to run after generating the flow file.", + "type": "array", + "items": { + "$ref": "#/definitions/ExecutableExecExecutableType" + } + }, + "preRun": { + "description": "A list of exec executables to run before generating the flow file.", + "type": "array", + "items": { + "$ref": "#/definitions/ExecutableExecExecutableType" + } + }, + "template": { + "description": "The flow file template to generate. The template must be a valid flow file after rendering.", + "type": "string" + } + } +} \ No newline at end of file diff --git a/tests/e2e_test.go b/tests/e2e_test.go index 8ccd8a1..c85fd31 100644 --- a/tests/e2e_test.go +++ b/tests/e2e_test.go @@ -16,24 +16,16 @@ func TestE2E(t *testing.T) { RunSpecs(t, "End-to-end Test Suite") } +const PrintEnvVar = "PRINT_TEST_STDOUT" + func readFileContent(f *os.File) (string, error) { out, err := os.ReadFile(f.Name()) if err != nil { return "", err } outStr := string(out) - if truthy, _ := strconv.ParseBool(os.Getenv("PRINT_TEST_STDOUT")); truthy { + if truthy, _ := strconv.ParseBool(os.Getenv(PrintEnvVar)); truthy { fmt.Println(outStr) } return outStr, nil } - -// func writeUserInput(f *os.File, input string) error { -// if _, err := f.WriteString(input); err != nil { -// return err -// } -// if _, err := f.Seek(0, 0); err != nil { -// return err -// } -// return nil -// } diff --git a/tests/secret_cmds_e2e_test.go b/tests/secret_cmds_e2e_test.go index 43602e5..16eca50 100644 --- a/tests/secret_cmds_e2e_test.go +++ b/tests/secret_cmds_e2e_test.go @@ -75,14 +75,18 @@ var _ = Describe("vault/secrets e2e", Ordered, func() { }) }) - // TODO: Get e2e tests with stdin working - this will require some updates in tuikit to handle stdin overrides - // When("deleting a secret (flow remove secret)", func() { - // It("should remove the secret from the vault", func() { - // Eventually(run.Run(ctx, "remove", "secret", "my-secret")).WithTimeout(3 * time.Second).Should(Succeed()) - // Expect(writeUserInput(ctx.StdIn(), "y\n")).To(Succeed()) - // out, err := readFileContent(ctx.StdOut()) - // Expect(err).NotTo(HaveOccurred()) - // Expect(out).To(ContainSubstring("Secret my-secret removed from vault")) - // }) - // }) + When("deleting a secret (flow remove secret)", func() { + It("should remove the secret from the vault", func() { + reader, writer, err := os.Pipe() + Expect(err).NotTo(HaveOccurred()) + _, err = writer.Write([]byte("yes\n")) + Expect(err).ToNot(HaveOccurred()) + + ctx.SetIO(reader, ctx.StdOut()) + Expect(run.Run(ctx, "remove", "secret", "my-secret")).To(Succeed()) + out, err := readFileContent(ctx.StdOut()) + Expect(err).NotTo(HaveOccurred()) + Expect(out).To(ContainSubstring("Secret my-secret removed from vault")) + }) + }) }) diff --git a/tests/template_cmds_e2e_test.go b/tests/template_cmds_e2e_test.go new file mode 100644 index 0000000..3191f2f --- /dev/null +++ b/tests/template_cmds_e2e_test.go @@ -0,0 +1,166 @@ +package tests_test + +import ( + stdCtx "context" + "fmt" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/jahvon/flow/internal/context" + "github.com/jahvon/flow/tests/utils" + "github.com/jahvon/flow/types/executable" +) + +var _ = Describe("flowfile template commands e2e", Ordered, func() { + var ( + ctx *context.Context + run *utils.CommandRunner + template *executable.Template + expectedFlowFile *executable.FlowFile + ) + + BeforeAll(func() { + ctx = utils.NewContext(stdCtx.Background(), GinkgoT()) + run = utils.NewE2ECommandRunner() + workDir, err := os.MkdirTemp("", "flowfile-template-e2e") + Expect(err).NotTo(HaveOccurred()) + tmpl := executable.FlowFile{ + Namespace: "test", + Description: "Template test flowfile", + Tags: []string{"test"}, + Executables: []*executable.Executable{ + { + Verb: "exec", + Name: "{{ .Name }}", + Exec: &executable.ExecExecutableType{Cmd: fmt.Sprintf("echo '%s'", "{{ .Msg }}")}}, + }, + } + tmplStr, err := tmpl.YAML() + Expect(err).NotTo(HaveOccurred()) + template = &executable.Template{ + Template: tmplStr, + Form: executable.FormFields{ + &executable.Field{ + Key: "Name", + Prompt: "Enter a name", + Default: "test", + }, + &executable.Field{ + Key: "Msg", + Prompt: "Enter a message", + Default: "Hello, world!", + }, + }, + Artifacts: []executable.Artifact{ + {SrcName: "artifact1"}, + {SrcName: "artifact2", DstName: "artifact2-renamed"}, + }, + PostRun: []executable.TemplatePostRunElem{ + { + Cmd: "touch {{ .Name }}", + }, + }, + } + template.SetContext("e2e", filepath.Join(workDir, "flowfile.tmpl.flow")) + data, err := template.YAML() + Expect(err).NotTo(HaveOccurred()) + Expect(os.WriteFile(filepath.Join(workDir, "flowfile.tmpl.flow"), []byte(data), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(workDir, "artifact1"), []byte("artifact1"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(workDir, "artifact2"), []byte("artifact2"), 0644)).To(Succeed()) + + expectedFlowFile = &executable.FlowFile{ + Namespace: "test", + Description: "Template test flowfile", + Tags: []string{"test"}, + Executables: []*executable.Executable{ + { + Verb: "exec", + Name: "test", + Exec: &executable.ExecExecutableType{Cmd: "echo 'Hello, world!'"}}, + }, + } + expectedFlowFile.SetContext( + ctx.CurrentWorkspace.AssignedName(), + ctx.CurrentWorkspace.Location(), + filepath.Join(workDir, "flowfile.flow"), + ) + }) + + BeforeEach(func() { + utils.ResetTestContext(ctx, GinkgoT()) + }) + + AfterEach(func() { + ctx.Finalize() + }) + + When("registering a new template (flow set template)", func() { + It("should complete successfully", func() { + stdOut := ctx.StdOut() + err := run.Run(ctx, "set", "template", "--verbosity", "-1", template.Name(), template.Location()) + Expect(err).ToNot(HaveOccurred()) + out, err := readFileContent(stdOut) + Expect(err).NotTo(HaveOccurred()) + Expect(out).To(ContainSubstring(fmt.Sprintf("Template %s set", template.Name()))) + }) + }) + + When("getting a registered template (flow get template)", func() { + It("should return the template", func() { + stdOut := ctx.StdOut() + err := run.Run(ctx, "get", "template", "-t", template.Name(), "-o", "yaml") + Expect(err).ToNot(HaveOccurred()) + out, err := readFileContent(stdOut) + Expect(err).NotTo(HaveOccurred()) + str, err := template.YAML() + Expect(err).NotTo(HaveOccurred()) + Expect(out).To(ContainSubstring(str)) + }) + }) + + When("getting a template by path (flow get template)", func() { + It("should return the template", func() { + stdOut := ctx.StdOut() + err := run.Run(ctx, "get", "template", "-f", template.Location(), "-o", "yaml") + Expect(err).ToNot(HaveOccurred()) + out, err := readFileContent(stdOut) + Expect(err).NotTo(HaveOccurred()) + str, err := template.YAML() + Expect(err).NotTo(HaveOccurred()) + Expect(out).To(ContainSubstring(str)) + }) + }) + + When("Listing all registered templates (flow list templates)", func() { + It("should return the list of templates", func() { + stdOut := ctx.StdOut() + Expect(run.Run(ctx, "list", "templates", "-o", "yaml")).To(Succeed()) + out, err := readFileContent(stdOut) + Expect(err).NotTo(HaveOccurred()) + // tabs may be present so instead of checking for exact match, we check for length + str, err := template.YAML() + Expect(err).NotTo(HaveOccurred()) + Expect(len(out)).To(BeNumerically(">", len(str))) + }) + }) + + When("Rendering a template (flow init template)", func() { + It("should process the template options and render the flowfile", func() { + name := "test" + outputDir := filepath.Join(ctx.CurrentWorkspace.Location(), "output") + reader, writer, err := os.Pipe() + Expect(err).NotTo(HaveOccurred()) + _, err = writer.Write([]byte("test\nhello\n")) + Expect(err).ToNot(HaveOccurred()) + + ctx.SetIO(reader, ctx.StdOut()) + Expect(run.Run(ctx, "init", "template", name, "-t", template.Name(), "-o", outputDir)).To(Succeed()) + out, err := readFileContent(ctx.StdOut()) + Expect(err).NotTo(HaveOccurred()) + Expect(out).To(ContainSubstring(fmt.Sprintf("Template '%s' rendered successfully", name))) + }) + }) +}) diff --git a/tests/utils/runner.go b/tests/utils/runner.go index c32ea4d..faf28e6 100644 --- a/tests/utils/runner.go +++ b/tests/utils/runner.go @@ -1,6 +1,8 @@ package utils import ( + "fmt" + "github.com/jahvon/flow/cmd" "github.com/jahvon/flow/internal/context" ) @@ -11,11 +13,18 @@ func NewE2ECommandRunner() *CommandRunner { return &CommandRunner{} } -func (r *CommandRunner) Run(ctx *context.Context, args ...string) error { +func (r *CommandRunner) Run(ctx *context.Context, args ...string) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic occurred: %v", r) + } + }() rootCmd := cmd.NewRootCmd(ctx) rootCmd.SetArgs(args) - if err := cmd.Execute(ctx, rootCmd); err != nil { + rootCmd.SetIn(ctx.StdIn()) + rootCmd.SetOut(ctx.StdOut()) + if err = cmd.Execute(ctx, rootCmd); err != nil { return err } - return nil + return } diff --git a/tools/docsgen/json.go b/tools/docsgen/json.go index e4483c3..8ab255d 100644 --- a/tools/docsgen/json.go +++ b/tools/docsgen/json.go @@ -19,7 +19,12 @@ const ( func generateJSONSchemas() { sm := schema.RegisteredSchemaMap() for fn, s := range sm { - if slices.Contains(TopLevelPages, fn.Title()) { + if slices.Contains(TopLevelPages, fn.Title()) { //nolint:nestif + if fn.Title() == "Template" { + // TODO: fix schema gem bug where the common executable definitions used by the flowfile + // and template schemas are non-deterministically generated + continue + } updateFileID(s, fn) for key, value := range s.Properties { if !value.Ext.IsExported() { diff --git a/tools/docsgen/main.go b/tools/docsgen/main.go index 77fcf3f..e236c24 100644 --- a/tools/docsgen/main.go +++ b/tools/docsgen/main.go @@ -24,7 +24,7 @@ var ( ) func main() { - fmt.Println("Generating CLI docs...") + fmt.Println("generating CLI docs...") ctx := context.NewContext(stdCtx.Background(), os.Stdin, os.Stdout) defer ctx.Finalize() @@ -38,10 +38,10 @@ func main() { panic(err) } - fmt.Println("Generating markdown docs...") + fmt.Println("generating markdown docs...") generateMarkdownDocs() - fmt.Println("Generating schema files...") + fmt.Println("generating schema files...") generateJSONSchemas() } diff --git a/tools/docsgen/markdown.go b/tools/docsgen/markdown.go index f9e20ef..de6ecd9 100644 --- a/tools/docsgen/markdown.go +++ b/tools/docsgen/markdown.go @@ -23,6 +23,7 @@ var ( schema.FlowfileDefinitionTitle, schema.ConfigDefinitionTitle, schema.WorkspaceDefinitionTitle, + schema.TemplateDefinitionTitle, } ) diff --git a/tools/docsgen/schema/consts.go b/tools/docsgen/schema/consts.go index 2f92c32..3cb001d 100644 --- a/tools/docsgen/schema/consts.go +++ b/tools/docsgen/schema/consts.go @@ -8,12 +8,14 @@ const ( ConfigSchema FileName = "config/schema.yaml" ExecutableSchema FileName = "executable/executable_schema.yaml" FlowfileSchema FileName = "executable/flowfile_schema.yaml" + TemplateSchema FileName = "executable/template_schema.yaml" CommonDefinitionTitle = "Common" WorkspaceDefinitionTitle = "Workspace" ConfigDefinitionTitle = "Config" ExecutableDefinitionTitle = "Executable" FlowfileDefinitionTitle = "FlowFile" + TemplateDefinitionTitle = "Template" ) var ( @@ -23,5 +25,6 @@ var ( ConfigSchema, ExecutableSchema, FlowfileSchema, + TemplateSchema, } ) diff --git a/tools/docsgen/schema/schema.go b/tools/docsgen/schema/schema.go index bc4ea56..63ba09b 100644 --- a/tools/docsgen/schema/schema.go +++ b/tools/docsgen/schema/schema.go @@ -44,22 +44,44 @@ func (e SchemaExt) IsExported() bool { return e.Identifier == "" || (e.Identifier[0] >= 'A' && e.Identifier[0] <= 'Z') } -//nolint:gocognit,nestif +//nolint:all func MergeSchemas(dst, src *JSONSchema, dstFile FileName, schemaMap map[FileName]*JSONSchema) { if src.Items != nil { MergeSchemas(dst, src.Items, dstFile, schemaMap) } + for _, value := range src.Definitions { + if value.Ref.IsRoot() { + continue + } + MergeSchemas(dst, value, dstFile, schemaMap) + } + for key, value := range src.Properties { + if !value.Ext.IsExported() { + delete(src.Properties, FieldKey(value.Ext.Identifier)) + continue + } + MergeSchemas(dst, value, dstFile, schemaMap) + src.Properties[key].Ref = convertToLocalSchemaRef(value.Ref, dstFile) + } var match *JSONSchema switch { case src.Ref.String() == "": // the source is not a reference + if src.Items != nil { + MergeSchemas(dst, src.Items, dstFile, schemaMap) + } for key, value := range src.Properties { if !value.Ext.IsExported() { delete(src.Properties, key) continue } MergeSchemas(dst, value, dstFile, schemaMap) + src.Properties[key].Ref = convertToLocalSchemaRef(value.Ref, dstFile) + } + for i, value := range src.Definitions { + MergeSchemas(dst, value, dstFile, schemaMap) + src.Definitions[i].Ref = convertToLocalSchemaRef(value.Ref, dstFile) } case src.Ref.ExternalFile() == "" && dst.Definitions[src.Ref.Key()] == nil: // the ref is a local definition but doesn't exist in the destination schema @@ -116,15 +138,13 @@ func MergeSchemas(dst, src *JSONSchema, dstFile FileName, schemaMap map[FileName return } - if src.Ref.String() != "" { - src.Ref = convertToLocalSchemaRef(src.Ref, dstFile) - } - for key, value := range match.Definitions { - MergeSchemas(dst, value, dstFile, schemaMap) - delete(match.Definitions, key) - } + src.Ref = convertToLocalSchemaRef(src.Ref, dstFile) if _, found := dst.Definitions[src.Ref.Key()]; !found { - dst.Definitions[src.Ref.Key()] = match + var d JSONSchema //nolint:gosimple + d = *match + d.Schema = "" + d.Definitions = nil + dst.Definitions[src.Ref.Key()] = &d } for key, value := range match.Properties { if !value.Ext.IsExported() { @@ -133,6 +153,9 @@ func MergeSchemas(dst, src *JSONSchema, dstFile FileName, schemaMap map[FileName } MergeSchemas(dst, value, dstFile, schemaMap) } + if match.Items != nil { + MergeSchemas(dst, match.Items, dstFile, schemaMap) + } for _, value := range match.Definitions { MergeSchemas(dst, value, dstFile, schemaMap) } diff --git a/tools/docsgen/schema/schema_test.go b/tools/docsgen/schema/schema_test.go index f57f609..17e12dd 100644 --- a/tools/docsgen/schema/schema_test.go +++ b/tools/docsgen/schema/schema_test.go @@ -30,6 +30,7 @@ var _ = Describe("MergeSchemas", func() { "prop1": {Ref: "#/definitions/MyEnum"}, "prop2": {Ref: "../alfa/schema.yaml#/definitions/MyString"}, "prop3": {Ref: "../charlie/schema.yaml#/"}, + "prop4": {Ref: "../bravo/other_schema.yaml#/definitions/MyString"}, }, } //nolint:exhaustive @@ -42,6 +43,11 @@ var _ = Describe("MergeSchemas", func() { Required: []string{"MyString"}, }, "bravo/schema.yaml": dst, + "bravo/other_schema.yaml": { + Definitions: map[schema.FieldKey]*schema.JSONSchema{ + "MyString": {Type: "string"}, + }, + }, "charlie/schema.yaml": { Definitions: map[schema.FieldKey]*schema.JSONSchema{ "MyInt": {Type: "integer"}, @@ -95,4 +101,18 @@ var _ = Describe("MergeSchemas", func() { Expect(src.Ref).To(Equal(schema.Ref("#/definitions/Charlie"))) }) }) + + Context("when the source is an external ref with a path to a definition", func() { + var src *schema.JSONSchema + BeforeEach(func() { + src = dst.Properties["prop4"] + }) + + It("should merge the external schema", func() { + schema.MergeSchemas(dst, src, dstFile, schemaMap) + Expect(dst.Definitions).To(HaveKey(schema.FieldKey("MyEnum"))) + Expect(dst.Definitions).To(HaveKey(schema.FieldKey("OtherMyString"))) + Expect(src.Ref).To(Equal(schema.Ref("#/definitions/OtherMyString"))) + }) + }) }) diff --git a/tools/docsgen/schema/types.go b/tools/docsgen/schema/types.go index f71f0d9..21df853 100644 --- a/tools/docsgen/schema/types.go +++ b/tools/docsgen/schema/types.go @@ -50,7 +50,7 @@ func (s Ref) ExternalFile() FileName { fmt.Println("invalid schema ref", s.String()) return "" } - // remove leading './' from file name if present to avoid relative paths + // remove leading '.' and `/` from file name if present to avoid relative paths return FileName(strings.Trim(parts[0], "./")) } @@ -62,6 +62,25 @@ func (s Ref) DefinitionPath() string { return fmt.Sprintf("#/definitions/%s", s.Key()) } +func (s Ref) IsRoot() bool { + if s.String() == "" { + return false + } + + var def string + if strings.HasPrefix(s.String(), "#") { + def = strings.Trim(s.String(), "./") + } else { + parts := strings.Split(s.String(), "#") + if len(parts) != 2 { + fmt.Println("invalid schema ref", s.String()) + return false + } + def = parts[1] + } + return def == "/" +} + func (s Ref) Key() FieldKey { if s.String() == "" { return "" @@ -84,17 +103,19 @@ func (s Ref) Key() FieldKey { return WorkspaceDefinitionTitle case strings.Contains(s.String(), ConfigSchema.String()): return ConfigDefinitionTitle - case strings.Contains(s.String(), ExecutableSchema.String()): - return ExecutableDefinitionTitle case strings.Contains(s.String(), FlowfileSchema.String()): return FlowfileDefinitionTitle case strings.Contains(s.String(), CommonSchema.String()): return CommonDefinitionTitle + case strings.Contains(s.String(), TemplateSchema.String()): + return TemplateDefinitionTitle + case strings.Contains(s.String(), ExecutableSchema.String()): + return ExecutableDefinitionTitle } fmt.Println("unknown schema ref; defaulting to the file's title", s.String()) return FieldKey(s.ExternalFile().Title()) } - parts := strings.Split(def, "/") + parts := strings.Split(strings.Trim(def, "./"), "/") if len(parts) < 2 { fmt.Println("invalid schema ref", s.String()) return "" diff --git a/types/executable/executable.go b/types/executable/executable.go index 7c5bf67..efc790f 100644 --- a/types/executable/executable.go +++ b/types/executable/executable.go @@ -331,10 +331,10 @@ func (l ExecutableList) JSON() (string, error) { return string(jsonBytes), nil } -func (l ExecutableList) Items() []*types.CollectionItem { - items := make([]*types.CollectionItem, 0) +func (l ExecutableList) Items() []*types.EntityInfo { + items := make([]*types.EntityInfo, 0) for _, exec := range l { - item := &types.CollectionItem{ + item := &types.EntityInfo{ Header: exec.Ref().String(), Desc: exec.Description, ID: exec.Ref().String(), diff --git a/types/executable/executable_md.go b/types/executable/executable_md.go index 80f3d73..6e95809 100644 --- a/types/executable/executable_md.go +++ b/types/executable/executable_md.go @@ -2,6 +2,7 @@ package executable import ( "fmt" + "path/filepath" "strings" ) @@ -257,6 +258,9 @@ func parallelExecMarkdown(e *ExecutableEnvironment, p *ParallelExecutableType) s } func execEnvTable(env *ExecutableEnvironment) string { + if env == nil { + return "" + } var table string if len(env.Params) > 0 { table += "### Parameters\n" @@ -297,3 +301,61 @@ func execEnvTable(env *ExecutableEnvironment) string { } return table } + +func templateMarkdown(t *Template) string { + mkdwn := fmt.Sprintf("# [Template] %s\n", t.Name()) + mkdwn += templateFormMarkdown(t) + mkdwn += templateArtifactsMarkdown(t) + if len(t.PreRun) > 0 { + mkdwn += "## Pre-Run\n" + for _, e := range t.PreRun { + exec := ExecExecutableType(e) + mkdwn += shellExecMarkdown(nil, &exec) + } + } + if len(t.PostRun) > 0 { + mkdwn += "## Post-Run\n" + for _, e := range t.PostRun { + exec := ExecExecutableType(e) + mkdwn += shellExecMarkdown(nil, &exec) + } + } + mkdwn += fmt.Sprintf("## Flow File\n```yaml\n%s\n```\n", t.Template) + mkdwn += fmt.Sprintf("\n\n_Template can be found in_ [%s](%s)\n", t.Name(), t.Location()) + return mkdwn +} + +func templateArtifactsMarkdown(t *Template) string { + if len(t.Artifacts) == 0 { + return "" + } + mkdwn := "## Artifacts\n" + for _, a := range t.Artifacts { + mkdwn += fmt.Sprintf("- Source: `%s`\n", filepath.Join(a.SrcDir, a.SrcName)) + if a.DstDir != "" { + mkdwn += fmt.Sprintf(" Destination: `%s`\n", filepath.Join(a.DstDir, a.DstName)) + } else if a.DstName != "" { + mkdwn += fmt.Sprintf(" Destination: `%s`\n", a.DstName) + } + if a.If != "" { + mkdwn += fmt.Sprintf(" Conditional: `%s`\n", a.If) + } + mkdwn += fmt.Sprintf(" Rendered as a template: %t\n", a.AsTemplate) + } + return mkdwn +} + +func templateFormMarkdown(t *Template) string { + if len(t.Form) == 0 { + return "" + } + mkdwn := "## Form Fields\n" + mkdwn += "| Field | Prompt | Description | Default Value | Required |\n" + for _, f := range t.Form { + mkdwn += fmt.Sprintf( + "| %s | %s | %s | %s | %t |\n", + f.Key, f.Prompt, f.Description, f.Default, f.Required, + ) + } + return mkdwn +} diff --git a/types/executable/flowfile.go b/types/executable/flowfile.go index fc6f6e2..7787d65 100644 --- a/types/executable/flowfile.go +++ b/types/executable/flowfile.go @@ -1,14 +1,17 @@ package executable import ( - "errors" "fmt" + "gopkg.in/yaml.v3" + "github.com/jahvon/flow/types/common" ) //go:generate go run github.com/atombender/go-jsonschema@v0.16.0 -et --only-models -p executable -o flowfile.gen.go flowfile_schema.yaml +const FlowFileExt = ".flow" + type FlowFileList []*FlowFile func (f *FlowFile) SetContext(workspaceName, workspacePath, configPath string) { @@ -41,6 +44,14 @@ func (f *FlowFile) ConfigPath() string { return f.configPath } +func (f *FlowFile) YAML() (string, error) { + yamlBytes, err := yaml.Marshal(f) + if err != nil { + return "", fmt.Errorf("failed to marshal flowfile - %w", err) + } + return string(yamlBytes), nil +} + func (l *FlowFileList) FilterByNamespace(namespace string) FlowFileList { filteredCfgs := make(FlowFileList, 0) for _, cfg := range *l { @@ -61,104 +72,3 @@ func (l *FlowFileList) FilterByTag(tag string) FlowFileList { } return filteredCfgs } - -type TemplateDataEntry struct { - // The key to associate the data with. This is used as the key in the template data map. - Key string `yaml:"key"` - // A prompt to be displayed to the user when collecting an input value. - Prompt string `yaml:"prompt"` - // The default value to use if a value is not set. - Default string `yaml:"default"` - // If true, a value must be set. If false, the default value will be used if a value is not set. - Required bool `yaml:"required"` - - value string -} - -func (t *TemplateDataEntry) Set(value string) { - t.value = value -} - -func (t *TemplateDataEntry) Value() string { - if t.value == "" { - return t.Default - } - return t.value -} - -func (t *TemplateDataEntry) Validate() error { - if t.Prompt == "" { - return errors.New("must specify prompt for template data") - } - if t.Key == "" { - return errors.New("must specify key for template data") - } - return nil -} - -func (t *TemplateDataEntry) ValidateValue() error { - if t.value == "" && t.Required { - return fmt.Errorf("required template data not set") - } - return nil -} - -type TemplateData []TemplateDataEntry - -func (t *TemplateData) Set(key, value string) { - for i, entry := range *t { - if entry.Key == key { - (*t)[i].Set(value) - return - } - } -} - -func (t *TemplateData) MapInterface() map[string]interface{} { - data := map[string]interface{}{} - for _, entry := range *t { - data[entry.Key] = entry.Value() - } - return data -} - -func (t *TemplateData) Validate() error { - for _, entry := range *t { - if err := entry.Validate(); err != nil { - return err - } - } - return nil -} - -func (t *TemplateData) ValidateValues() error { - for _, entry := range *t { - if err := entry.ValidateValue(); err != nil { - return err - } - } - return nil -} - -type FlowFileTemplate struct { - // A list of template data to be used when rendering the flow executable config file. - Data TemplateData `yaml:"data"` - // A list of files to include when copying the template in a new location. The files are copied as-is. - Artifacts []string `yaml:"artifacts,omitempty"` - - *FlowFile `yaml:",inline"` - - location string -} - -func (t *FlowFileTemplate) SetContext(location string) { - t.location = location -} - -func (t *FlowFileTemplate) Location() string { - return t.location -} - -func (t *FlowFileTemplate) Validate() error { - return t.Data.Validate() -} diff --git a/types/executable/template.gen.go b/types/executable/template.gen.go new file mode 100644 index 0000000..ffe441d --- /dev/null +++ b/types/executable/template.gen.go @@ -0,0 +1,112 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package executable + +// File source and destination configuration. +// Go templating from form data is supported in all fields. +type Artifact struct { + // If true, the artifact will be copied as a template file. The file will be + // rendered using Go templating from + // the form data. [Sprig functions](https://masterminds.github.io/sprig/) are + // available for use in the template. + // + AsTemplate bool `json:"asTemplate,omitempty" yaml:"asTemplate,omitempty" mapstructure:"asTemplate,omitempty"` + + // The directory to copy the file to. If not set, the file will be copied to the + // root of the flow file directory. + // The directory will be created if it does not exist. + // + DstDir string `json:"dstDir,omitempty" yaml:"dstDir,omitempty" mapstructure:"dstDir,omitempty"` + + // The name of the file to copy to. If not set, the file will be copied with the + // same name. + DstName string `json:"dstName,omitempty" yaml:"dstName,omitempty" mapstructure:"dstName,omitempty"` + + // A condition to determine if the artifact should be copied. The condition is + // evaluated using Go templating + // from the form data. If the condition is not met, the artifact will not be + // copied. + // [Sprig functions](https://masterminds.github.io/sprig/) are available for use + // in the condition. + // + // For example, to copy the artifact only if the `name` field is set: + // ``` + // {{ if .name }}true{{ end }} + // ``` + // + If string `json:"if,omitempty" yaml:"if,omitempty" mapstructure:"if,omitempty"` + + // The directory to copy the file from. + // If not set, the file will be copied from the directory of the template file. + // + SrcDir string `json:"srcDir,omitempty" yaml:"srcDir,omitempty" mapstructure:"srcDir,omitempty"` + + // The name of the file to copy. + SrcName string `json:"srcName" yaml:"srcName" mapstructure:"srcName"` +} + +// A field to be displayed to the user when generating a flow file from a template. +type Field struct { + // The default value to use if a value is not set. + Default string `json:"default,omitempty" yaml:"default,omitempty" mapstructure:"default,omitempty"` + + // A description of the field. + Description string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"` + + // The group to display the field in. Fields with the same group will be displayed + // together. + Group int `json:"group,omitempty" yaml:"group,omitempty" mapstructure:"group,omitempty"` + + // The key to associate the data with. This is used as the key in the template + // data map. + Key string `json:"key" yaml:"key" mapstructure:"key"` + + // A prompt to be displayed to the user when collecting an input value. + Prompt string `json:"prompt" yaml:"prompt" mapstructure:"prompt"` + + // If true, a value must be set. If false, the default value will be used if a + // value is not set. + Required bool `json:"required,omitempty" yaml:"required,omitempty" mapstructure:"required,omitempty"` + + // A regular expression to validate the input value against. + Validate string `json:"validate,omitempty" yaml:"validate,omitempty" mapstructure:"validate,omitempty"` + + // value corresponds to the JSON schema field "value". + value *string `json:"value,omitempty" yaml:"value,omitempty" mapstructure:"value,omitempty"` +} + +// Configuration for a flowfile template; templates can be used to generate flow +// files. +type Template struct { + // A list of artifacts to be copied after generating the flow file. + Artifacts []Artifact `json:"artifacts,omitempty" yaml:"artifacts,omitempty" mapstructure:"artifacts,omitempty"` + + // assignedName corresponds to the JSON schema field "assignedName". + assignedName *string `json:"assignedName,omitempty" yaml:"assignedName,omitempty" mapstructure:"assignedName,omitempty"` + + // Form fields to be displayed to the user when generating a flow file from a + // template. + // The form will be rendered first, and the user's input can be used to render the + // template. + // For example, a form field with the key `name` can be used in the template as + // `{{.name}}`. + // + Form FormFields `json:"form,omitempty" yaml:"form,omitempty" mapstructure:"form,omitempty"` + + // location corresponds to the JSON schema field "location". + location *string `json:"location,omitempty" yaml:"location,omitempty" mapstructure:"location,omitempty"` + + // A list of exec executables to run after generating the flow file. + PostRun []TemplatePostRunElem `json:"postRun,omitempty" yaml:"postRun,omitempty" mapstructure:"postRun,omitempty"` + + // A list of exec executables to run before generating the flow file. + PreRun []TemplatePreRunElem `json:"preRun,omitempty" yaml:"preRun,omitempty" mapstructure:"preRun,omitempty"` + + // The flow file template to generate. The template must be a valid flow file + // after rendering. + Template string `json:"template" yaml:"template" mapstructure:"template"` +} + +type TemplatePostRunElem ExecExecutableType + +type TemplatePreRunElem ExecExecutableType diff --git a/types/executable/template.go b/types/executable/template.go new file mode 100644 index 0000000..10d8349 --- /dev/null +++ b/types/executable/template.go @@ -0,0 +1,166 @@ +package executable + +import ( + "encoding/json" + "errors" + "fmt" + "path/filepath" + "strings" + + "github.com/jahvon/tuikit/types" + "gopkg.in/yaml.v3" +) + +//go:generate go run github.com/atombender/go-jsonschema@v0.16.0 -et --only-models -p executable -o template.gen.go template_schema.yaml + +const FlowFileTemplateExt = ".flow.tmpl" + +type TemplateList []*Template + +func (f *Field) Set(value string) { + f.value = &value +} + +func (f *Field) Value() string { + if f.value == nil { + return f.Default + } + return *f.value +} + +func (f *Field) ValidateConfig() error { + if f.Key == "" { + return errors.New("field is missing a key") + } + if f.Prompt == "" && f.Description == "" { + return fmt.Errorf("field %s is missing a prompt", f.Key) + } + return nil +} + +type FormFields []*Field + +func (f FormFields) Set(key, value string) { + for i, entry := range f { + if entry.Key == key { + f[i].Set(value) + return + } + } +} + +func (f FormFields) ValueMap() map[string]string { + data := map[string]string{} + for _, entry := range f { + data[entry.Key] = entry.Value() + } + return data +} + +func (f FormFields) Validate() error { + for _, field := range f { + if err := field.ValidateConfig(); err != nil { + return err + } + } + return nil +} + +func (t *Template) SetContext(name, location string) { + if t == nil { + return + } + t.location = &location + t.assignedName = &name + if name == "" { + fn := filepath.Base(location) + switch { + case strings.HasSuffix(fn, FlowFileTemplateExt): + fn = strings.TrimSuffix(fn, FlowFileTemplateExt) + case strings.HasSuffix(fn, FlowFileExt): + fn = strings.TrimSuffix(fn, FlowFileExt) + default: + fn = strings.TrimSuffix(fn, filepath.Ext(fn)) + } + t.assignedName = &fn + } +} + +func (t *Template) Location() string { + if t.location == nil { + return "" + } + return *t.location +} + +func (t *Template) Name() string { + return *t.assignedName +} + +func (t *Template) Validate() error { + return t.Form.Validate() +} + +func (t *Template) YAML() (string, error) { + yamlBytes, err := yaml.Marshal(t) + if err != nil { + return "", fmt.Errorf("failed to marshal template - %w", err) + } + return string(yamlBytes), nil +} + +func (t *Template) JSON() (string, error) { + jsonBytes, err := json.MarshalIndent(t, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal template - %w", err) + } + return string(jsonBytes), nil +} + +func (t *Template) Markdown() string { + return templateMarkdown(t) +} + +func (t TemplateList) YAML() (string, error) { + yamlBytes, err := yaml.Marshal(t) + if err != nil { + return "", fmt.Errorf("failed to marshal template list - %w", err) + } + return string(yamlBytes), nil +} + +func (t TemplateList) JSON() (string, error) { + jsonBytes, err := json.MarshalIndent(t, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal template list - %w", err) + } + return string(jsonBytes), nil +} + +func (t TemplateList) Singular() string { + return "template" +} + +func (t TemplateList) Plural() string { + return "templates" +} + +func (t TemplateList) Items() []*types.EntityInfo { + items := make([]*types.EntityInfo, len(t)) + for i, template := range t { + items[i] = &types.EntityInfo{ + ID: template.Name(), + Header: template.Name(), + } + } + return items +} + +func (t TemplateList) Find(name string) *Template { + for _, template := range t { + if template.Name() == name { + return template + } + } + return nil +} diff --git a/types/executable/template_schema.yaml b/types/executable/template_schema.yaml new file mode 100644 index 0000000..e8cd919 --- /dev/null +++ b/types/executable/template_schema.yaml @@ -0,0 +1,136 @@ +$schema: "http://json-schema.org/draft-07/schema#" +$id: "https://raw.githubusercontent.com/jahvon/flow/HEAD/types/executable/template_schema.yaml" + +title: Template +description: Configuration for a flowfile template; templates can be used to generate flow files. + +definitions: + Artifact: + type: object + description: | + File source and destination configuration. + Go templating from form data is supported in all fields. + required: + - srcName + properties: + if: + type: string + description: | + A condition to determine if the artifact should be copied. The condition is evaluated using Go templating + from the form data. If the condition is not met, the artifact will not be copied. + [Sprig functions](https://masterminds.github.io/sprig/) are available for use in the condition. + + For example, to copy the artifact only if the `name` field is set: + ``` + {{ if .name }}true{{ end }} + ``` + default: "" + asTemplate: + type: boolean + default: false + description: | + If true, the artifact will be copied as a template file. The file will be rendered using Go templating from + the form data. [Sprig functions](https://masterminds.github.io/sprig/) are available for use in the template. + srcDir: + type: string + default: "" + description: | + The directory to copy the file from. + If not set, the file will be copied from the directory of the template file. + srcName: + type: string + description: The name of the file to copy. + dstDir: + type: string + description: | + The directory to copy the file to. If not set, the file will be copied to the root of the flow file directory. + The directory will be created if it does not exist. + default: "" + dstName: + type: string + default: "" + description: The name of the file to copy to. If not set, the file will be copied with the same name. + + Field: + type: object + description: A field to be displayed to the user when generating a flow file from a template. + required: + - key + - prompt + properties: + key: + type: string + description: The key to associate the data with. This is used as the key in the template data map. + prompt: + type: string + description: A prompt to be displayed to the user when collecting an input value. + group: + type: integer + description: The group to display the field in. Fields with the same group will be displayed together. + default: 0 + description: + type: string + default: "" + description: A description of the field. + default: + type: string + default: "" + description: The default value to use if a value is not set. + required: + type: boolean + description: If true, a value must be set. If false, the default value will be used if a value is not set. + default: false + validate: + type: string + default: "" + description: A regular expression to validate the input value against. + value: + type: string + goJSONSchema: + identifier: value + +type: object +required: + - template +properties: + artifacts: + type: array + description: A list of artifacts to be copied after generating the flow file. + items: + $ref: '#/definitions/Artifact' + preRun: + type: array + description: A list of exec executables to run before generating the flow file. + items: + $ref: '../executable/executable_schema.yaml#/definitions/ExecExecutableType' + goJSONSchema: + type: "ExecExecutableType" + postRun: + type: array + description: A list of exec executables to run after generating the flow file. + items: + $ref: '../executable/executable_schema.yaml#/definitions/ExecExecutableType' + goJSONSchema: + type: "ExecExecutableType" + form: + type: array + default: [] + description: | + Form fields to be displayed to the user when generating a flow file from a template. + The form will be rendered first, and the user's input can be used to render the template. + For example, a form field with the key `name` can be used in the template as `{{.name}}`. + items: + $ref: '#/definitions/Field' + goJSONSchema: + type: "FormFields" + template: + type: string + description: The flow file template to generate. The template must be a valid flow file after rendering. + location: + type: string + goJSONSchema: + identifier: location + assignedName: + type: string + goJSONSchema: + identifier: assignedName diff --git a/types/executable/template_test.go b/types/executable/template_test.go new file mode 100644 index 0000000..064286f --- /dev/null +++ b/types/executable/template_test.go @@ -0,0 +1,140 @@ +package executable_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/jahvon/flow/types/executable" +) + +var _ = Describe("Template", func() { + var ( + template *executable.Template + ) + + BeforeEach(func() { + template = &executable.Template{ + Artifacts: []executable.Artifact{ + {SrcName: "main.go"}, + {SrcName: "go.mod"}, + }, + Form: executable.FormFields{ + &executable.Field{ + Key: "testKey", + Prompt: "testPrompt", + Default: "testDefault", + }, + }, + Template: `namespace: test +description: {{ .testKey }} +tags: [test] +`, + } + template.SetContext("flowfile", "flowfile.flow.tmpl") + }) + + Describe("SetContext", func() { + It("should set the context correctly", func() { + template.SetContext("newName", "new/flowfile.flow.tmpl") + Expect(template.Name()).To(Equal("newName")) + Expect(template.Location()).To(Equal("new/flowfile.flow.tmpl")) + }) + + It("should set the name from the location when empty", func() { + template.SetContext("", "new/flowfile.flow.tmpl") + Expect(template.Name()).To(Equal("flowfile")) + Expect(template.Location()).To(Equal("new/flowfile.flow.tmpl")) + }) + }) + + Describe("Validate", func() { + It("should validate the form config correctly", func() { + Expect(template.Validate()).To(Succeed()) + }) + + It("should error when there is an invalid form field", func() { + template.Form = append(template.Form, &executable.Field{Description: "i have missing fields"}) + Expect(template.Validate()).To(HaveOccurred()) + }) + }) + + Describe("Format Methods", func() { + It("JSON should return the JSON representation of the template", func() { + str, err := template.JSON() + Expect(err).NotTo(HaveOccurred()) + Expect(str).ToNot(BeEmpty()) + }) + It("YAML should return the YAML representation of the template", func() { + str, err := template.YAML() + Expect(err).NotTo(HaveOccurred()) + Expect(str).ToNot(BeEmpty()) + }) + It("Markdown should return the Markdown representation of the template", func() { + str := template.Markdown() + Expect(str).ToNot(BeEmpty()) + }) + }) +}) + +var _ = Describe("TemplateList", func() { + var ( + templates executable.TemplateList + ) + + BeforeEach(func() { + templates = []*executable.Template{ + { + Artifacts: []executable.Artifact{ + {SrcName: "main.go"}, + {SrcName: "go.mod"}, + }, + Form: executable.FormFields{ + &executable.Field{ + Key: "testKey", + Prompt: "testPrompt", + Default: "testDefault", + }, + }, + Template: `namespace: test +description: {{ .testKey }} +tags: [test] +`, + }, + { + Template: `namespace: test2 +description: test2 +tags: [test2] +`, + }, + } + templates[0].SetContext("flowfile", "flowfile.tmpl.flow") + templates[1].SetContext("flowfile2", "flowfile2.tmpl.flow") + }) + + Describe("Format Methods", func() { + It("JSON should return the JSON representation of the templates", func() { + str, err := templates.JSON() + Expect(err).NotTo(HaveOccurred()) + Expect(str).ToNot(BeEmpty()) + }) + It("YAML should return the YAML representation of the templates", func() { + str, err := templates.YAML() + Expect(err).NotTo(HaveOccurred()) + Expect(str).ToNot(BeEmpty()) + }) + It("Items should return the tuikit item representation of the templates", func() { + items := templates.Items() + Expect(items).To(HaveLen(2)) + }) + }) + + Describe("Find", func() { + It("should find the correct template", func() { + Expect(templates.Find("flowfile2")).ToNot(BeNil()) + }) + + It("should return nil when the template is not found", func() { + Expect(templates.Find("flowfile3")).To(BeNil()) + }) + }) +}) diff --git a/types/workspace/workspace.go b/types/workspace/workspace.go index 3ae99f4..bf2a710 100644 --- a/types/workspace/workspace.go +++ b/types/workspace/workspace.go @@ -83,8 +83,8 @@ func (l WorkspaceList) FindByName(name string) *Workspace { return nil } -func (l WorkspaceList) Items() []*types.CollectionItem { - items := make([]*types.CollectionItem, 0) +func (l WorkspaceList) Items() []*types.EntityInfo { + items := make([]*types.EntityInfo, 0) for _, ws := range l { name := ws.AssignedName() if ws.DisplayName != "" { @@ -107,7 +107,7 @@ func (l WorkspaceList) Items() []*types.CollectionItem { ws.Description = d } - item := types.CollectionItem{ + item := types.EntityInfo{ Header: name, SubHeader: location, Desc: ws.Description, diff --git a/types/workspace/workspace_md.go b/types/workspace/workspace_md.go index 696f1a3..c1547bc 100644 --- a/types/workspace/workspace_md.go +++ b/types/workspace/workspace_md.go @@ -42,23 +42,23 @@ func workspaceMarkdown(w *Workspace) string { func workspaceDescription(w *Workspace) string { var mkdwn string - const descSpacer = "| \n" + const descSpacer = "> \n" if w.Description != "" { mkdwn += descSpacer lines := strings.Split(w.Description, "\n") for _, line := range lines { - mkdwn += fmt.Sprintf("| %s\n", line) + mkdwn += fmt.Sprintf("> %s\n", line) } mkdwn += descSpacer } if w.DescriptionFile != "" { mdBytes, err := os.ReadFile(filepath.Clean(w.DescriptionFile)) if err != nil { - mkdwn += fmt.Sprintf("| **error rendering description file**: %s\n", err) + mkdwn += fmt.Sprintf("> **error rendering description file**: %s\n", err) } else { lines := strings.Split(string(mdBytes), "\n") for _, line := range lines { - mkdwn += fmt.Sprintf("| %s\n", line) + mkdwn += fmt.Sprintf("> %s\n", line) } } mkdwn += descSpacer