diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a20f3fbc..896ea910 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -52,9 +52,9 @@ homebrew_casks: name: homebrew-tap token: "{{ .Env.HOMEBREW_FLOW_GITHUB_TOKEN }}" completions: - bash: completions/flow.bash - zsh: completions/flow.zsh - fish: completions/flow.fish + bash: scripts/completions/flow.bash + zsh: scripts/completions/flow.zsh + fish: scripts/completions/flow.fish # dependencies: # - cask: xclip # Required for clipboard support, only linux, so I need to figure out to get this to skip macOS hooks: diff --git a/README.md b/README.md index 715ad9dd..1b52c63b 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ flow run hello flow complements existing CLI tools by adding multi-project organization, built-in security, and visual discovery to your automation toolkit. - **Workspace organization** - Group and manage workflows across multiple projects -- **Encrypted secret vaults** - Multiple backends (AES, Age, external tools) +- **Encrypted secret vaults** - Multiple backends (AES, Age, keyring, external tools) - **Interactive discovery** - Browse, search, and filter workflows visually - **Flexible execution** - Serial, parallel, conditional, and interactive workflows - **Workflow generation** - Create projects and workflows from reusable templates diff --git a/cmd/internal/cache.go b/cmd/internal/cache.go index 554a799d..14a607b2 100644 --- a/cmd/internal/cache.go +++ b/cmd/internal/cache.go @@ -65,7 +65,7 @@ func cacheSetFunc(ctx *context.Context, _ *cobra.Command, args []string) { if err != nil { logger.Log().FatalErr(err) } - if err = form.Run(ctx.Ctx); err != nil { + if err = form.Run(ctx); err != nil { logger.Log().FatalErr(err) } value = form.FindByKey("value").Value() diff --git a/cmd/internal/config.go b/cmd/internal/config.go index e741fc48..a9d280cf 100644 --- a/cmd/internal/config.go +++ b/cmd/internal/config.go @@ -55,7 +55,7 @@ func resetConfigFunc(ctx *context.Context, _ *cobra.Command, _ []string) { if err != nil { logger.Log().FatalErr(err) } - if err := form.Run(ctx.Ctx); err != nil { + if err := form.Run(ctx); err != nil { logger.Log().FatalErr(err) } resp := form.FindByKey("confirm").Value() diff --git a/cmd/internal/exec.go b/cmd/internal/exec.go index 7cc5a7d5..af6c64e8 100644 --- a/cmd/internal/exec.go +++ b/cmd/internal/exec.go @@ -152,7 +152,7 @@ func execFunc(ctx *context.Context, cmd *cobra.Command, verb executable.Verb, ar if err != nil { logger.Log().FatalErr(err) } - if err := form.Run(ctx.Ctx); err != nil { + if err := form.Run(ctx); err != nil { logger.Log().FatalErr(err) } for key, val := range form.ValueMap() { @@ -223,7 +223,7 @@ func runByRef(ctx *context.Context, cmd *cobra.Command, argsStr string) error { execCmd.SetIn(ctx.StdIn()) execPreRun(ctx, execCmd, []string{id}) execFunc(ctx, execCmd, verb, []string{id}) - ctx.CancelFunc() + ctx.Cancel() return nil } @@ -241,7 +241,7 @@ func setAuthEnv(ctx *context.Context, _ *cobra.Command, executable *executable.E if err != nil { logger.Log().FatalErr(err) } - if err := form.Run(ctx.Ctx); err != nil { + if err := form.Run(ctx); err != nil { logger.Log().FatalErr(err) } val := form.FindByKey(vault.EncryptionKeyEnvVar).Value() diff --git a/cmd/internal/flags/types.go b/cmd/internal/flags/types.go index c4d5502f..1446f204 100644 --- a/cmd/internal/flags/types.go +++ b/cmd/internal/flags/types.go @@ -203,7 +203,7 @@ var VaultSetFlag = &Metadata{ var VaultTypeFlag = &Metadata{ Name: "type", Shorthand: "t", - Usage: "Vault type. Either age or aes256", + Usage: "Vault type. Either unencrypted, age, aes256, keyring, or external", Default: "aes256", Required: false, } @@ -250,3 +250,11 @@ var VaultIdentityFileFlag = &Metadata{ Default: "", Required: false, } + +var VaultFromFileFlag = &Metadata{ + Name: "config", + Shorthand: "c", + Usage: "File path to read the external vault's configuration from. The file must be a valid vault configuration file.", + Default: "", + Required: false, +} diff --git a/cmd/internal/secret.go b/cmd/internal/secret.go index a1018147..dfb73ed4 100644 --- a/cmd/internal/secret.go +++ b/cmd/internal/secret.go @@ -63,7 +63,7 @@ func removeSecretFunc(ctx *context.Context, _ *cobra.Command, args []string) { if err != nil { logger.Log().FatalErr(err) } - if err := form.Run(ctx.Ctx); err != nil { + if err := form.Run(ctx); err != nil { logger.Log().FatalErr(err) } resp := form.FindByKey("confirm").Value() @@ -137,7 +137,7 @@ func setSecretFunc(ctx *context.Context, cmd *cobra.Command, args []string) { if err != nil { logger.Log().FatalErr(err) } - if err := form.Run(ctx.Ctx); err != nil { + if err := form.Run(ctx); err != nil { logger.Log().FatalErr(err) } value = form.FindByKey("value").Value() diff --git a/cmd/internal/vault.go b/cmd/internal/vault.go index 87b7d87c..09b006ec 100644 --- a/cmd/internal/vault.go +++ b/cmd/internal/vault.go @@ -64,6 +64,7 @@ func registerCreateVaultCmd(ctx *context.Context, vaultCmd *cobra.Command) { RegisterFlag(ctx, createCmd, *flags.VaultTypeFlag) RegisterFlag(ctx, createCmd, *flags.VaultPathFlag) RegisterFlag(ctx, createCmd, *flags.VaultSetFlag) + RegisterFlag(ctx, createCmd, *flags.VaultFromFileFlag) // AES flags RegisterFlag(ctx, createCmd, *flags.VaultKeyEnvFlag) RegisterFlag(ctx, createCmd, *flags.VaultKeyFileFlag) @@ -82,6 +83,8 @@ func createVaultFunc(ctx *context.Context, cmd *cobra.Command, args []string) { setVault := flags.ValueFor[bool](cmd, *flags.VaultSetFlag, false) switch strings.ToLower(vaultType) { + case "unencrypted": + vaultV2.NewUnencryptedVault(vaultName, vaultPath) case "aes256": keyEnv := flags.ValueFor[string](cmd, *flags.VaultKeyEnvFlag, false) keyFile := flags.ValueFor[string](cmd, *flags.VaultKeyFileFlag, false) @@ -92,8 +95,19 @@ func createVaultFunc(ctx *context.Context, cmd *cobra.Command, args []string) { identityEnv := flags.ValueFor[string](cmd, *flags.VaultIdentityEnvFlag, false) identityFile := flags.ValueFor[string](cmd, *flags.VaultIdentityFileFlag, false) vaultV2.NewAgeVault(vaultName, vaultPath, recipients, identityEnv, identityFile) + case "keyring": + vaultV2.NewKeyringVault(vaultName) + case "external": + cfgFile := flags.ValueFor[string](cmd, *flags.VaultFromFileFlag, false) + if cfgFile == "" { + logger.Log().Fatalf("external vault requires a configuration file to be specified with --config") + } + vaultV2.NewExternalVault(vaultPath) default: - logger.Log().Fatalf("unsupported vault type: %s - must be one of 'aes256' or 'age'", vaultType) + logger.Log().Fatalf( + "unsupported vault type: %s - must be one of 'aes256', 'age', 'unencrypted', 'keyring', or 'external'", + vaultType, + ) } if ctx.Config.Vaults == nil { @@ -229,7 +243,7 @@ func removeVaultFunc(ctx *context.Context, _ *cobra.Command, args []string) { if err != nil { logger.Log().FatalErr(err) } - if err := form.Run(ctx.Ctx); err != nil { + if err := form.Run(ctx); err != nil { logger.Log().FatalErr(err) } resp := form.FindByKey("confirm").Value() diff --git a/cmd/internal/workspace.go b/cmd/internal/workspace.go index b6c66702..d741f8c7 100644 --- a/cmd/internal/workspace.go +++ b/cmd/internal/workspace.go @@ -178,7 +178,7 @@ func removeWorkspaceFunc(ctx *context.Context, _ *cobra.Command, args []string) if err != nil { logger.Log().FatalErr(err) } - if err := form.Run(ctx.Ctx); err != nil { + if err := form.Run(ctx); err != nil { logger.Log().FatalErr(err) } resp := form.FindByKey("confirm").Value() diff --git a/desktop/src-tauri/src/types/generated/template.rs b/desktop/src-tauri/src/types/generated/template.rs index d613cdb2..78600f7d 100644 --- a/desktop/src-tauri/src/types/generated/template.rs +++ b/desktop/src-tauri/src/types/generated/template.rs @@ -42,7 +42,7 @@ pub mod error { #[doc = " ],"] #[doc = " \"properties\": {"] #[doc = " \"asTemplate\": {"] -#[doc = " \"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\","] +#[doc = " \"description\": \"If true, the artifact will be copied as a template file. The file will be rendered using Go templating from \\nthe form data. [Expr language functions](https://expr-lang.org/docs/language-definition) are available for use in the template.\\n\","] #[doc = " \"default\": false,"] #[doc = " \"type\": \"boolean\""] #[doc = " },"] @@ -76,7 +76,7 @@ pub mod error { #[doc = r" "] #[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug)] pub struct Artifact { - #[doc = "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"] + #[doc = "If true, the artifact will be copied as a template file. The file will be rendered using Go templating from \nthe form data. [Expr language functions](https://expr-lang.org/docs/language-definition) are available for use in the template.\n"] #[serde(rename = "asTemplate", default)] pub as_template: bool, #[doc = "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"] diff --git a/desktop/src/types/generated/template.ts b/desktop/src/types/generated/template.ts index 399a415e..bbfb0e5e 100644 --- a/desktop/src/types/generated/template.ts +++ b/desktop/src/types/generated/template.ts @@ -40,7 +40,7 @@ export interface Template { export interface Artifact { /** * 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. + * the form data. [Expr language functions](https://expr-lang.org/docs/language-definition) are available for use in the template. * */ asTemplate?: boolean; diff --git a/docs/cli/flow_vault_create.md b/docs/cli/flow_vault_create.md index acef681b..67f6d52b 100644 --- a/docs/cli/flow_vault_create.md +++ b/docs/cli/flow_vault_create.md @@ -9,6 +9,7 @@ flow vault create NAME [flags] ### Options ``` + -c, --config string File path to read the external vault's configuration from. The file must be a valid vault configuration file. -h, --help help for create --identity-env string Environment variable name for the Age vault identity. Only used for Age vaults. --identity-file string File path for the Age vault identity. An absolute path is recommended. Only used for Age vaults. @@ -17,7 +18,7 @@ flow vault create NAME [flags] -p, --path string Directory that the vault will use to store its data. If not set, the vault will be stored in the flow cache directory. --recipients string Comma-separated list of recipient keys for the vault. Only used for Age vaults. -s, --set Set the newly created vault as the current vault - -t, --type string Vault type. Either age or aes256 (default "aes256") + -t, --type string Vault type. Either unencrypted, age, aes256, keyring, or external (default "aes256") ``` ### Options inherited from parent commands diff --git a/docs/guide/advanced.md b/docs/guide/advanced.md index 7c096a9a..39ff121f 100644 --- a/docs/guide/advanced.md +++ b/docs/guide/advanced.md @@ -16,6 +16,8 @@ The expression language supports standard comparison and logical operators: - String: `+` (concatenation), `matches` (regex matching) - Length: `len()` +**See the [Expr Language Definition](https://expr-lang.org/docs/language-definition) for all available operators and functions.** + ### Basic Conditions Use the `if` field to control when executables run: @@ -61,6 +63,27 @@ executables: cmd: npm test ``` +### File System Conditions +Check for files or directories to control execution: + +```yaml +executables: + - verb: deploy + name: app + serial: + execs: + # Only run if config file exists + - if: fileExists("config.yaml") + cmd: kubectl apply -f config.yaml + + # Abort deployment if no Dockerfile found + - if: not fileExists("Dockerfile") + cmd: echo "No Dockerfile found"; exit 1 + + # Run deployment if Dockerfile exists + - cmd: docker build -t myapp . +``` + ### Data-Driven Conditions Use stored data to control execution flow: @@ -104,6 +127,19 @@ Conditions have access to extensive runtime information: - `ctx.flowFilePath` - Path to current flow file - `ctx.flowFileDir` - Directory containing current flow file +Additionally the following functions are provided alongside the Expr language definition: + +- `fileExists(path)` - Check if file/directory exists +- `dirExists(path)` - Check if path is a directory +- `isFile(path)` - Check if path is a file +- `isDir(path)` - Check if path is a directory +- `basename(path)` - Get filename from path +- `dirname(path)` - Get directory from path +- `readFile(path)` - Read file contents as string +- `fileSize(path)` - Get file size in bytes +- `fileModTime(path)` - Get file modification time +- `fileAge(path)` - Get duration since last modified + ## Managing State Persist data across executions and share information between workflow steps. diff --git a/docs/guide/executables.md b/docs/guide/executables.md index d7d59235..760597b1 100644 --- a/docs/guide/executables.md +++ b/docs/guide/executables.md @@ -327,11 +327,11 @@ executables: - `method`: HTTP method (GET, POST, PUT, PATCH, DELETE) - `url`: Request URL (required) - `headers`: Custom headers -- `body`: Request body +- `body`: Request body with Expr templating - `timeout`: Request timeout - `validStatusCodes`: Acceptable status codes - `logResponse`: Log response body -- `transformResponse`: Transform response with Expr +- `transformResponse`: Transform response with Expr templating - `responseFile`: Save response to file ### render - Dynamic Documentation @@ -351,16 +351,16 @@ executables: ```markdown # System Status -Current time: {{ .timestamp }} +Current time: {{ data["timestamp"] }} ## Services {{- range .services }} -- **{{ .name }}**: {{ .status }} +- **{{ .name }}**: {{ data["status"] }} {{- end }} ## Metrics -- CPU: {{ .cpu }}% -- Memory: {{ .memory }}% +- CPU: {{ data["cpu"] }}% +- Memory: {{ data["memory"] }}% ``` **Options:** diff --git a/docs/guide/secrets.md b/docs/guide/secrets.md index a834d890..095227ef 100644 --- a/docs/guide/secrets.md +++ b/docs/guide/secrets.md @@ -100,6 +100,81 @@ flow vault create team --type age --recipients key1,key2,key3 --identity-file ~/ flow vault create team --type age --recipients key1,key2,key3 --identity-env MY_IDENTITY ``` +#### **Unencrypted** +A simple vault that stores secrets in plain text JSON files. +This is not recommended for production use but can be useful for development or testing. + +```shell +# Create an unencrypted vault +flow vault create dev --type unencrypted +``` + + +#### **Keyring** + +A vault that uses your operating system's keyring for managing secrets. +This is a good option for personal use where you want seamless integration with your OS security. + +```shell +# Create a keyring vault +flow vault create dev --type keyring +``` + +#### **External (other CLI tools)** + +An external vault that uses executes an external CLI tool via shell commands to manage secrets. +This allows you to integrate with existing secret management systems. + +First you have to define the external vault configuration in JSON format. Here is a sample one that uses the `pass` CLI tool: + +```json +{ + "id": "pass", + "type": "external", + "external": { + "get": { + "cmd": "pass show {{key}}", + "output": "{{output}}" + }, + "set": { + "cmd": "pass insert -e {{key}}", + "input": "{{value}}" + }, + "delete": { + "cmd": "pass rm -f {{key}}" + }, + "list": { + "cmd": "pass ls", + "output": "{{output}}" + }, + "environment": { + "PASSWORD_STORE_DIR": "$PASSWORD_STORE_DIR" + }, + "timeout": "30s" + } +} +``` + +> [!INFO] +> See the [flowexec/vault examples](https://github.com/flowexec/vault/tree/v0.2.1/examples) for sample configurations for popular CLI tools like Bitwarden, 1Password, AWS SSM, and more. + + +```shell +# Create an external vault +flow vault create passwords --type external --config /path/to/config.json +``` + +**Template Variables** + +Available in `cmd` and `output` fields: + +- `{{key}}` - The secret key/name +- `{{value}}` - The secret value (for set operations) +- `{{env["VariableName"]}}`- Environment variable value +- `{{output}}` - Raw command output (for output templates) + +All [Expr language](https://expr-lang.org/docs/language-definition) operators and functions can be used in the command templates, allowing for powerful dynamic secret management. + #### Authentication @@ -109,6 +184,9 @@ If you did not provide a key or file, these default environment variables will b - For AES256 vaults: `FLOW_VAULT_KEY` environment variable - For Age vaults: `FLOW_VAULT_IDENTITY` environment variable +- For Unencrypted vaults: no key is needed, it stores secrets in plain text +- For Keyring vaults: no key is needed, it uses the OS keyring directly +- For External vaults: no key is needed, it uses the external CLI tool directly. Auth may be required by the tool itself At least one of the key or file will be used. You can configure key storage during vault creation: diff --git a/docs/schemas/template_schema.json b/docs/schemas/template_schema.json index 407afb0b..abd01f58 100644 --- a/docs/schemas/template_schema.json +++ b/docs/schemas/template_schema.json @@ -16,7 +16,7 @@ ], "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", + "description": "If true, the artifact will be copied as a template file. The file will be rendered using Go templating from \nthe form data. [Expr language functions](https://expr-lang.org/docs/language-definition) are available for use in the template.\n", "type": "boolean", "default": false }, diff --git a/docs/types/template.md b/docs/types/template.md index b6363f9c..ed5d6ce4 100644 --- a/docs/types/template.md +++ b/docs/types/template.md @@ -36,7 +36,7 @@ Go templating from form data is supported in all fields. | 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 | | +| `asTemplate` | If true, the artifact will be copied as a template file. The file will be rendered using Go templating from the form data. [Expr language functions](https://expr-lang.org/docs/language-definition) 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` | An expression that determines whether the the artifact should be copied, using the Expr language syntax. The expression is evaluated at runtime and must resolve to a boolean value. If the condition is not met, the artifact will not be copied. The expression has access to OS/architecture information (os, arch), environment variables (env), form input (form), and context information (name, workspace, directory, etc.). See the [flow documentation](https://flowexec.io/guide/templating) for more information. | `string` | | | diff --git a/go.mod b/go.mod index df8b0d37..6d62b6b1 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,15 @@ module github.com/flowexec/flow go 1.24.0 require ( - github.com/Masterminds/sprig/v3 v3.3.0 github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/exp/teatest v0.0.0-20250806222409-83e3a29d542f - github.com/expr-lang/expr v1.17.5 github.com/flowexec/tuikit v0.2.3 - github.com/flowexec/vault v0.1.2 + github.com/flowexec/vault v0.2.1 github.com/gen2brain/beeep v0.11.1 + github.com/jahvon/expression v0.1.4 github.com/jahvon/glamour v0.8.1-patch3 github.com/mark3labs/mcp-go v0.37.0 github.com/mattn/go-runewidth v0.0.16 @@ -33,11 +32,9 @@ require ( ) require ( - dario.cat/mergo v1.0.2 // indirect + al.essio.dev/pkg/shellescape v1.5.1 // indirect filippo.io/age v1.2.1 // indirect git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect - github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-udiff v0.3.1 // indirect @@ -54,10 +51,12 @@ require ( github.com/charmbracelet/x/exp/strings v0.0.0-20250806222409-83e3a29d542f // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/esiqveland/notify v0.13.3 // indirect + github.com/expr-lang/expr v1.17.5 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-ole/go-ole v1.3.0 // indirect @@ -67,7 +66,6 @@ require ( github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackmordaunt/icns/v3 v3.0.1 // indirect @@ -76,9 +74,7 @@ require ( 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.2.0 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect - github.com/mitchellh/reflectwalk v1.0.2 // 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 @@ -89,7 +85,6 @@ require ( github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sergeymakinen/go-bmp v1.0.0 // indirect github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect - github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.9.2 // indirect github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect @@ -97,6 +92,7 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect + github.com/zalando/go-keyring v0.2.6 // indirect go.uber.org/automaxprocs v1.6.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sys v0.35.0 // indirect diff --git a/go.sum b/go.sum index 2960408e..faa5f52d 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,13 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= -dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= -dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE= git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo= 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.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= -github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= @@ -71,6 +65,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3 github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= 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= @@ -86,8 +82,8 @@ github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/flowexec/tuikit v0.2.3 h1:hGlBc8yXvj4AXaKFp+IUNQ9nO7xOYY4W99m1BfNT13Q= github.com/flowexec/tuikit v0.2.3/go.mod h1:fjMwEM7FkxbP7bIV4CfEjsixgjicgQqPrejoBZAHf5s= -github.com/flowexec/vault v0.1.2 h1:INQ/w81piKRM+zqPBQpxFYl1iK8dI3APIHZ1F1Jm7CA= -github.com/flowexec/vault v0.1.2/go.mod h1:nxoGHIVjwSgg1o6DoTmj5NCJtubu71SvS883LPUXuvg= +github.com/flowexec/vault v0.2.1 h1:IYII6iXhhzUc4o0arJVH8281so67L9V8HY8ary/kTps= +github.com/flowexec/vault v0.2.1/go.mod h1:6JHONK+fTf8Zn7bOwejzbKTWuIh1BYHxgAwBc/XPXeY= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/gen2brain/beeep v0.11.1 h1:EbSIhrQZFDj1K2fzlMpAYlFOzV8YuNe721A58XcCTYI= @@ -108,20 +104,22 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/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.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= -github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o= github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= +github.com/jahvon/expression v0.1.4 h1:4q/jvM5G2mBJDqXtTUDThtJ4Sfajx+vIhUf4r6EAy6A= +github.com/jahvon/expression v0.1.4/go.mod h1:4HJB2k+epW5vFeptF6ILlXbFRQ+CuCyCSO4QdnGT3AE= github.com/jahvon/glamour v0.8.1-patch3 h1:LfyMACZavV8yxK4UsPENNQQOqafWuq4ZdLuEAP2ZLE8= github.com/jahvon/glamour v0.8.1-patch3/go.mod h1:30MVJwG3rcEHrN277NrA4DKzndSL9lBtEmpcfOygwCQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -145,12 +143,8 @@ 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.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= -github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 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.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= -github.com/mitchellh/reflectwalk v1.0.2/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= @@ -189,8 +183,6 @@ github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNW github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY= github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ= github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk= -github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= @@ -201,6 +193,8 @@ github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -218,6 +212,8 @@ github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I= go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= diff --git a/internal/context/context.go b/internal/context/context.go index 8350b82a..10b53907 100644 --- a/internal/context/context.go +++ b/internal/context/context.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "time" "github.com/flowexec/tuikit" "github.com/flowexec/tuikit/themes" @@ -25,8 +26,11 @@ const ( ) type Context struct { - Ctx context.Context - CancelFunc context.CancelFunc + ctx context.Context + cancelFunc context.CancelFunc + stdOut, stdIn *os.File + callbacks []func(*Context) error + Config *config.Config CurrentWorkspace *workspace.Workspace TUIContainer *tuikit.Container @@ -40,12 +44,9 @@ type Context struct { // 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. ProcessTmpDir string - - stdOut, stdIn *os.File - callbacks []func(*Context) error } -func NewContext(ctx context.Context, stdIn, stdOut *os.File) *Context { +func NewContext(ctx context.Context, cancelFunc context.CancelFunc, stdIn, stdOut *os.File) *Context { cfg, err := filesystem.LoadConfig() if err != nil { panic(errors.Wrap(err, "user config load error")) @@ -67,16 +68,15 @@ func NewContext(ctx context.Context, stdIn, stdOut *os.File) *Context { workspaceCache := cache.NewWorkspaceCache() executableCache := cache.NewExecutableCache(workspaceCache) - ctxx, cancel := context.WithCancel(ctx) c := &Context{ - Ctx: ctxx, - CancelFunc: cancel, + ctx: ctx, + cancelFunc: cancelFunc, + stdOut: stdOut, + stdIn: stdIn, Config: cfg, CurrentWorkspace: wsConfig, WorkspacesCache: workspaceCache, ExecutableCache: executableCache, - stdOut: stdOut, - stdIn: stdIn, } app := tuikit.NewApplication( @@ -101,6 +101,32 @@ func NewContext(ctx context.Context, stdIn, stdOut *os.File) *Context { return c } +func (ctx *Context) Deadline() (deadline time.Time, ok bool) { + return ctx.ctx.Deadline() +} + +func (ctx *Context) Done() <-chan struct{} { + return ctx.ctx.Done() +} + +func (ctx *Context) Err() error { + return ctx.ctx.Err() +} + +func (ctx *Context) Cancel() { + if ctx.cancelFunc != nil { + ctx.cancelFunc() + } +} + +// TODO: Move access to various context fields to this function +func (ctx *Context) Value(key any) any { + if key == HeaderCtxKey { + return ctx.String() + } + return ctx.ctx.Value(key) +} + func (ctx *Context) String() string { ws := ctx.CurrentWorkspace.AssignedName() ns := ctx.Config.CurrentNamespace @@ -129,6 +155,14 @@ func (ctx *Context) SetIO(stdIn, stdOut *os.File) { ctx.stdOut = stdOut } +// SetContext sets the context and cancel function for the Context. +// This function should NOT be used outside of tests! The context and cancel function +// should be set when creating the context. +func (ctx *Context) SetContext(c context.Context, cancelFunc context.CancelFunc) { + ctx.ctx = c + ctx.cancelFunc = cancelFunc +} + func (ctx *Context) SetView(view tuikit.View) error { return ctx.TUIContainer.SetView(view) } diff --git a/internal/mcp/command_executor.go b/internal/mcp/command_executor.go index f842b55c..1c67a8bc 100644 --- a/internal/mcp/command_executor.go +++ b/internal/mcp/command_executor.go @@ -31,7 +31,7 @@ func (c *FlowCLIExecutor) Execute(args ...string) (string, error) { output, err := cmd.CombinedOutput() if err != nil { // Only return an error if it's not an exit error. - exitErr := &exec.ExitError{} + var exitErr exec.ExitError if !errors.As(err, &exitErr) { return string(output), err } diff --git a/internal/mcp/resources/template_schema.json b/internal/mcp/resources/template_schema.json index 407afb0b..abd01f58 100644 --- a/internal/mcp/resources/template_schema.json +++ b/internal/mcp/resources/template_schema.json @@ -16,7 +16,7 @@ ], "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", + "description": "If true, the artifact will be copied as a template file. The file will be rendered using Go templating from \nthe form data. [Expr language functions](https://expr-lang.org/docs/language-definition) are available for use in the template.\n", "type": "boolean", "default": false }, diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 60eccdad..cbc50438 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -311,11 +311,11 @@ func executeFlowHandler(executor CommandExecutor) server.ToolHandlerFunc { cmdArgs = append(cmdArgs, "--sync") } - output, _ := executor.Execute(cmdArgs...) - // if err != nil { - // ref := strings.Join([]string{executableVerb, executableID}, " ") - // return mcp.NewToolResultError(fmt.Sprintf("%s execution failed: %s", ref, output)), nil - // } + output, err := executor.Execute(cmdArgs...) + if err != nil { + ref := strings.Join([]string{executableVerb, executableID}, " ") + return mcp.NewToolResultError(fmt.Sprintf("%s execution failed: %s", ref, output)), nil + } return mcp.NewToolResultText(output), nil } @@ -350,8 +350,5 @@ func syncStateHandler(executor CommandExecutor) server.ToolHandlerFunc { } func boolPtr(b bool) *bool { - if b { - return &b - } - return nil + return &b } diff --git a/internal/runner/parallel/parallel.go b/internal/runner/parallel/parallel.go index cfd2befc..d6f9c7d6 100644 --- a/internal/runner/parallel/parallel.go +++ b/internal/runner/parallel/parallel.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "github.com/jahvon/expression" "github.com/pkg/errors" "golang.org/x/sync/errgroup" @@ -15,7 +16,6 @@ import ( "github.com/flowexec/flow/internal/logger" "github.com/flowexec/flow/internal/runner" "github.com/flowexec/flow/internal/runner/engine" - "github.com/flowexec/flow/internal/services/expr" "github.com/flowexec/flow/internal/services/store" envUtils "github.com/flowexec/flow/internal/utils/env" execUtils "github.com/flowexec/flow/internal/utils/executables" @@ -94,7 +94,7 @@ func handleExec( inputEnv map[string]string, cacheData map[string]string, ) error { - groupCtx, cancel := stdCtx.WithCancel(ctx.Ctx) + groupCtx, cancel := stdCtx.WithCancel(ctx) defer cancel() group, _ := errgroup.WithContext(groupCtx) limit := parallelSpec.MaxThreads @@ -127,12 +127,12 @@ func handleExec( // Build the list of steps to execute var execs []engine.Exec - conditionalData := expr.ExpressionEnv(ctx, parent, cacheData, inputEnv) + conditionalData := runner.ExpressionEnv(ctx, parent, cacheData, inputEnv) for i, refConfig := range parallelSpec.Execs { // Skip over steps that do not match the condition if refConfig.If != "" { - if truthy, err := expr.IsTruthy(refConfig.If, &conditionalData); err != nil { + if truthy, err := expression.IsTruthy(refConfig.If, conditionalData); err != nil { return err } else if !truthy { logger.Log().Debugf("skipping execution %d/%d", i+1, len(parallelSpec.Execs)) @@ -229,7 +229,7 @@ func handleExec( } results := eng.Execute( - ctx.Ctx, execs, + ctx, execs, engine.WithMode(engine.Parallel), engine.WithFailFast(parent.Parallel.FailFast), engine.WithMaxThreads(parent.Parallel.MaxThreads), diff --git a/internal/runner/render/render.go b/internal/runner/render/render.go index dd2e4b92..7c172ce2 100644 --- a/internal/runner/render/render.go +++ b/internal/runner/render/render.go @@ -1,16 +1,14 @@ package render import ( - "bytes" "encoding/json" "fmt" stdio "io" "os" "path/filepath" - "text/template" - "github.com/Masterminds/sprig/v3" "github.com/flowexec/tuikit/views" + "github.com/jahvon/expression" "github.com/pkg/errors" "gopkg.in/yaml.v3" @@ -97,21 +95,21 @@ func (r *renderRunner) Exec( } } - tmpl, err := template.New(filepath.Base(renderSpec.TemplateFile)).Funcs(sprig.TxtFuncMap()).ParseFiles(contentFile) - if err != nil { + tmpl := expression.NewTemplate(filepath.Base(renderSpec.TemplateFile), map[string]interface{}{"data": templateData}) + if err = tmpl.ParseFile(contentFile); err != nil { return errors.Wrapf(err, "unable to parse template file %s", contentFile) } - var buff bytes.Buffer - if err = tmpl.Execute(&buff, templateData); err != nil { - return errors.Wrapf(err, "unable to execute template file %s", contentFile) + data, err := tmpl.ExecuteToString() + if err != nil { + return errors.Wrapf(err, "unable to parse template file %s", contentFile) } logger.Log().Infof("Rendering content from file %s", contentFile) if os.Getenv(io.DisableInteractiveEnvKey) != "" { logger.Log().Print("### Rendered Content Start ###") - logger.Log().Print(buff.String()) + logger.Log().Print(data) logger.Log().Print("### Rendered Content End ###") return nil } @@ -125,7 +123,7 @@ func (r *renderRunner) Exec( filename := filepath.Base(contentFile) ctx.TUIContainer.SetState("file", filename) - return ctx.TUIContainer.SetView(views.NewMarkdownView(ctx.TUIContainer.RenderState(), buff.String())) + return ctx.TUIContainer.SetView(views.NewMarkdownView(ctx.TUIContainer.RenderState(), data)) } func readDataFile(dir, path string) (interface{}, error) { diff --git a/internal/runner/request/request.go b/internal/runner/request/request.go index 7ee69051..c213ff2f 100644 --- a/internal/runner/request/request.go +++ b/internal/runner/request/request.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + "github.com/jahvon/expression" "github.com/pkg/errors" "gopkg.in/yaml.v3" @@ -13,7 +14,6 @@ import ( "github.com/flowexec/flow/internal/logger" "github.com/flowexec/flow/internal/runner" "github.com/flowexec/flow/internal/runner/engine" - "github.com/flowexec/flow/internal/services/expr" "github.com/flowexec/flow/internal/services/rest" "github.com/flowexec/flow/internal/utils/env" "github.com/flowexec/flow/types/executable" @@ -53,6 +53,13 @@ func (r *requestRunner) Exec( url := expandEnvVars(envMap, requestSpec.URL) body := expandEnvVars(envMap, requestSpec.Body) + if body != "" { + body, err = expression.EvaluateString(body, map[string]interface{}{"env": envMap}) + if err != nil { + return errors.Wrap(err, "unable to evaluate request body expression") + } + } + for key, value := range requestSpec.Headers { requestSpec.Headers[key] = expandEnvVars(envMap, value) } @@ -70,7 +77,7 @@ func (r *requestRunner) Exec( respStr := resp.Body if requestSpec.TransformResponse != "" { - respStr, err = expr.EvaluateString(requestSpec.TransformResponse, resp) + respStr, err = expression.EvaluateString(requestSpec.TransformResponse, resp) if err != nil { return errors.Wrap(err, "unable to transform response") } diff --git a/internal/runner/request/request_test.go b/internal/runner/request/request_test.go index f501e4d5..a01dd084 100644 --- a/internal/runner/request/request_test.go +++ b/internal/runner/request/request_test.go @@ -2,6 +2,8 @@ package request_test import ( stdCtx "context" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -61,10 +63,26 @@ var _ = Describe("Request Runner", func() { }) Describe("Exec", func() { + var testServer *httptest.Server + BeforeEach(func() { + testServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"message": "GET request successful"}`)) + case http.MethodPost: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"key": "value"}`)) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + })) + }) + It("should send a GET request and log the response", func() { exec := &executable.Executable{ Request: &executable.RequestExecutableType{ - URL: "https://httpbin.org/get", + URL: testServer.URL, Method: executable.RequestExecutableTypeMethodGET, LogResponse: true, }, @@ -78,7 +96,7 @@ var _ = Describe("Request Runner", func() { It("should send a POST request with a body and log the response", func() { exec := &executable.Executable{ Request: &executable.RequestExecutableType{ - URL: "https://httpbin.org/post", + URL: testServer.URL, Method: executable.RequestExecutableTypeMethodPOST, Body: `{"key": "value"}`, LogResponse: true, @@ -93,7 +111,7 @@ var _ = Describe("Request Runner", func() { It("should save the response to a file", func() { exec := &executable.Executable{ Request: &executable.RequestExecutableType{ - URL: "https://httpbin.org/get", + URL: testServer.URL, Method: executable.RequestExecutableTypeMethodGET, ResponseFile: &executable.RequestResponseFile{ Filename: "response.json", @@ -115,14 +133,14 @@ var _ = Describe("Request Runner", func() { It("should transform the response when specified", func() { exec := &executable.Executable{ Request: &executable.RequestExecutableType{ - URL: "https://httpbin.org/get", + URL: testServer.URL, Method: executable.RequestExecutableTypeMethodGET, TransformResponse: `upper(body)`, LogResponse: true, }, } - ctx.Logger.EXPECT().Infox(gomock.Any(), gomock.Any(), gomock.Regex("HTTPS://HTTPBIN.ORG")).Times(1) + ctx.Logger.EXPECT().Infox(gomock.Any(), gomock.Any(), gomock.Regex("SUCCESSFUL")).Times(1) err := requestRnr.Exec(ctx.Ctx, exec, mockEngine, make(map[string]string), nil) Expect(err).NotTo(HaveOccurred()) }) diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 53ddc3f3..d23f6e1c 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -2,9 +2,13 @@ package runner import ( "fmt" + "path/filepath" "time" + "github.com/jahvon/expression" + "github.com/flowexec/flow/internal/context" + "github.com/flowexec/flow/internal/logger" "github.com/flowexec/flow/internal/runner/engine" "github.com/flowexec/flow/types/executable" ) @@ -71,3 +75,38 @@ func Exec( func Reset() { registeredRunners = make([]Runner, 0) } + +type CtxData struct { + Workspace string `expr:"workspace"` + Namespace string `expr:"namespace"` + WorkspacePath string `expr:"workspacePath"` + FlowFileName string `expr:"flowFileName"` + FlowFilePath string `expr:"flowFilePath"` + FlowFileDir string `expr:"flowFileDir"` +} + +func ExpressionEnv( + ctx *context.Context, + executable *executable.Executable, + dataMap, envMap map[string]string, +) expression.Data { + fn := filepath.Base(filepath.Base(executable.FlowFilePath())) + data, err := expression.BuildData( + ctx, + envMap, + "store", dataMap, + "ctx", &CtxData{ + Workspace: ctx.CurrentWorkspace.AssignedName(), + Namespace: ctx.Config.CurrentNamespace, + WorkspacePath: executable.WorkspacePath(), + FlowFileName: fn, + FlowFilePath: executable.FlowFilePath(), + FlowFileDir: filepath.Dir(executable.FlowFilePath()), + }, + ) + if err != nil { + logger.Log().Errorf("failed to build expression data: %v", err) + return nil + } + return data +} diff --git a/internal/runner/serial/serial.go b/internal/runner/serial/serial.go index 670878bf..458bc34d 100644 --- a/internal/runner/serial/serial.go +++ b/internal/runner/serial/serial.go @@ -7,13 +7,13 @@ import ( "path/filepath" "strings" + "github.com/jahvon/expression" "github.com/pkg/errors" "github.com/flowexec/flow/internal/context" "github.com/flowexec/flow/internal/logger" "github.com/flowexec/flow/internal/runner" "github.com/flowexec/flow/internal/runner/engine" - "github.com/flowexec/flow/internal/services/expr" "github.com/flowexec/flow/internal/services/store" envUtils "github.com/flowexec/flow/internal/utils/env" execUtils "github.com/flowexec/flow/internal/utils/executables" @@ -116,12 +116,12 @@ func handleExec( // Build the list of steps to execute var execs []engine.Exec - conditionalData := expr.ExpressionEnv(ctx, parent, cacheData, inputEnv) + conditionalData := runner.ExpressionEnv(ctx, parent, cacheData, inputEnv) for i, refConfig := range serialSpec.Execs { // Skip over steps that do not match the condition if refConfig.If != "" { - truthy, err := expr.IsTruthy(refConfig.If, &conditionalData) + truthy, err := expression.IsTruthy(refConfig.If, conditionalData) if err != nil { return err } @@ -217,7 +217,7 @@ func handleExec( execs = append(execs, engine.Exec{ID: exec.Ref().String(), Function: runExec, MaxRetries: refConfig.Retries}) } - results := eng.Execute(ctx.Ctx, execs, engine.WithMode(engine.Serial), engine.WithFailFast(parent.Serial.FailFast)) + results := eng.Execute(ctx, execs, engine.WithMode(engine.Serial), engine.WithFailFast(parent.Serial.FailFast)) if results.HasErrors() { return errors.New(results.String()) } diff --git a/internal/services/expr/expr.go b/internal/services/expr/expr.go deleted file mode 100644 index c2ea80e2..00000000 --- a/internal/services/expr/expr.go +++ /dev/null @@ -1,108 +0,0 @@ -package expr - -import ( - "fmt" - "path/filepath" - "reflect" - "runtime" - "strconv" - "strings" - - "github.com/expr-lang/expr" - "github.com/expr-lang/expr/vm" - - "github.com/flowexec/flow/internal/context" - "github.com/flowexec/flow/types/executable" -) - -func IsTruthy(ex string, env any) (bool, error) { - output, err := Evaluate(ex, env) - if err != nil { - return false, err - } - - switch v := output.(type) { - case bool: - return v, nil - case int, int64, float64, uint, uint64: - return v != 0, nil - case string: - truthy, err := strconv.ParseBool(strings.Trim(v, `"' `)) - if err != nil { - return false, err - } - return truthy, nil - default: - return false, nil - } -} - -func Evaluate(ex string, env any) (interface{}, error) { - var program *vm.Program - var err error - if env == nil || reflect.ValueOf(env).IsNil() { - program, err = expr.Compile(ex) - } else { - program, err = expr.Compile(ex, expr.Env(env)) - } - if err != nil { - return nil, err - } - - output, err := expr.Run(program, env) - if err != nil { - return nil, err - } - return output, nil -} - -func EvaluateString(ex string, env any) (string, error) { - output, err := Evaluate(ex, env) - if err != nil { - return "", err - } - str, ok := output.(string) - if !ok { - return "", fmt.Errorf("expected string, got %T", output) - } - return str, nil -} - -type CtxData struct { - Workspace string `expr:"workspace"` - Namespace string `expr:"namespace"` - WorkspacePath string `expr:"workspacePath"` - FlowFileName string `expr:"flowFileName"` - FlowFilePath string `expr:"flowFilePath"` - FlowFileDir string `expr:"flowFileDir"` -} - -type ExpressionData struct { - OS string `expr:"os"` - Arch string `expr:"arch"` - Ctx *CtxData `expr:"ctx"` - Store map[string]string `expr:"store"` - Env map[string]string `expr:"env"` -} - -func ExpressionEnv( - ctx *context.Context, - executable *executable.Executable, - dataMap, envMap map[string]string, -) ExpressionData { - fn := filepath.Base(filepath.Base(executable.FlowFilePath())) - return ExpressionData{ - OS: runtime.GOOS, - Arch: runtime.GOARCH, - Ctx: &CtxData{ - Workspace: ctx.CurrentWorkspace.AssignedName(), - Namespace: ctx.Config.CurrentNamespace, - WorkspacePath: executable.WorkspacePath(), - FlowFileName: fn, - FlowFilePath: executable.FlowFilePath(), - FlowFileDir: filepath.Dir(executable.FlowFilePath()), - }, - Store: dataMap, - Env: envMap, - } -} diff --git a/internal/services/expr/expr_test.go b/internal/services/expr/expr_test.go deleted file mode 100644 index c467e593..00000000 --- a/internal/services/expr/expr_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package expr_test - -import ( - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/flowexec/flow/internal/services/expr" -) - -func TestExpr(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Expr Suite") -} - -var _ = Describe("Expr", func() { - Describe("IsTruthy", func() { - It("should evaluate truthy expressions correctly", func() { - tests := []struct { - expr string - env *expr.ExpressionData - expected bool - }{ - {"true", nil, true}, - {"false", nil, false}, - {"1", nil, true}, - {"0", nil, false}, - {`"true"`, nil, true}, - {`"false"`, nil, false}, - } - - for _, test := range tests { - result, err := expr.IsTruthy(test.expr, test.env) - Expect(err).NotTo(HaveOccurred()) - By("testing expression: " + test.expr) - Expect(result).To(Equal(test.expected)) - } - }) - }) - - Describe("Evaluate", func() { - It("should evaluate expressions correctly", func() { - tests := []struct { - expr string - env *expr.ExpressionData - expected interface{} - }{ - {"1 + 1", nil, 2}, - {"true && false", nil, false}, - {`"hello" + " " + "world"`, nil, "hello world"}, - } - - for _, test := range tests { - result, err := expr.Evaluate(test.expr, test.env) - Expect(err).NotTo(HaveOccurred()) - By("testing expression: " + test.expr) - Expect(result).To(Equal(test.expected)) - } - }) - }) - - Describe("EvaluateString", func() { - It("should evaluate string expressions correctly", func() { - tests := []struct { - expr string - env *expr.ExpressionData - expected string - }{ - {`"hello"`, nil, "hello"}, - {`"foo" + "bar"`, nil, "foobar"}, - } - - for _, test := range tests { - result, err := expr.EvaluateString(test.expr, test.env) - Expect(err).NotTo(HaveOccurred()) - By("testing expression: " + test.expr) - Expect(result).To(Equal(test.expected)) - } - }) - }) - - Describe("ExpressionData", func() { - var ( - data *expr.ExpressionData - ) - - BeforeEach(func() { - data = &expr.ExpressionData{ - OS: "linux", - Arch: "amd64", - Ctx: &expr.CtxData{ - Workspace: "test_workspace", - Namespace: "test_namespace", - WorkspacePath: "/path/to/workspace", - FlowFilePath: "/path/to/flowfile", - FlowFileDir: "/path/to", - }, - Store: map[string]string{ - "key1": "value1", - "key2": "value2", - }, - Env: map[string]string{ - "ENV_VAR1": "env_value1", - "ENV_VAR2": "env_value2", - }, - } - }) - - Describe("Evaluate complex expressions", func() { - It("should evaluate various expressions correctly", func() { - tests := []struct { - expr string - expected interface{} - }{ - {"1 + 1", 2}, - {"true && false", false}, - {`"hello" + " " + "world"`, "hello world"}, - {`store["key1"]`, "value1"}, - {`env["ENV_VAR1"]`, "env_value1"}, - {`os == "linux"`, true}, - {`arch == "amd64"`, true}, - {`ctx.workspace == "test_workspace"`, true}, - } - - for _, test := range tests { - result, err := expr.Evaluate(test.expr, data) - Expect(err).NotTo(HaveOccurred()) - By("testing expression: " + test.expr) - Expect(result).To(Equal(test.expected)) - } - }) - }) - }) -}) diff --git a/internal/services/expr/template.go b/internal/services/expr/template.go deleted file mode 100644 index 2948cefa..00000000 --- a/internal/services/expr/template.go +++ /dev/null @@ -1,193 +0,0 @@ -package expr - -import ( - "bytes" - "fmt" - "io" - "reflect" - "strconv" - "strings" - "text/template" - - "github.com/expr-lang/expr" - "github.com/expr-lang/expr/vm" -) - -// Template wraps text/template but evaluates expressions using expr instead -type Template struct { - name string - text string - data any - tmpl *template.Template - exprCache map[string]*vm.Program -} - -func NewTemplate(name string, data any) *Template { - t := &Template{ - name: name, - data: data, - exprCache: make(map[string]*vm.Program), - } - return t -} - -func (t *Template) Parse(text string) error { - t.text = text - processed := t.preProcessExpressions(text) - tmpl := template.New(t.name).Funcs(template.FuncMap{"expr": t.evalExpr, "exprBool": t.evalExprBool}) - - parsed, err := tmpl.Parse(processed) - if err != nil { - return fmt.Errorf("parsing template: %w", err) - } - - t.tmpl = parsed - return nil -} - -func (t *Template) Execute(wr io.Writer) error { - if t.tmpl == nil { - return fmt.Errorf("template not parsed") - } - - return t.tmpl.Execute(wr, t.data) -} - -func (t *Template) ExecuteToString() (string, error) { - var buf bytes.Buffer - err := t.Execute(&buf) - return buf.String(), err -} - -func (t *Template) compileExpr(expression string) (*vm.Program, error) { - if node, ok := t.exprCache[expression]; ok { - return node, nil - } - - var compiled *vm.Program - var err error - if t.data == nil || reflect.ValueOf(t.data).IsNil() { - compiled, err = expr.Compile(expression) - } else { - compiled, err = expr.Compile(expression, expr.Env(t.data)) - } - if err != nil { - return nil, err - } - t.exprCache[expression] = compiled - return compiled, nil -} - -//nolint:funlen -func (t *Template) preProcessExpressions(text string) string { - var result strings.Builder - remaining := text - contextDepth := 0 // Track nested range/with blocks - - for { - start := strings.Index(remaining, "{{") - if start == -1 { - result.WriteString(remaining) - break - } - result.WriteString(remaining[:start]) - - end := strings.Index(remaining[start:], "}}") - if end == -1 { - result.WriteString(remaining[start:]) - break - } - end += start - - action := remaining[start+2 : end] - trimLeft := strings.HasPrefix(action, "-") - trimRight := strings.HasSuffix(action, "-") - action = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(action, "-"), "-")) - - result.WriteString("{{") - if trimLeft { - result.WriteString("-") - } - result.WriteString(" ") - - switch { - case strings.HasPrefix(action, "if "): - condition := strings.TrimPrefix(action, "if ") - result.WriteString("if exprBool `") - result.WriteString(strings.TrimSpace(condition)) - result.WriteString("`") - case strings.HasPrefix(action, "with "): - value := strings.TrimPrefix(action, "with ") - result.WriteString("with expr `") - result.WriteString(strings.TrimSpace(value)) - result.WriteString("`") - contextDepth++ - case action == "end": - result.WriteString("end") - if contextDepth > 0 { - contextDepth-- - } - case action == "else": - result.WriteString("else") - case strings.HasPrefix(action, "range "): - value := strings.TrimPrefix(action, "range ") - result.WriteString("range expr `") - result.WriteString(strings.TrimSpace(value)) - result.WriteString("`") - contextDepth++ - default: - if contextDepth > 0 && (strings.HasPrefix(action, ".") || action == ".") { - result.WriteString(action) - } else { - result.WriteString("expr `") - result.WriteString(strings.TrimSpace(action)) - result.WriteString("`") - } - } - - result.WriteString(" ") - if trimRight { - result.WriteString("-") - } - result.WriteString("}}") - - remaining = remaining[end+2:] - } - - return result.String() -} - -func (t *Template) evalExpr(expression string) (interface{}, error) { - program, err := t.compileExpr(expression) - if err != nil { - return nil, fmt.Errorf("compiling expression: %w", err) - } - result, err := expr.Run(program, t.data) - if err != nil { - return nil, fmt.Errorf("evaluating expression: %w", err) - } - - return result, nil -} - -func (t *Template) evalExprBool(expression string) (bool, error) { - result, err := t.evalExpr(expression) - if err != nil { - return false, err - } - - switch v := result.(type) { - case bool: - return v, nil - case int, int64, float64, uint, uint64: - return v != 0, nil - case string: - truthy, err := strconv.ParseBool(strings.Trim(v, `"' `)) - if err != nil { - return false, err - } - return truthy, nil - default: - return result != nil, nil - } -} diff --git a/internal/services/expr/template_test.go b/internal/services/expr/template_test.go deleted file mode 100644 index f233fef6..00000000 --- a/internal/services/expr/template_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package expr_test - -import ( - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/flowexec/flow/internal/services/expr" -) - -var _ = Describe("Template", func() { - var ( - tmpl *expr.Template - data map[string]interface{} - ) - - BeforeEach(func() { - data = map[string]interface{}{ - "os": "linux", - "arch": "amd64", - "store": map[string]interface{}{"key1": "value1", "key2": 2}, - "ctx": map[string]interface{}{"workspace": "test_workspace", "namespace": "test_namespace"}, - "workspaces": []string{"test_workspace", "other_workspace"}, - "executables": []map[string]interface{}{ - {"name": "exec1", "tags": []string{"tag"}, "type": "serial"}, - {"name": "exec2", "tags": []string{}, "type": "exec"}, - {"name": "exec3", "tags": []string{"tag", "tag2"}, "type": "exec"}, - }, - "featureEnabled": true, - } - tmpl = expr.NewTemplate("test", data) - }) - - Describe("expr evaluation", func() { - It("evaluates simple expressions", func() { - err := tmpl.Parse("{{ ctx.workspace }}") - Expect(err).NotTo(HaveOccurred()) - - result, err := tmpl.ExecuteToString() - Expect(err).NotTo(HaveOccurred()) - Expect(result).To(Equal("test_workspace")) - }) - - It("evaluates boolean expressions", func() { - err := tmpl.Parse("{{ os == \"linux\" && arch == \"amd64\" }}") - Expect(err).NotTo(HaveOccurred()) - - result, err := tmpl.ExecuteToString() - Expect(err).NotTo(HaveOccurred()) - Expect(result).To(Equal("true")) - }) - - It("evaluates arithmetic expressions", func() { - err := tmpl.Parse("{{ store[\"key2\"] * 2 }}") - Expect(err).NotTo(HaveOccurred()) - - result, err := tmpl.ExecuteToString() - Expect(err).NotTo(HaveOccurred()) - Expect(result).To(Equal("4")) - }) - }) - - Describe("control structures", func() { - It("handles if/else with expr conditions", func() { - template := ` - {{- if featureEnabled && ctx.workspace == "test_workspace" }} - Matched - {{- else }} - Unmatched - {{- end }} - ` - err := tmpl.Parse(template) - Expect(err).NotTo(HaveOccurred()) - - result, err := tmpl.ExecuteToString() - Expect(err).NotTo(HaveOccurred()) - Expect(strings.TrimSpace(result)).To(Equal("Matched")) - }) - - It("handles range with expr", func() { - template := ` -{{- range filter(executables, {.type == "exec"}) }} -{{ .name }}: {{ .tags }} -{{- end }} - ` - err := tmpl.Parse(template) - Expect(err).NotTo(HaveOccurred()) - - result, err := tmpl.ExecuteToString() - Expect(err).NotTo(HaveOccurred()) - expected := "exec2: []\nexec3: [tag tag2]" - Expect(strings.TrimSpace(result)).To(Equal(expected)) - }) - - It("handles with using expr", func() { - template := ` -{{- with ctx }} -Workspace: {{ .workspace }} -Namespace: {{ .namespace }} -{{- end }} - ` - err := tmpl.Parse(template) - Expect(err).NotTo(HaveOccurred()) - - result, err := tmpl.ExecuteToString() - Expect(err).NotTo(HaveOccurred()) - expected := "Workspace: test_workspace\nNamespace: test_namespace" - Expect(strings.TrimSpace(result)).To(Equal(expected)) - }) - - It("handles nested control structures with expr", func() { - GinkgoT().Skip("nested control structures not supported yet") - template := ` -{{- range executables }} -{{- $exec := . }} -{{- if len($exec.tags) > 0 }} -{{ .name }}: {{ .type }} -{{- end }} -{{- end }} - ` - err := tmpl.Parse(template) - Expect(err).NotTo(HaveOccurred()) - - result, err := tmpl.ExecuteToString() - Expect(err).NotTo(HaveOccurred()) - expected := "Item 1: 12.089 (with tax)\nItem 3: 16.5 (with tax)" - Expect(strings.TrimSpace(result)).To(Equal(expected)) - }) - }) - - Describe("error handling", func() { - It("handles invalid expressions", func() { - err := tmpl.Parse("{{ unknown.field }}") - Expect(err).NotTo(HaveOccurred()) - - _, err = tmpl.ExecuteToString() - Expect(err).To(HaveOccurred()) - }) - - It("handles invalid syntax in if conditions", func() { - err := tmpl.Parse("{{ if 1 ++ \"2\" }}invalid{{end}}") - Expect(err).NotTo(HaveOccurred()) - - _, err = tmpl.ExecuteToString() - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("Template with trim markers", func() { - It("handles trim markers in range", func() { - template := `start -{{- range workspaces }} -{{ . }} -{{- end }} -end` - err := tmpl.Parse(template) - Expect(err).NotTo(HaveOccurred()) - - result, err := tmpl.ExecuteToString() - Expect(err).NotTo(HaveOccurred()) - expected := "start\ntest_workspace\nother_workspace\nend" - Expect(result).To(Equal(expected)) - }) - - It("handles trim markers in if/else", func() { - template := `start -{{- if featureEnabled }} -enabled -{{- else }} -disabled -{{- end }} -end` - err := tmpl.Parse(template) - Expect(err).NotTo(HaveOccurred()) - - result, err := tmpl.ExecuteToString() - Expect(err).NotTo(HaveOccurred()) - expected := "start\nenabled\nend" - Expect(result).To(Equal(expected)) - }) - }) -}) diff --git a/internal/templates/artifacts.go b/internal/templates/artifacts.go index 6e7e6de1..2ddfcef3 100644 --- a/internal/templates/artifacts.go +++ b/internal/templates/artifacts.go @@ -7,11 +7,11 @@ import ( "path/filepath" "strings" + "github.com/jahvon/expression" "github.com/pkg/errors" "github.com/flowexec/flow/internal/filesystem" "github.com/flowexec/flow/internal/logger" - "github.com/flowexec/flow/internal/services/expr" "github.com/flowexec/flow/types/executable" ) @@ -46,7 +46,7 @@ func copyArtifact( } if artifact.If != "" { - eval, err := expr.IsTruthy(artifact.If, templateData) + eval, err := expression.IsTruthy(artifact.If, templateData) if err != nil { return errors.Wrap(err, "unable to evaluate if condition") } diff --git a/internal/templates/form.go b/internal/templates/form.go index 026e874a..70aef8e8 100644 --- a/internal/templates/form.go +++ b/internal/templates/form.go @@ -51,7 +51,7 @@ func showForm(ctx *context.Context, fields executable.FormFields) error { if err != nil { return fmt.Errorf("encountered form init error: %w", err) } - if err = form.Run(ctx.Ctx); err != nil { + if err = form.Run(ctx); err != nil { return fmt.Errorf("encountered form run error: %w", err) } for _, f := range fields { diff --git a/internal/templates/templates.go b/internal/templates/templates.go index a191da84..aea90457 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/jahvon/expression" "github.com/pkg/errors" "gopkg.in/yaml.v3" @@ -17,7 +18,6 @@ import ( "github.com/flowexec/flow/internal/logger" "github.com/flowexec/flow/internal/runner" "github.com/flowexec/flow/internal/runner/engine" - "github.com/flowexec/flow/internal/services/expr" "github.com/flowexec/flow/internal/utils" argUtils "github.com/flowexec/flow/internal/utils/env" execUtils "github.com/flowexec/flow/internal/utils/executables" @@ -114,7 +114,7 @@ func runExecutables( logger.Log().Debugf("running %d %s executables", len(execs), stage) for i, e := range execs { if e.If != "" { - eval, err := expr.IsTruthy(e.If, templateData) + eval, err := expression.IsTruthy(e.If, templateData) if err != nil { return errors.Wrap(err, "unable to evaluate if condition") } @@ -238,7 +238,7 @@ func templateToFlowfile( } func processAsGoTemplate(fileName, txt string, data expressionData) (*bytes.Buffer, error) { - tmpl := expr.NewTemplate(fileName, data) + tmpl := expression.NewTemplate(fileName, data) if err := tmpl.Parse(txt); err != nil { return nil, errors.Wrap(err, fmt.Sprintf("unable to parse %s template", fileName)) } diff --git a/internal/vault/v2/vault.go b/internal/vault/v2/vault.go index 44300c30..88937547 100644 --- a/internal/vault/v2/vault.go +++ b/internal/vault/v2/vault.go @@ -19,6 +19,7 @@ const ( LegacyVaultReservedName = "legacy" v2CacheDataDir = "vaults" + keyringService = "io.flowexec.flow" ) type Vault = vault.Provider @@ -101,6 +102,27 @@ func generateAESKey(keyEnv, logLevel string) string { return key } +func NewUnencryptedVault(name, storagePath string) { + storagePath = utils.ExpandPath(storagePath, CacheDirectory(""), nil) + if storagePath == "" { + logger.Log().Fatalf("unable to expand storage path: %s", storagePath) + } + + opts := []vault.Option{vault.WithUnencryptedPath(storagePath), vault.WithProvider(vault.ProviderTypeUnencrypted)} + + v, cfg, err := vault.New(name, opts...) + if err != nil { + logger.Log().FatalErr(err) + } + + cfgPath := ConfigFilePath(v.ID()) + if err = vault.SaveConfigJSON(*cfg, cfgPath); err != nil { + logger.Log().FatalErr(fmt.Errorf("unable to save vault config: %w", err)) + } + + logger.Log().PlainTextSuccess(fmt.Sprintf("Vault '%s' without encryption created successfully", v.ID())) +} + func NewAgeVault(name, storagePath, recipients, identityKey, identityFile string) { storagePath = utils.ExpandPath(storagePath, CacheDirectory(""), nil) if storagePath == "" { @@ -137,6 +159,51 @@ func NewAgeVault(name, storagePath, recipients, identityKey, identityFile string logger.Log().PlainTextSuccess(fmt.Sprintf("Vault '%s' with Age encryption created successfully", v.ID())) } +func NewKeyringVault(name string) { + opts := []vault.Option{ + vault.WithKeyringService(fmt.Sprintf("%s.%s", keyringService, name)), + vault.WithProvider(vault.ProviderTypeKeyring)} + v, cfg, err := vault.New(name, opts...) + if err != nil { + logger.Log().FatalErr(err) + } + + cfgPath := ConfigFilePath(v.ID()) + if err = vault.SaveConfigJSON(*cfg, cfgPath); err != nil { + logger.Log().FatalErr(fmt.Errorf("unable to save vault config: %w", err)) + } + + logger.Log().PlainTextSuccess(fmt.Sprintf("Vault '%s' with Keyring encryption created successfully", v.ID())) +} + +func NewExternalVault(providerConfigFile string) { + if providerConfigFile == "" { + logger.Log().Fatalf("provider config file path cannot be empty") + } + + providerConfigFile = utils.ExpandPath(providerConfigFile, CacheDirectory(""), nil) + if providerConfigFile == "" { + logger.Log().Fatalf("unable to expand provider config file path: %s", providerConfigFile) + } + + cfg, err := vault.LoadConfigJSON(providerConfigFile) + if err != nil { + logger.Log().FatalErr(fmt.Errorf("failed to load vault config: %w", err)) + } + + v, _, err := vault.New(cfg.ID, vault.WithExternalConfig(cfg.External)) + if err != nil { + logger.Log().FatalErr(err) + } + + cfgPath := ConfigFilePath(v.ID()) + if err = vault.SaveConfigJSON(cfg, cfgPath); err != nil { + logger.Log().FatalErr(fmt.Errorf("unable to save vault config: %w", err)) + } + + logger.Log().PlainTextSuccess(fmt.Sprintf("Vault '%s' with external provider registered successfully", v.ID())) +} + func VaultFromName(name string) (*VaultConfig, Vault, error) { if name == "" { return nil, nil, fmt.Errorf("vault name cannot be empty") @@ -157,6 +224,16 @@ func VaultFromName(name string) (*VaultConfig, Vault, error) { case vault.ProviderTypeAES256: provider, err := vault.NewAES256Vault(&cfg) return &cfg, provider, err + case vault.ProviderTypeUnencrypted: + provider, err := vault.NewUnencryptedVault(&cfg) + return &cfg, provider, err + case vault.ProviderTypeKeyring: + provider, err := vault.NewKeyringVault(&cfg) + return &cfg, provider, err + case vault.ProviderTypeExternal: + // todo: rename this func in the vault pkg + provider, err := vault.NewExternalVaultProvider(&cfg) + return &cfg, provider, err default: return nil, nil, fmt.Errorf("unsupported vault type: %s", cfg.Type) } diff --git a/main.go b/main.go index 37a5b887..fb8eed6e 100644 --- a/main.go +++ b/main.go @@ -42,14 +42,14 @@ func main() { } }() - ctx := context.NewContext(stdCtx.Background(), io.Stdin, io.Stdout) + bkgCtx, cancelFunc := stdCtx.WithCancel(stdCtx.Background()) + ctx := context.NewContext(bkgCtx, cancelFunc, io.Stdin, io.Stdout) defer ctx.Finalize() if ctx == nil { panic("failed to initialize context") } rootCmd := cmd.NewRootCmd(ctx) - ctx.Ctx, ctx.CancelFunc = stdCtx.WithCancel(ctx.Ctx) if err := cmd.Execute(ctx, rootCmd); err != nil { logger.Log().FatalErr(err) } diff --git a/scripts/completions.sh b/scripts/completions.sh index 084c05a2..f0fe83b4 100755 --- a/scripts/completions.sh +++ b/scripts/completions.sh @@ -1,7 +1,7 @@ #!/bin/sh set -e -rm -rf completions -mkdir completions +rm -rf scripts/completions +mkdir scripts/completions for sh in bash zsh fish; do - go run main.go completion "$sh" >"completions/flow.$sh" + go run main.go completion "$sh" >"scripts/completions/flow.$sh" done \ No newline at end of file diff --git a/completions/flow.bash b/scripts/completions/flow.bash similarity index 100% rename from completions/flow.bash rename to scripts/completions/flow.bash diff --git a/completions/flow.fish b/scripts/completions/flow.fish similarity index 100% rename from completions/flow.fish rename to scripts/completions/flow.fish diff --git a/completions/flow.zsh b/scripts/completions/flow.zsh similarity index 100% rename from completions/flow.zsh rename to scripts/completions/flow.zsh diff --git a/tests/browse_cmds_e2e_test.go b/tests/browse_cmds_e2e_test.go index 33672dba..401970ad 100644 --- a/tests/browse_cmds_e2e_test.go +++ b/tests/browse_cmds_e2e_test.go @@ -39,7 +39,7 @@ var _ = Describe("browse TUI", func() { return nil } - container = newTUIContainer(ctx.Ctx) + container = newTUIContainer(ctx) ctx.TUIContainer = container }) diff --git a/tests/utils/context.go b/tests/utils/context.go index 8159795f..624ae7ab 100644 --- a/tests/utils/context.go +++ b/tests/utils/context.go @@ -102,13 +102,12 @@ func NewContextWithMocks(ctx stdCtx.Context, tb testing.TB) *ContextWithMocks { wsCache := cacheMocks.NewMockWorkspaceCache(gomock.NewController(tb)) execCache := cacheMocks.NewMockExecutableCache(gomock.NewController(tb)) ctxx := &context.Context{ - Ctx: ctx, - CancelFunc: cancel, Config: testUserCfg, CurrentWorkspace: testWsCfg, WorkspacesCache: wsCache, ExecutableCache: execCache, } + ctxx.SetContext(ctx, cancel) ctxx.SetIO(null, null) return &ContextWithMocks{ Ctx: ctxx, @@ -120,7 +119,8 @@ func NewContextWithMocks(ctx stdCtx.Context, tb testing.TB) *ContextWithMocks { } func ResetTestContext(ctx *Context, tb testing.TB) { - ctx.Ctx = stdCtx.Background() + c, cancel := stdCtx.WithCancel(stdCtx.Background()) + ctx.SetContext(c, cancel) stdIn, stdOut := createTempIOFiles(tb) ctx.SetIO(stdIn, stdOut) setTestEnv(tb, ctx.configDir, ctx.cacheDir) @@ -173,13 +173,12 @@ func newTestContext( } ctxx := &context.Context{ - Ctx: ctx, - CancelFunc: cancel, Config: testCfg, CurrentWorkspace: testWsCfg, WorkspacesCache: wsCache, ExecutableCache: execCache, } + ctxx.SetContext(ctx, cancel) ctxx.SetIO(stdIn, stdOut) return ctxx, configDir, cacheDir, wsDir } diff --git a/tools/docsgen/main.go b/tools/docsgen/main.go index 82800e70..6ee3c01a 100644 --- a/tools/docsgen/main.go +++ b/tools/docsgen/main.go @@ -25,7 +25,8 @@ var ( func main() { fmt.Println("generating CLI docs...") - ctx := context.NewContext(stdCtx.Background(), os.Stdin, os.Stdout) + bkgCtx, cancelFunc := stdCtx.WithCancel(stdCtx.Background()) + ctx := context.NewContext(bkgCtx, cancelFunc, os.Stdin, os.Stdout) defer ctx.Finalize() rootCmd := cmd.NewRootCmd(ctx) diff --git a/types/executable/template.gen.go b/types/executable/template.gen.go index b9d2d36d..6d6cbb61 100644 --- a/types/executable/template.gen.go +++ b/types/executable/template.gen.go @@ -7,8 +7,9 @@ package executable 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. + // the form data. [Expr language + // functions](https://expr-lang.org/docs/language-definition) are available for + // use in the template. // AsTemplate bool `json:"asTemplate,omitempty" yaml:"asTemplate,omitempty" mapstructure:"asTemplate,omitempty"` diff --git a/types/executable/template_schema.yaml b/types/executable/template_schema.yaml index ef8df78c..41ddb7ec 100644 --- a/types/executable/template_schema.yaml +++ b/types/executable/template_schema.yaml @@ -30,7 +30,7 @@ definitions: 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. + the form data. [Expr language functions](https://expr-lang.org/docs/language-definition) are available for use in the template. srcDir: type: string default: ""