Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions cmd/internal/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,22 +126,29 @@ func workspaceOrCurrent(ctx *context.Context, workspaceName string) *workspace.W
}

func loadFlowfileTemplate(ctx *context.Context, name, path string) *executable.Template {
if name != "" {
if ctx.Config.Templates == nil {
logger.Log().Errorf("template %s not found", name)
return nil
}
var found bool
if path, found = ctx.Config.Templates[name]; !found {
logger.Log().Errorf("template %s not found", name)
return nil
}
} else {
if name == "" {
if _, err := os.Stat(path); os.IsNotExist(err) {
logger.Log().Errorf("flowfile template at %s not found", path)
return nil
}
return loadTemplateFromPath(name, path)
}

// Registered templates take precedence over discovered ones for backward compatibility.
if registeredPath, found := ctx.Config.Templates[name]; found {
return loadTemplateFromPath(name, registeredPath)
}

// Fall back to a workspace-discovered template resolved via the cache.
tmpl, err := ctx.TemplateCache.GetTemplate(name)
if err != nil {
logger.Log().Errorf("template %s not found", name)
return nil
}
return tmpl
}

func loadTemplateFromPath(name, path string) *executable.Template {
tmpl, err := filesystem.LoadFlowFileTemplate(name, path)
if err != nil {
logger.Log().WrapError(err, "unable to load flowfile template")
Expand Down
57 changes: 47 additions & 10 deletions cmd/internal/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package internal
import (
"fmt"
"maps"
"path/filepath"
"slices"
"strings"

Expand All @@ -11,13 +12,14 @@ import (
errhandler "github.com/flowexec/flow/v2/cmd/internal/errors"
"github.com/flowexec/flow/v2/cmd/internal/flags"
"github.com/flowexec/flow/v2/cmd/internal/response"
"github.com/flowexec/flow/v2/internal/io/executable"
iolib "github.com/flowexec/flow/v2/internal/io/executable"
"github.com/flowexec/flow/v2/internal/runner"
"github.com/flowexec/flow/v2/internal/runner/exec"
"github.com/flowexec/flow/v2/internal/templates"
"github.com/flowexec/flow/v2/pkg/context"
"github.com/flowexec/flow/v2/pkg/filesystem"
"github.com/flowexec/flow/v2/pkg/logger"
"github.com/flowexec/flow/v2/types/executable"
)

func RegisterTemplateCmd(ctx *context.Context, rootCmd *cobra.Command) {
Expand Down Expand Up @@ -116,6 +118,11 @@ func registerAddTemplateCmd(ctx *context.Context, templateCmd *cobra.Command) {
func addTemplateFunc(ctx *context.Context, cmd *cobra.Command, args []string) {
name := args[0]
flowFilePath := args[1]
// Normalize to an absolute path so the template resolves regardless of the cwd it was
// registered from.
if abs, err := filepath.Abs(flowFilePath); err == nil {
flowFilePath = abs
}
loadedTemplates, err := filesystem.LoadFlowFileTemplate(name, flowFilePath)
if err != nil {
errhandler.HandleFatal(ctx, cmd, err)
Expand Down Expand Up @@ -176,7 +183,7 @@ func registerListTemplateCmd(ctx *context.Context, templateCmd *cobra.Command) {
listCmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List registered flowfile templates.",
Short: "List registered and workspace-discovered 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) },
Expand All @@ -187,16 +194,20 @@ func registerListTemplateCmd(ctx *context.Context, templateCmd *cobra.Command) {
}

func listTemplateFunc(ctx *context.Context, cmd *cobra.Command, _ []string) {
// TODO: include unregistered templates within the current ws;
// add --annotation filter flags (mirroring browse / workspace list)
tmpls, err := filesystem.LoadFlowFileTemplates(ctx.Config.Templates)
// TODO: add --annotation filter flags (mirroring browse / workspace list)
registered, err := filesystem.LoadFlowFileTemplates(ctx.Config.Templates)
if err != nil {
errhandler.HandleFatal(ctx, cmd, err)
}
discovered, err := ctx.TemplateCache.GetTemplateList()
if err != nil {
errhandler.HandleFatal(ctx, cmd, err)
}
tmpls := mergeTemplates(registered, discovered)

outputFormat := flags.ValueFor[string](cmd, *flags.OutputFormatFlag, false)
if TUIEnabled(ctx, cmd) {
view := executable.NewTemplateListView(
view := iolib.NewTemplateListView(
ctx, tmpls,
func(name string) error {
tmpl := tmpls.Find(name)
Expand All @@ -214,8 +225,29 @@ func listTemplateFunc(ctx *context.Context, cmd *cobra.Command, _ []string) {
)
SetView(ctx, cmd, view)
} else {
executable.PrintTemplateList(outputFormat, tmpls)
iolib.PrintTemplateList(outputFormat, tmpls)
}
}

// mergeTemplates combines registered (global config) and discovered (workspace) templates
// into a single list keyed by name. Registered templates take precedence on name collisions.
func mergeTemplates(registered, discovered executable.TemplateList) executable.TemplateList {
byName := make(map[string]*executable.Template, len(registered)+len(discovered))
for _, tmpl := range discovered {
byName[tmpl.Name()] = tmpl
}
for _, tmpl := range registered {
byName[tmpl.Name()] = tmpl
}

merged := make(executable.TemplateList, 0, len(byName))
for _, tmpl := range byName {
merged = append(merged, tmpl)
}
slices.SortFunc(merged, func(a, b *executable.Template) int {
return strings.Compare(a.Name(), b.Name())
})
return merged
}

func registerGetTemplateCmd(ctx *context.Context, getCmd *cobra.Command) {
Expand Down Expand Up @@ -246,18 +278,23 @@ func getTemplateFunc(ctx *context.Context, cmd *cobra.Command, _ []string) {
outputFormat := flags.ValueFor[string](cmd, *flags.OutputFormatFlag, false)
if TUIEnabled(ctx, cmd) {
runFunc := func(ref string) error { return runByRef(ctx, cmd, ref) }
view := executable.NewTemplateView(ctx, tmpl, runFunc)
view := iolib.NewTemplateView(ctx, tmpl, runFunc)
SetView(ctx, cmd, view)
} else {
executable.PrintTemplate(outputFormat, tmpl)
iolib.PrintTemplate(outputFormat, tmpl)
}
}

const templateParentLong = `Manage flowfile templates. A template is a reusable flowfile scaffold that can generate
executables, directory structures, and configuration files via 'template generate'.

Templates are registered by name for easy reuse. Use 'template add' to register a
template, 'template generate' to scaffold from one, and 'template list' to see what's available.`
template, 'template generate' to scaffold from one, and 'template list' to see what's available.

Templates (*.flow.tmpl files) are also auto-discovered within your workspaces during
'flow sync' — dropping one into a workspace makes it available without registering it.
Registered templates take precedence over discovered ones when names collide; use a
'workspace/name' reference to target a specific discovered template.`

var templateLong = `Add rendered executables from a flowfile template to a workspace.

Expand Down
58 changes: 58 additions & 0 deletions cmd/internal/template_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package internal

import (
"testing"

"github.com/flowexec/flow/v2/types/executable"
)

func tmplWith(name, location string) *executable.Template {
t := &executable.Template{}
t.SetContext(name, location)
return t
}

func TestMergeTemplates_RegisteredWinsOnCollision(t *testing.T) {
registered := executable.TemplateList{
tmplWith("webapp", "/registered/webapp.flow.tmpl"),
}
discovered := executable.TemplateList{
tmplWith("webapp", "/discovered/webapp.flow.tmpl"),
tmplWith("service", "/discovered/service.flow.tmpl"),
}

merged := mergeTemplates(registered, discovered)

if len(merged) != 2 {
t.Fatalf("mergeTemplates returned %d templates, want 2", len(merged))
}

byName := map[string]string{}
for _, tmpl := range merged {
byName[tmpl.Name()] = tmpl.Location()
}

if got := byName["webapp"]; got != "/registered/webapp.flow.tmpl" {
t.Errorf("registered template should win on collision; got webapp=%q", got)
}
if got := byName["service"]; got != "/discovered/service.flow.tmpl" {
t.Errorf("discovered-only template should be present; got service=%q", got)
}
}

func TestMergeTemplates_SortedByName(t *testing.T) {
discovered := executable.TemplateList{
tmplWith("zeta", "/z.flow.tmpl"),
tmplWith("alpha", "/a.flow.tmpl"),
tmplWith("mid", "/m.flow.tmpl"),
}

merged := mergeTemplates(nil, discovered)

want := []string{"alpha", "mid", "zeta"}
for i, tmpl := range merged {
if tmpl.Name() != want[i] {
t.Fatalf("merged[%d].Name() = %q, want %q", i, tmpl.Name(), want[i])
}
}
}
7 changes: 6 additions & 1 deletion docs/cli/flow_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ executables, directory structures, and configuration files via 'template generat
Templates are registered by name for easy reuse. Use 'template add' to register a
template, 'template generate' to scaffold from one, and 'template list' to see what's available.

Templates (*.flow.tmpl files) are also auto-discovered within your workspaces during
'flow sync' — dropping one into a workspace makes it available without registering it.
Registered templates take precedence over discovered ones when names collide; use a
'workspace/name' reference to target a specific discovered template.

### Options

```
Expand All @@ -29,6 +34,6 @@ template, 'template generate' to scaffold from one, and 'template list' to see w
* [flow template add](flow_template_add.md) - Register a flowfile template by name.
* [flow template generate](flow_template_generate.md) - Generate workspace executables and scaffolding from a flowfile template.
* [flow template get](flow_template_get.md) - Get a flowfile template's details. Either it's registered name or file path can be used.
* [flow template list](flow_template_list.md) - List registered flowfile templates.
* [flow template list](flow_template_list.md) - List registered and workspace-discovered flowfile templates.
* [flow template remove](flow_template_remove.md) - Unregister a flowfile template by name.

2 changes: 1 addition & 1 deletion docs/cli/flow_template_list.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## flow template list

List registered flowfile templates.
List registered and workspace-discovered flowfile templates.

```
flow template list [flags]
Expand Down
79 changes: 71 additions & 8 deletions docs/guides/templating.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,20 @@ template: |
cmd: "npm run build"
```

Register and use it:
Use it. If the file lives inside a workspace, `flow sync` discovers it automatically — no registration needed:

```shell
# Register the template
flow template add webapp ./webapp.flow.tmpl
# Discover templates in your workspaces
flow sync

# Generate from the template (named after the file: webapp.flow.tmpl -> "webapp")
flow template generate my-app --template webapp
```

# Generate from template
Or register it explicitly — handy when the file lives outside a workspace or you want it available everywhere:

```shell
flow template add webapp ./webapp.flow.tmpl
flow template generate my-app --template webapp
```

Expand Down Expand Up @@ -194,26 +201,82 @@ template: |

See the [template command reference](../cli/flow_template.md) for all detailed commands and options.

There are two ways to make a template available by name:

- **Auto-discovery** — drop a `.flow.tmpl` file anywhere in a workspace and it's found automatically. Zero configuration.
- **Registration** — register a template by name in your global config. Useful for templates that live *outside* any workspace, or that you want available from *every* workspace.

### Auto-discovery

Any file ending in `.flow.tmpl` (also `.flow.tmpl.yaml` / `.flow.tmpl.yml`) inside a workspace is
discovered during `flow sync`, the same way executables (`.flow` files) are. No `flow template add` required.

```shell
# Create a template inside a workspace
touch ./my-workspace/webapp.flow.tmpl

# Discover it (and any new executables) by refreshing the cache
flow sync

# It now shows up alongside registered templates
flow template list

# Generate from it by name — the name is derived from the filename (webapp.flow.tmpl -> "webapp")
flow template generate my-app --template webapp
```

Discovery is safe by design: only files with the exact `.flow.tmpl` extension are picked up, so
artifact/partial files such as `deployment.yaml.tmpl` are never mistaken for template generators.

**Scoping discovery.** By default the whole workspace is scanned (minus common exclusions like
`node_modules/`, `vendor/`, and `.git/`). To narrow it, add a `templates` filter to the workspace's
`flow.yaml` — it uses the same `included`/`excluded` semantics as the `executables` filter:

```yaml
# flow.yaml
templates:
included:
- templates/
excluded:
- templates/wip/
```

**Name collisions.** If the same template name is discovered in more than one workspace, `flow sync`
logs a warning. Use a `workspace/name` reference to target a specific one:

```shell
flow template generate my-app --template my-workspace/webapp
```

### Register Templates

Registration stores a name → path mapping in your global config. Reach for it when a template lives
outside a workspace, or when you want it usable from any workspace.

```shell
# From file
# From file (the path is stored as an absolute path)
flow template add webapp ./templates/webapp.flow.tmpl

# List registered templates
# List templates (registered + discovered)
flow template list

# View template details
flow template get -t webapp
```

> [!NOTE]
> When a registered name and a discovered name collide, the **registered** template wins.

### Generate from Templates

```shell
# Using registered template
# Using a template by name (registered or discovered)
flow template generate my-app --template webapp

# Using file directly
# Disambiguate a discovered template by workspace
flow template generate my-app --template my-workspace/webapp

# Using a file directly (no registration or discovery needed)
flow template generate my-app --file ./webapp.flow.tmpl

# Specify workspace and output directory
Expand Down
4 changes: 4 additions & 0 deletions docs/public/schemas/workspace_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@
"$ref": "#/definitions/CommonTags",
"default": []
},
"templates": {
"$ref": "#/definitions/ExecutableFilter",
"description": "Filters controlling which flowfile template files (*.flow.tmpl) are auto-discovered\nwithin the workspace during `flow sync`. Uses the same include/exclude semantics as\nthe executables filter. When unset, the entire workspace is scanned (minus the default\nexclusions like node_modules/, vendor/, and .git/).\n"
},
"verbAliases": {
"$ref": "#/definitions/VerbAliases"
}
Expand Down
1 change: 1 addition & 0 deletions docs/types/workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Every workspace has a workspace config file named `flow.yaml` in the root of the
| `gitRefType` | The type of git ref specified when the workspace was added. Either "branch" or "tag". Empty if no ref was specified. | `string` | | |
| `gitRemote` | The git remote URL for the workspace. This is set automatically when a workspace is added from a git URL. Used by `flow workspace update` to pull the latest changes. | `string` | | |
| `tags` | | [CommonTags](#commontags) | [] | |
| `templates` | Filters controlling which flowfile template files (*.flow.tmpl) are auto-discovered within the workspace during `flow sync`. Uses the same include/exclude semantics as the executables filter. When unset, the entire workspace is scanned (minus the default exclusions like node_modules/, vendor/, and .git/). | [ExecutableFilter](#executablefilter) | | |
| `verbAliases` | | [VerbAliases](#verbaliases) | | |


Expand Down
4 changes: 4 additions & 0 deletions internal/validation/workspace_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@
"$ref": "#/definitions/CommonTags",
"default": []
},
"templates": {
"$ref": "#/definitions/ExecutableFilter",
"description": "Filters controlling which flowfile template files (*.flow.tmpl) are auto-discovered\nwithin the workspace during `flow sync`. Uses the same include/exclude semantics as\nthe executables filter. When unset, the entire workspace is scanned (minus the default\nexclusions like node_modules/, vendor/, and .git/).\n"
},
"verbAliases": {
"$ref": "#/definitions/VerbAliases"
}
Expand Down
Loading
Loading