diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index e3061f2..b60b1e1 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -53,7 +53,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
- go-version: "1.24"
+ go-version: "1.25.10"
cache: true
- run: go mod download
- run: go test ./... -v
@@ -63,6 +63,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: AxeForging/reviewforge@main
+ continue-on-error: true
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AI_PROVIDER: gemini
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index ecbbf7f..fdc01d2 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -16,7 +16,7 @@ jobs:
- uses: actions/setup-go@v5
with:
- go-version: "1.24"
+ go-version: "1.25.10"
cache: true
- name: Download dependencies
diff --git a/.structlint.yaml b/.structlint.yaml
index 4930ee3..b2738df 100644
--- a/.structlint.yaml
+++ b/.structlint.yaml
@@ -39,6 +39,9 @@ file_naming_pattern:
- "*.toml"
- "*.md"
- "*.txt"
+ - "*.css"
+ - "*.js"
+ - "*.tmpl"
- "*.png"
- "*.jpg"
- "*.svg"
diff --git a/COMMANDS.md b/COMMANDS.md
new file mode 100644
index 0000000..47b0fff
--- /dev/null
+++ b/COMMANDS.md
@@ -0,0 +1,134 @@
+# YankRun Commands
+
+Quick reference for the commands available in the single `yankrun` binary.
+
+## `template`
+
+Template an existing directory in place.
+
+```sh
+yankrun template --dir ./project --input values.yaml
+yankrun template --dir ./project --input values.yaml --dryRun
+yankrun template --dir ./project --input values.yaml --processTemplates
+```
+
+Useful when you already have a working tree and want to replace placeholders safely.
+
+Key flags:
+
+| Flag | Description |
+|------|-------------|
+| `--dir`, `-d` | Directory to process |
+| `--input`, `-i` | YAML/JSON values file |
+| `--dryRun`, `--dr` | Preview without writing |
+| `--startDelim`, `--sd` | Start delimiter, default `[[` |
+| `--endDelim`, `--ed` | End delimiter, default `]]` |
+| `--processTemplates`, `--pt` | Process `.tpl` files and remove `.tpl` suffix |
+| `--onlyTemplates`, `--ot` | Only process `.tpl` files |
+| `--ignore` | Glob pattern to skip |
+
+## `clone`
+
+Clone a repository and apply replacements.
+
+```sh
+yankrun clone \
+ --repo https://github.com/AxeForging/template-tester.git \
+ --input values.yaml \
+ --outputDir ./my-project
+```
+
+SSH works when your local SSH key or agent can access the repo:
+
+```sh
+yankrun clone \
+ --repo git@github.com:AxeForging/template-tester.git \
+ --branch main \
+ --outputDir ./my-project
+```
+
+Key flags:
+
+| Flag | Description |
+|------|-------------|
+| `--repo` | HTTPS or SSH Git URL |
+| `--branch`, `-b` | Branch to clone |
+| `--outputDir`, `-o` | Output directory |
+| `--input`, `-i` | YAML/JSON values file |
+| `--dryRun`, `--dr` | Preview without leaving output behind |
+
+## `generate`
+
+Choose from configured template repos and generate a project.
+
+```sh
+yankrun generate --prompt
+yankrun generate --template go-service --input values.yaml --outputDir ./new-service
+```
+
+Templates come from `~/.yankrun/config.yaml` and optional GitHub discovery.
+
+Key flags:
+
+| Flag | Description |
+|------|-------------|
+| `--template`, `-t` | Template name/filter |
+| `--branch`, `-b` | Branch to clone |
+| `--outputDir`, `-o` | Output directory |
+| `--input`, `-i` | YAML/JSON values file |
+| `--noCache`, `--nc` | Bypass cached GitHub/template data |
+
+## `serve`
+
+Open the local web workbench.
+
+```sh
+yankrun serve --dir ./project --input values.yaml
+yankrun serve --dir ./project --addr 127.0.0.1:19090
+yankrun serve --dir ./project --dryRun
+```
+
+The server binds to `127.0.0.1:17817` by default.
+
+Use `serve` when you want:
+
+- Local, Clone, and Generate modes in one UI
+- file-level placeholder trees
+- evaluated transform previews
+- idle refresh while editing values
+- saved presets per repo/template in browser IndexedDB
+- JSON import/export for presets
+
+Key flags:
+
+| Flag | Description |
+|------|-------------|
+| `--dir`, `-d` | Local directory for Local mode |
+| `--input`, `-i` | YAML/JSON values file |
+| `--addr` | Listen address |
+| `--dryRun`, `--dr` | Force preview-only mode |
+| `--ignore` | Glob pattern to skip |
+
+## `tui`
+
+Run a conservative terminal workflow for local templating.
+
+```sh
+yankrun tui --dir ./project --input values.yaml
+yankrun tui --dir ./project --dryRun
+```
+
+The TUI scans, summarizes, previews replacement counts, and writes only when not in dry-run mode.
+
+## `setup`
+
+Manage `~/.yankrun/config.yaml`.
+
+```sh
+yankrun setup
+yankrun setup --show
+yankrun setup --reset
+```
+
+Use this to configure default delimiters, file size limits, template repos, and GitHub discovery.
+
diff --git a/EXAMPLES.md b/EXAMPLES.md
new file mode 100644
index 0000000..7a31081
--- /dev/null
+++ b/EXAMPLES.md
@@ -0,0 +1,158 @@
+# YankRun Examples
+
+Practical examples for common template workflows.
+
+## Try the public sample template
+
+```sh
+cat > values.yaml <<'EOF'
+variables:
+ - key: APP_NAME
+ value: TemplateTester
+ - key: PROJECT_NAME
+ value: DemoProject
+ - key: USER_NAME
+ value: tester
+ - key: USER_EMAIL
+ value: tester@example.com
+ - key: VERSION
+ value: 9.9.9
+EOF
+
+yankrun clone \
+ --repo https://github.com/AxeForging/template-tester.git \
+ --input values.yaml \
+ --outputDir ./template-test
+```
+
+## Preview a repo in the web workbench
+
+```sh
+yankrun serve --dir ./template-test --input values.yaml
+```
+
+Open `http://127.0.0.1:17817`, then use:
+
+- **Local** for the directory passed with `--dir`
+- **Clone** for a direct SSH/HTTPS repo URL
+- **Generate** for templates configured in `~/.yankrun/config.yaml`
+
+The workbench shows a file tree for each placeholder and evaluated transform previews such as `APP_NAME:toUpperCase -> TEMPLATETESTER`.
+
+## Clone with SSH auth
+
+```sh
+yankrun clone \
+ --repo git@github.com:AxeForging/template-tester.git \
+ --branch main \
+ --input values.yaml \
+ --outputDir ./template-test
+```
+
+This uses the same local SSH setup as Git. If your key or agent works with `git clone`, YankRun should work too.
+
+## Configure templates for `generate`
+
+Create or edit `~/.yankrun/config.yaml`:
+
+```yaml
+templates:
+ - name: template-tester
+ url: https://github.com/AxeForging/template-tester.git
+ description: YankRun sample template repo
+ default_branch: main
+```
+
+Then run:
+
+```sh
+yankrun generate --template template-tester --input values.yaml --outputDir ./generated-app
+```
+
+Or use the **Generate** tab in `yankrun serve`.
+
+## Template an existing project
+
+```sh
+cat > updates.yaml <<'EOF'
+variables:
+ - key: COMPANY_NAME
+ value: AxeForge
+ - key: SUPPORT_EMAIL
+ value: support@example.com
+ - key: VERSION
+ value: 2.0.0
+EOF
+
+yankrun template --dir ./project --input updates.yaml --dryRun
+yankrun template --dir ./project --input updates.yaml
+```
+
+## Avoid Helm or Go template conflicts
+
+YankRun defaults to `[[` and `]]`, so it will not touch Helm/Go template syntax like `{{ .Values.name }}`.
+
+If a project already uses `[[` and `]]`, choose custom delimiters:
+
+```sh
+yankrun template \
+ --dir ./project \
+ --input values.yaml \
+ --startDelim "<%" \
+ --endDelim "%>"
+```
+
+Template files can then contain:
+
+```text
+name: <%APP_NAME%>
+version: <%VERSION%>
+```
+
+## Process `.tpl` files
+
+```sh
+yankrun template \
+ --dir ./project \
+ --input values.yaml \
+ --processTemplates
+```
+
+`README.md.tpl` becomes `README.md` after template processing. Existing target files are not overwritten silently.
+
+## Use transforms
+
+Template:
+
+```text
+Package: [[APP_NAME:toLowerCase]]
+Constant: [[APP_NAME:toUpperCase]]
+URL slug: [[PROJECT_NAME:gsub( ,-):toLowerCase]]
+```
+
+Values:
+
+```yaml
+variables:
+ - key: APP_NAME
+ value: My App
+ - key: PROJECT_NAME
+ value: My Project
+```
+
+Result:
+
+```text
+Package: my app
+Constant: MY APP
+URL slug: my-project
+```
+
+## Save and reuse presets in `serve`
+
+1. Run `yankrun serve --dir ./project --input values.yaml`.
+2. Preview a Local, Clone, or Generate run.
+3. The run is saved locally in browser IndexedDB.
+4. Use the left preset rail to search by repo, template, branch, output directory, or value keys.
+5. Export/import presets as JSON when moving between browsers.
+
diff --git a/README.md b/README.md
index 136856c..6d3dedc 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
-
+
@@ -25,6 +25,9 @@ yankrun clone --repo https://github.com/AxeForging/template-tester.git \
# Or template an existing directory
yankrun template --dir ./my-project --input values.yaml --verbose
+# Or use the local web workbench
+yankrun serve --dir ./my-project --input values.yaml
+
# Works alongside Helm/Jinja/Go templates — default [[ ]] won't touch {{ }}
yankrun template --dir ./helm-chart --input values.yaml
@@ -44,12 +47,16 @@ yankrun template --dir ./project --input values.yaml --startDelim "<%" --endDeli
- **Transformation functions** (`toUpperCase`, `toLowerCase`, `gsub`)
- **Template file processing** (`.tpl` files processed and renamed)
- **Caching** for `generate` - caches GitHub repos and template variables in `~/.yankrun/cache.yaml`
+- **Interactive workbench** (`serve`) for local/clone/generate workflows with file trees, evaluated transform previews, saved presets, and JSON import/export
+- **Safe terminal workflow** (`tui`) for preview-first directory templating
## Documentation
| Audience | Link |
|----------|------|
| **Users** | [docs/user/README.md](docs/user/README.md) - Installation, usage, examples |
+| **Commands** | [COMMANDS.md](COMMANDS.md) - Quick command and flag reference |
+| **Examples** | [EXAMPLES.md](EXAMPLES.md) - Copy-paste workflows for common use cases |
| **AI Assistants** | [docs/AI/README.md](docs/AI/README.md) - Architecture, testing, common tasks |
| **Transformations** | [doc/functions.md](doc/functions.md) - Function reference |
@@ -97,7 +104,7 @@ Move-Item -Path yankrun-windows-amd64.exe -Destination yankrun.exe
-From Source (Go 1.24+)
+From Source (Go 1.25+)
```sh
go install github.com/AxeForging/yankrun@latest
@@ -278,6 +285,58 @@ Requires templates configured in `~/.yankrun/config.yaml` or GitHub discovery en
The `generate` command caches GitHub-discovered repos and template variables in `~/.yankrun/cache.yaml`. Subsequent dry runs use cached data to avoid re-cloning. Use `--noCache` to bypass.
+
+
+
+serve - Local web workbench
+
+```sh
+# Serve a local directory at http://127.0.0.1:17817
+yankrun serve --dir ./my-project --input values.yaml
+
+# Use a different bind address
+yankrun serve --dir ./my-project --addr 127.0.0.1:19090
+
+# Force preview-only mode
+yankrun serve --dir ./my-project --input values.yaml --dryRun
+```
+
+The workbench supports:
+
+- local scan, preview, and apply using the same replacement logic as `template`
+- clone from SSH or HTTPS repositories using the existing cloner/auth behavior
+- generate from configured templates and GitHub discovery
+- file-level placeholder trees and evaluated transform previews
+- local saved presets in IndexedDB, with JSON import/export
+
+**Flags:**
+
+| Flag | Alias | Description | Default |
+|------|-------|-------------|---------|
+| `--dir` | `-d` | Directory to scan/apply for local mode | - |
+| `--input` | `-i` | Values file (JSON/YAML) | - |
+| `--addr` | | Web listen address | `127.0.0.1:17817` |
+| `--startDelim` | `--sd` | Start delimiter | `[[` |
+| `--endDelim` | `--ed` | End delimiter | `]]` |
+| `--fileSizeLimit` | `--fl` | Skip files larger than | `3 mb` |
+| `--processTemplates` | `--pt` | Process `.tpl` files | `false` |
+| `--onlyTemplates` | `--ot` | Only process `.tpl` files | `false` |
+| `--dryRun` | `--dr` | Block writes and preview only | `false` |
+| `--ignore` | | Glob patterns to skip | - |
+| `--verbose` | `-v` | Verbose output | `false` |
+
+
+
+
+tui - Safe terminal workflow
+
+```sh
+yankrun tui --dir ./my-project --input values.yaml
+yankrun tui --dir ./my-project --dryRun
+```
+
+The TUI scans a local directory, prints the discovered placeholders, previews the replacement count, and only writes when not in `--dryRun` mode.
+
diff --git a/actions/clone.go b/actions/clone.go
index e2ea9e1..ada6248 100644
--- a/actions/clone.go
+++ b/actions/clone.go
@@ -35,9 +35,7 @@ func (a *CloneAction) Execute(c *cli.Context) error {
outputDir := c.String("outputDir")
verbose := c.Bool("verbose")
input := c.String("input")
- fileSizeLimit := c.String("fileSizeLimit")
- startDelim := c.String("startDelim")
- endDelim := c.String("endDelim")
+ branch := c.String("branch")
interactive := c.Bool("interactive")
processTemplates := c.Bool("processTemplates")
onlyTemplates := c.Bool("onlyTemplates")
@@ -53,23 +51,13 @@ func (a *CloneAction) Execute(c *cli.Context) error {
if cfg == nil {
cfg = &domain.Config{}
}
- if startDelim == "" && cfg.StartDelim != "" {
- startDelim = cfg.StartDelim
- }
- if endDelim == "" && cfg.EndDelim != "" {
- endDelim = cfg.EndDelim
- }
- if fileSizeLimit == "" && cfg.FileSizeLimit != "" {
- fileSizeLimit = cfg.FileSizeLimit
- }
- if startDelim == "" {
- startDelim = "[["
- }
- if endDelim == "" {
- endDelim = "]]"
+ startDelim, endDelim, fileSizeLimit := templateSettings(c, cfg)
+
+ if repoURL == "" {
+ return fmt.Errorf("--repo is required for clone command")
}
- if fileSizeLimit == "" {
- fileSizeLimit = "3 mb"
+ if outputDir == "" && !dryRun {
+ return fmt.Errorf("--outputDir is required for clone command")
}
// Validate flag combination
@@ -77,15 +65,35 @@ func (a *CloneAction) Execute(c *cli.Context) error {
return fmt.Errorf("--onlyTemplates requires --processTemplates to be set")
}
- if err := a.fs.EnsureDir(outputDir); err != nil {
- return err
+ workDir := outputDir
+ if dryRun {
+ tmp, err := os.MkdirTemp("", "yankrun-clone-dryrun-*")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(tmp)
+ workDir = tmp
+ } else {
+ if err := a.fs.EnsureDir(outputDir); err != nil {
+ return err
+ }
}
- if err := a.cloner.CloneRepository(repoURL, outputDir); err != nil {
- return err
+ if branch != "" {
+ if err := a.cloner.CloneRepositoryBranch(repoURL, branch, workDir); err != nil {
+ return err
+ }
+ } else {
+ if err := a.cloner.CloneRepository(repoURL, workDir); err != nil {
+ return err
+ }
}
- helpers.Log.Info().Msgf("Cloned into %s", outputDir)
+ if dryRun {
+ helpers.Log.Info().Msg("Cloned into temporary directory for dry run")
+ } else {
+ helpers.Log.Info().Msgf("Cloned into %s", outputDir)
+ }
// Parse provided replacements if any
var provided domain.InputReplacement
@@ -101,7 +109,7 @@ func (a *CloneAction) Execute(c *cli.Context) error {
ignorePatterns := append(ignoreFlags, provided.IgnorePath...)
// Analyze placeholders in cloned directory
- counts, err := a.replacer.AnalyzeDir(outputDir, fileSizeLimit, startDelim, endDelim, onlyTemplates, ignorePatterns)
+ counts, err := a.replacer.AnalyzeDir(workDir, fileSizeLimit, startDelim, endDelim, onlyTemplates, ignorePatterns)
if err != nil {
return err
}
@@ -166,14 +174,14 @@ func (a *CloneAction) Execute(c *cli.Context) error {
// Skip regular templating if onlyTemplates is set
if !onlyTemplates {
- if err := a.replacer.ReplaceInDir(outputDir, final, fileSizeLimit, startDelim, endDelim, verbose, ignorePatterns); err != nil {
+ if err := a.replacer.ReplaceInDir(workDir, final, fileSizeLimit, startDelim, endDelim, verbose, ignorePatterns); err != nil {
return err
}
}
// Process .tpl files if requested
if processTemplates {
- if err := a.replacer.ProcessTemplateFiles(outputDir, final, fileSizeLimit, startDelim, endDelim, verbose, ignorePatterns); err != nil {
+ if err := a.replacer.ProcessTemplateFiles(workDir, final, fileSizeLimit, startDelim, endDelim, verbose, ignorePatterns); err != nil {
return err
}
helpers.Log.Info().Msg("Template file processing complete.")
diff --git a/actions/generate.go b/actions/generate.go
index 89ff719..f60569f 100644
--- a/actions/generate.go
+++ b/actions/generate.go
@@ -31,9 +31,6 @@ func (a *GenerateAction) Execute(c *cli.Context) error {
// parse flags first for non-interactive allowance
interactivePrompt := c.Bool("interactive")
input := c.String("input")
- startDelim := c.String("startDelim")
- endDelim := c.String("endDelim")
- fileSizeLimit := c.String("fileSizeLimit")
verbose := c.Bool("verbose")
outputDir := c.String("outputDir")
templateFilter := c.String("template")
@@ -76,25 +73,7 @@ func (a *GenerateAction) Execute(c *cli.Context) error {
_ = services.Save(cfg)
}
- // Fill defaults from config
- if startDelim == "" && cfg.StartDelim != "" {
- startDelim = cfg.StartDelim
- }
- if endDelim == "" && cfg.EndDelim != "" {
- endDelim = cfg.EndDelim
- }
- if fileSizeLimit == "" && cfg.FileSizeLimit != "" {
- fileSizeLimit = cfg.FileSizeLimit
- }
- if startDelim == "" {
- startDelim = "[["
- }
- if endDelim == "" {
- endDelim = "]]"
- }
- if fileSizeLimit == "" {
- fileSizeLimit = "3 mb"
- }
+ startDelim, endDelim, fileSizeLimit := templateSettings(c, cfg)
// Load cache
cache, _ := services.LoadCache()
@@ -279,7 +258,7 @@ func (a *GenerateAction) Execute(c *cli.Context) error {
}
}
- if outputDir == "" {
+ if outputDir == "" && !dryRun {
fmt.Printf("Output directory [./new-project]: ")
out, _ := r.ReadString('\n')
out = strings.TrimSpace(out)
@@ -289,20 +268,34 @@ func (a *GenerateAction) Execute(c *cli.Context) error {
outputDir = out
}
- if err := a.fs.EnsureDir(outputDir); err != nil {
- return err
+ workDir := outputDir
+ if dryRun {
+ tmp, err := os.MkdirTemp("", "yankrun-generate-dryrun-*")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(tmp)
+ workDir = tmp
+ } else {
+ if err := a.fs.EnsureDir(outputDir); err != nil {
+ return err
+ }
}
- if err := a.cloner.CloneRepositoryBranch(chosen.URL, br, outputDir); err != nil {
+ if err := a.cloner.CloneRepositoryBranch(chosen.URL, br, workDir); err != nil {
return err
}
- helpers.Log.Info().Msgf("Cloned %s@%s into %s", chosen.Name, br, outputDir)
+ if dryRun {
+ helpers.Log.Info().Msgf("Cloned %s@%s into temporary directory for dry run", chosen.Name, br)
+ } else {
+ helpers.Log.Info().Msgf("Cloned %s@%s into %s", chosen.Name, br, outputDir)
+ }
// Get HEAD SHA before removing .git for cache
- headSHA, _ := services.HeadSHA(outputDir)
+ headSHA, _ := services.HeadSHA(workDir)
// Remove .git directory to make it a fresh repo
- gitDir := filepath.Join(outputDir, ".git")
+ gitDir := filepath.Join(workDir, ".git")
if err := os.RemoveAll(gitDir); err != nil {
return fmt.Errorf("failed to remove %s: %w", gitDir, err)
}
@@ -321,7 +314,7 @@ func (a *GenerateAction) Execute(c *cli.Context) error {
ignorePatterns := append(ignoreFlags, provided.IgnorePath...)
// Analyze placeholders
- counts, err := a.replacer.AnalyzeDir(outputDir, fileSizeLimit, startDelim, endDelim, onlyTemplates, ignorePatterns)
+ counts, err := a.replacer.AnalyzeDir(workDir, fileSizeLimit, startDelim, endDelim, onlyTemplates, ignorePatterns)
if err != nil {
return err
}
@@ -406,14 +399,14 @@ func (a *GenerateAction) Execute(c *cli.Context) error {
// Skip regular templating if onlyTemplates is set
if !onlyTemplates {
- if err := a.replacer.ReplaceInDir(outputDir, final, fileSizeLimit, startDelim, endDelim, verbose, ignorePatterns); err != nil {
+ if err := a.replacer.ReplaceInDir(workDir, final, fileSizeLimit, startDelim, endDelim, verbose, ignorePatterns); err != nil {
return err
}
}
// Process .tpl files if requested
if processTemplates {
- if err := a.replacer.ProcessTemplateFiles(outputDir, final, fileSizeLimit, startDelim, endDelim, verbose, ignorePatterns); err != nil {
+ if err := a.replacer.ProcessTemplateFiles(workDir, final, fileSizeLimit, startDelim, endDelim, verbose, ignorePatterns); err != nil {
return err
}
helpers.Log.Info().Msg("Template file processing complete.")
diff --git a/actions/serve.go b/actions/serve.go
new file mode 100644
index 0000000..c8d4d43
--- /dev/null
+++ b/actions/serve.go
@@ -0,0 +1,78 @@
+package actions
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AxeForging/yankrun/domain"
+ "github.com/AxeForging/yankrun/helpers"
+ "github.com/AxeForging/yankrun/internal/web"
+ "github.com/AxeForging/yankrun/services"
+ "github.com/urfave/cli"
+)
+
+type ServeAction struct {
+ fs services.FileSystem
+ parser services.ReplacementParser
+ replacer services.Replacer
+ cloner services.Cloner
+}
+
+func NewServeAction(fs services.FileSystem, parser services.ReplacementParser, replacer services.Replacer, cloner services.Cloner) *ServeAction {
+ return &ServeAction{fs: fs, parser: parser, replacer: replacer, cloner: cloner}
+}
+
+func (a *ServeAction) Execute(c *cli.Context) error {
+ dir := c.String("dir")
+ if dir == "" {
+ return fmt.Errorf("--dir is required for serve command")
+ }
+ if c.Bool("onlyTemplates") && !c.Bool("processTemplates") {
+ return fmt.Errorf("--onlyTemplates requires --processTemplates to be set")
+ }
+
+ cfg, _ := services.Load()
+ startDelim, endDelim, fileSizeLimit := templateSettings(c, cfg)
+
+ var provided domain.InputReplacement
+ if input := c.String("input"); input != "" {
+ parsed, err := a.parser.Parse(input)
+ if err != nil {
+ return err
+ }
+ provided = parsed
+ }
+
+ addr := c.String("addr")
+ if addr == "" {
+ addr = "127.0.0.1:17817"
+ }
+ if host, _, err := net.SplitHostPort(addr); err == nil && host == "" {
+ addr = "127.0.0.1" + addr
+ }
+
+ server, err := web.New(web.Options{
+ Addr: addr,
+ Dir: dir,
+ Input: c.String("input"),
+ StartDelim: startDelim,
+ EndDelim: endDelim,
+ FileSizeLimit: fileSizeLimit,
+ IgnorePatterns: append(c.StringSlice("ignore"), provided.IgnorePath...),
+ ProcessTemplates: c.Bool("processTemplates"),
+ OnlyTemplates: c.Bool("onlyTemplates"),
+ ForceDryRun: c.Bool("dryRun"),
+ Verbose: c.Bool("verbose"),
+ Parser: a.parser,
+ Replacer: a.replacer,
+ Cloner: a.cloner,
+ Config: cfg,
+ })
+ if err != nil {
+ return fmt.Errorf("init web server: %w", err)
+ }
+
+ helpers.Log.Info().Msgf("YankRun web UI listening on http://%s", server.Addr())
+ fmt.Printf("\n Open http://%s in your browser.\n\n", server.Addr())
+ return server.ListenAndServe()
+}
diff --git a/actions/settings.go b/actions/settings.go
new file mode 100644
index 0000000..0d5b5ab
--- /dev/null
+++ b/actions/settings.go
@@ -0,0 +1,36 @@
+package actions
+
+import (
+ "github.com/AxeForging/yankrun/domain"
+ "github.com/urfave/cli"
+)
+
+func templateSettings(c *cli.Context, cfg *domain.Config) (string, string, string) {
+ startDelim := c.String("startDelim")
+ endDelim := c.String("endDelim")
+ fileSizeLimit := c.String("fileSizeLimit")
+
+ if cfg == nil {
+ cfg = &domain.Config{}
+ }
+ if !c.IsSet("startDelim") && cfg.StartDelim != "" {
+ startDelim = cfg.StartDelim
+ }
+ if !c.IsSet("endDelim") && cfg.EndDelim != "" {
+ endDelim = cfg.EndDelim
+ }
+ if !c.IsSet("fileSizeLimit") && cfg.FileSizeLimit != "" {
+ fileSizeLimit = cfg.FileSizeLimit
+ }
+ if startDelim == "" {
+ startDelim = "[["
+ }
+ if endDelim == "" {
+ endDelim = "]]"
+ }
+ if fileSizeLimit == "" {
+ fileSizeLimit = "3 mb"
+ }
+
+ return startDelim, endDelim, fileSizeLimit
+}
diff --git a/actions/template.go b/actions/template.go
index b93f9e6..deb2db7 100644
--- a/actions/template.go
+++ b/actions/template.go
@@ -29,9 +29,6 @@ func (t *TemplateAction) Execute(c *cli.Context) error {
dir := c.String("dir")
verbose := c.Bool("verbose")
interactive := c.Bool("interactive")
- startDelim := c.String("startDelim")
- endDelim := c.String("endDelim")
- fileSizeLimit := c.String("fileSizeLimit")
processTemplates := c.Bool("processTemplates")
onlyTemplates := c.Bool("onlyTemplates")
dryRun := c.Bool("dryRun")
@@ -51,24 +48,7 @@ func (t *TemplateAction) Execute(c *cli.Context) error {
if cfg == nil {
cfg = &domain.Config{}
}
- if startDelim == "" && cfg.StartDelim != "" {
- startDelim = cfg.StartDelim
- }
- if endDelim == "" && cfg.EndDelim != "" {
- endDelim = cfg.EndDelim
- }
- if fileSizeLimit == "" && cfg.FileSizeLimit != "" {
- fileSizeLimit = cfg.FileSizeLimit
- }
- if startDelim == "" {
- startDelim = "[["
- }
- if endDelim == "" {
- endDelim = "]]"
- }
- if fileSizeLimit == "" {
- fileSizeLimit = "3 mb"
- }
+ startDelim, endDelim, fileSizeLimit := templateSettings(c, cfg)
var parsed domain.InputReplacement
var err error
diff --git a/actions/tui.go b/actions/tui.go
new file mode 100644
index 0000000..acaec5e
--- /dev/null
+++ b/actions/tui.go
@@ -0,0 +1,56 @@
+package actions
+
+import (
+ "fmt"
+
+ "github.com/AxeForging/yankrun/domain"
+ "github.com/AxeForging/yankrun/internal/tui"
+ "github.com/AxeForging/yankrun/services"
+ "github.com/urfave/cli"
+)
+
+type TUIAction struct {
+ fs services.FileSystem
+ parser services.ReplacementParser
+ replacer services.Replacer
+}
+
+func NewTUIAction(fs services.FileSystem, parser services.ReplacementParser, replacer services.Replacer) *TUIAction {
+ return &TUIAction{fs: fs, parser: parser, replacer: replacer}
+}
+
+func (a *TUIAction) Execute(c *cli.Context) error {
+ dir := c.String("dir")
+ if dir == "" {
+ return fmt.Errorf("--dir is required for tui command")
+ }
+ if c.Bool("onlyTemplates") && !c.Bool("processTemplates") {
+ return fmt.Errorf("--onlyTemplates requires --processTemplates to be set")
+ }
+
+ cfg, _ := services.Load()
+ startDelim, endDelim, fileSizeLimit := templateSettings(c, cfg)
+
+ var provided domain.InputReplacement
+ if input := c.String("input"); input != "" {
+ parsed, err := a.parser.Parse(input)
+ if err != nil {
+ return err
+ }
+ provided = parsed
+ }
+
+ return tui.Run(tui.Options{
+ Dir: dir,
+ StartDelim: startDelim,
+ EndDelim: endDelim,
+ FileSizeLimit: fileSizeLimit,
+ IgnorePatterns: append(c.StringSlice("ignore"), provided.IgnorePath...),
+ ProcessTemplates: c.Bool("processTemplates"),
+ OnlyTemplates: c.Bool("onlyTemplates"),
+ DryRun: c.Bool("dryRun"),
+ Verbose: c.Bool("verbose"),
+ Provided: provided,
+ Replacer: a.replacer,
+ })
+}
diff --git a/docs/AI/README.md b/docs/AI/README.md
index e4cdbe7..1649793 100644
--- a/docs/AI/README.md
+++ b/docs/AI/README.md
@@ -6,8 +6,8 @@ This document provides structured information for AI assistants working with the
| Item | Value |
|------|-------|
-| **Purpose** | CLI tool for template value replacement in files and git repositories |
-| **Language** | Go 1.24+ |
+| **Purpose** | CLI, TUI, and local web workbench for template value replacement in files and git repositories |
+| **Language** | Go 1.25+ |
| **Repository** | https://github.com/AxeForging/yankrun |
| **License** | MIT |
| **Binary Size** | ~12-13MB (includes go-git library) |
@@ -24,7 +24,12 @@ yankrun/
│ ├── clone.go # `clone` command
│ ├── generate.go # `generate` command
│ ├── setup.go # `setup` command
+│ ├── serve.go # `serve` command wrapper
│ └── template.go # `template` command
+├── internal/
+│ ├── tui/ # Terminal preview workflow
+│ ├── web/ # Embedded web UI, handlers, static assets
+│ └── workflow/ # Shared scan/apply/clone/generate workflow layer
├── services/ # Business logic
│ ├── cloner.go # Git clone operations
│ ├── configio.go # Config file I/O (~/.yankrun/config.yaml)
@@ -57,7 +62,7 @@ yankrun/
### Command Flow
```
-User Command → main.go → actions/*.go → services/*.go → File System
+User Command → main.go → actions/*.go → internal/workflow → services/*.go → File System
↓
flags.go (parse flags)
↓
@@ -72,13 +77,24 @@ User Command → main.go → actions/*.go → services/*.go → File System
| File | Purpose | Key Functions |
|------|---------|---------------|
-| `services/replacer.go` | Placeholder scanning and replacement | `ReplaceInDir()`, `AnalyzeDir()`, `ProcessTemplateFiles()` |
+| `services/replacer.go` | Placeholder scanning, evaluated previews, and replacement | `ReplaceInDir()`, `AnalyzeDirDetails()`, `EvaluatePlaceholder()`, `ProcessTemplateFiles()` |
| `services/parser.go` | Parse JSON/YAML input files | `Parse()` |
| `services/cloner.go` | Git clone operations | `CloneRepository()`, `CloneRepositoryBranch()` |
| `services/configio.go` | Config file management | `Load()`, `Save()`, `Reset()` |
+| `internal/workflow/workflow.go` | Shared workflow used by CLI/TUI/web | `ScanDir()`, `ApplyDir()`, `CloneAndApply()` |
+| `internal/web/server.go` | Embedded local workbench API | `Scan()`, `Apply()`, `Clone()`, `Generate()` |
+| `internal/tui/tui.go` | Preview-first terminal workflow | `Run()` |
| `actions/clone.go` | Clone command handler | `Execute()` |
| `actions/template.go` | Template command handler | `Execute()` |
+### Interactive Surfaces
+
+- `serve` embeds `internal/web/templates` and `internal/web/static` into the single binary.
+- The web UI supports local scan/apply, direct clone, and generate from configured templates.
+- Preview responses include file-level placeholder trees and evaluated transform previews.
+- Browser IndexedDB stores saved presets locally; JSON import/export is client-side only.
+- `tui` uses the same workflow engine for local directory scan/apply and remains preview-first.
+
---
## Testing Strategy
diff --git a/docs/user/README.md b/docs/user/README.md
index 9d5285c..810d350 100644
--- a/docs/user/README.md
+++ b/docs/user/README.md
@@ -2,11 +2,19 @@
A CLI tool for smart template replacement in repositories and directories.
+YankRun can run fully non-interactively, prompt in a terminal, or open a local web workbench for previewing template repos before writing files.
+
+Short references:
+
+- [Command reference](../../COMMANDS.md)
+- [Copy-paste examples](../../EXAMPLES.md)
+
## Table of Contents
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Commands](#commands)
+- [Interactive Workbench](#interactive-workbench)
- [Values File Format](#values-file-format)
- [Transformation Functions](#transformation-functions)
- [Configuration](#configuration)
@@ -78,7 +86,7 @@ yankrun version
-From Source (Go 1.24+)
+From Source (Go 1.25+)
```sh
go install github.com/AxeForging/yankrun@latest
@@ -122,6 +130,12 @@ yankrun clone \
--verbose
```
+Or inspect it interactively first:
+
+```sh
+yankrun serve --dir ./my-new-project --input values.yaml
+```
+
### Step 3: Check the output
```sh
@@ -297,6 +311,89 @@ yankrun generate \
---
+### `serve`
+
+Open a local web workbench for local directories, direct clones, and configured templates.
+
+```sh
+yankrun serve --dir ./my-project --input values.yaml
+```
+
+The server binds to `127.0.0.1:17817` by default. It is intended for local use and shares the same parser, cloner, and replacer logic as the CLI commands.
+
+
+All flags
+
+| Flag | Alias | Description | Default |
+|------|-------|-------------|---------|
+| `--dir` | `-d` | Directory for local scan/apply | - |
+| `--input` | `-i` | Values file path | - |
+| `--addr` | | Listen address | `127.0.0.1:17817` |
+| `--startDelim` | `--sd` | Template start delimiter | `[[` |
+| `--endDelim` | `--ed` | Template end delimiter | `]]` |
+| `--fileSizeLimit` | `--fl` | Skip files larger than this limit | `3 mb` |
+| `--processTemplates` | `--pt` | Process `.tpl` files | `false` |
+| `--onlyTemplates` | `--ot` | Only process `.tpl` files | `false` |
+| `--dryRun` | `--dr` | Force preview-only mode | `false` |
+| `--ignore` | | Glob patterns to skip | - |
+| `--verbose` | `-v` | Show detailed output | `false` |
+
+
+
+The workbench provides:
+
+- **Local** mode for a directory passed with `--dir`
+- **Clone** mode for SSH or HTTPS repositories
+- **Generate** mode for `~/.yankrun/config.yaml` templates and GitHub-discovered templates
+- file-level placeholder trees so you can see what will change
+- evaluated transform previews, for example `APP_NAME:toUpperCase -> MYAPP`
+- idle refresh after value edits so previews stay current
+- saved presets stored in browser IndexedDB, searchable by repo/template/branch/output/value keys
+- preset JSON export/import for moving saved runs between browsers
+
+### `tui`
+
+Open a preview-first terminal workflow for local directory templating.
+
+```sh
+yankrun tui --dir ./my-project --input values.yaml
+yankrun tui --dir ./my-project --dryRun
+```
+
+The TUI is intentionally conservative: it scans, summarizes, previews the replacement count, and respects `--dryRun` before writing.
+
+---
+
+## Interactive Workbench
+
+Use `serve` when you want to inspect a template repo before writing it to disk, reuse values across repeated runs, or compare where placeholders appear.
+
+Typical flow:
+
+1. Start the server with an optional local directory and values file.
+2. Use **Local**, **Clone**, or **Generate** mode.
+3. Click **Preview** before applying.
+4. Edit values; evaluated previews refresh after a short idle delay.
+5. Restore prior work from the left preset rail when repeating a repo/template.
+
+For clone mode:
+
+```text
+Repository URL: https://github.com/AxeForging/template-tester.git
+Branch: main
+Output directory: ./my-new-project
+```
+
+SSH URLs also work when your local environment has the needed SSH key or agent configured:
+
+```text
+git@github.com:AxeForging/template-tester.git
+```
+
+Presets stay local to the browser unless exported manually as JSON.
+
+---
+
### `setup`
Configure YankRun defaults.
diff --git a/flags.go b/flags.go
index d1476bf..6553141 100644
--- a/flags.go
+++ b/flags.go
@@ -96,3 +96,9 @@ var sshKeyFlag = cli.StringFlag{
Value: "",
Usage: "Path to SSH private key (auto-detects id_ed25519, id_ecdsa, id_rsa if not set)",
}
+
+var addrFlag = cli.StringFlag{
+ Name: "addr",
+ Value: "127.0.0.1:17817",
+ Usage: "Address for the local serve command",
+}
diff --git a/go.mod b/go.mod
index 64155d6..f525390 100644
--- a/go.mod
+++ b/go.mod
@@ -1,11 +1,11 @@
module github.com/AxeForging/yankrun
-go 1.24.0
+go 1.25.0
-toolchain go1.24.6
+toolchain go1.25.10
require (
- github.com/go-git/go-git/v5 v5.16.4
+ github.com/go-git/go-git/v5 v5.17.1
github.com/mitchellh/go-homedir v1.1.0
github.com/rs/zerolog v1.34.0
github.com/urfave/cli v1.22.17
@@ -21,7 +21,7 @@ require (
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
- github.com/go-git/go-billy/v5 v5.7.0 // indirect
+ github.com/go-git/go-billy/v5 v5.8.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
@@ -33,8 +33,8 @@ require (
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
- golang.org/x/crypto v0.47.0 // indirect
- golang.org/x/net v0.49.0 // indirect
- golang.org/x/sys v0.40.0 // indirect
+ golang.org/x/crypto v0.52.0 // indirect
+ golang.org/x/net v0.54.0 // indirect
+ golang.org/x/sys v0.45.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)
diff --git a/go.sum b/go.sum
index dd981d3..f9d9bd2 100644
--- a/go.sum
+++ b/go.sum
@@ -30,10 +30,14 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
+github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
+github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
+github.com/go-git/go-git/v5 v5.17.1 h1:WnljyxIzSj9BRRUlnmAU35ohDsjRK0EKmL0evDqi5Jk=
+github.com/go-git/go-git/v5 v5.17.1/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
@@ -100,11 +104,15 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
+golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
+golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
+golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
+golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -116,12 +124,16 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
+golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/integration/dryrun_ignore_test.go b/integration/dryrun_ignore_test.go
index 990e82b..e4cfcf5 100644
--- a/integration/dryrun_ignore_test.go
+++ b/integration/dryrun_ignore_test.go
@@ -56,7 +56,7 @@ func TestDryRunTemplate(t *testing.T) {
func TestDryRunClone(t *testing.T) {
bin := buildBinary(t)
- testDir := t.TempDir()
+ testDir := filepath.Join(t.TempDir(), "out")
// Create a local git repo to clone from
repoDir := t.TempDir()
@@ -64,7 +64,7 @@ func TestDryRunClone(t *testing.T) {
t.Fatal(err)
}
- cmd := exec.Command("git", "init")
+ cmd := exec.Command("git", "init", "-b", "main")
cmd.Dir = repoDir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init failed: %v\n%s", err, string(out))
@@ -105,13 +105,193 @@ func TestDryRunClone(t *testing.T) {
t.Errorf("expected 'Dry run' in output, got:\n%s", string(out))
}
- // File should still have placeholder (dry run skips replacement)
- got, err := os.ReadFile(filepath.Join(testDir, "readme.txt"))
+ // Clone dry-run should not leave output files behind.
+ if _, err := os.Stat(testDir); !os.IsNotExist(err) {
+ t.Fatalf("dry-run clone should not create output dir, stat err=%v", err)
+ }
+}
+
+func TestCloneBranchFlag(t *testing.T) {
+ bin := buildBinary(t)
+ outDir := filepath.Join(t.TempDir(), "out")
+ repoDir := t.TempDir()
+
+ if err := os.WriteFile(filepath.Join(repoDir, "readme.txt"), []byte("main branch"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command("git", "init", "-b", "main")
+ cmd.Dir = repoDir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git init failed: %v\n%s", err, string(out))
+ }
+ cmd = exec.Command("git", "add", ".")
+ cmd.Dir = repoDir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git add failed: %v\n%s", err, string(out))
+ }
+ cmd = exec.Command("git", "commit", "-m", "main")
+ cmd.Dir = repoDir
+ cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com")
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git commit failed: %v\n%s", err, string(out))
+ }
+ cmd = exec.Command("git", "switch", "-c", "feature-template")
+ cmd.Dir = repoDir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git switch failed: %v\n%s", err, string(out))
+ }
+ if err := os.WriteFile(filepath.Join(repoDir, "readme.txt"), []byte("feature [[NAME]]"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ cmd = exec.Command("git", "add", ".")
+ cmd.Dir = repoDir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git add failed: %v\n%s", err, string(out))
+ }
+ cmd = exec.Command("git", "commit", "-m", "feature")
+ cmd.Dir = repoDir
+ cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com")
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git commit failed: %v\n%s", err, string(out))
+ }
+
+ valsPath := writeFile(t, t.TempDir(), "values.yaml", `variables: [{key: NAME, value: World}]`)
+ cmd = exec.Command(bin,
+ "clone",
+ "--repo", repoDir,
+ "--outputDir", outDir,
+ "--branch", "feature-template",
+ "--input", valsPath,
+ )
+ cmd.Dir = repoRoot(t)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("clone branch failed: %v\n%s", err, string(out))
+ }
+
+ got, err := os.ReadFile(filepath.Join(outDir, "readme.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "feature World" {
+ t.Fatalf("readme.txt = %q, want feature branch content", string(got))
+ }
+}
+
+func TestGenerateDryRunDoesNotCreateOutput(t *testing.T) {
+ bin := buildBinary(t)
+ home := t.TempDir()
+ configDir := filepath.Join(home, ".yankrun")
+ if err := os.MkdirAll(configDir, 0700); err != nil {
+ t.Fatal(err)
+ }
+
+ repoDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(repoDir, "readme.txt"), []byte("Hello [[NAME]]"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command("git", "init", "-b", "main")
+ cmd.Dir = repoDir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git init failed: %v\n%s", err, string(out))
+ }
+ cmd = exec.Command("git", "add", ".")
+ cmd.Dir = repoDir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git add failed: %v\n%s", err, string(out))
+ }
+ cmd = exec.Command("git", "commit", "-m", "init")
+ cmd.Dir = repoDir
+ cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com")
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git commit failed: %v\n%s", err, string(out))
+ }
+
+ config := "templates:\n - name: local\n url: " + repoDir + "\n default_branch: main\n"
+ if err := os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(config), 0600); err != nil {
+ t.Fatal(err)
+ }
+ valsPath := writeFile(t, t.TempDir(), "values.yaml", `variables: [{key: NAME, value: World}]`)
+ outDir := filepath.Join(t.TempDir(), "generated")
+
+ cmd = exec.Command(bin,
+ "generate",
+ "--template", "local",
+ "--outputDir", outDir,
+ "--input", valsPath,
+ "--dryRun",
+ )
+ cmd.Dir = repoRoot(t)
+ cmd.Env = append(os.Environ(), "HOME="+home)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("generate dry-run failed: %v\n%s", err, string(out))
+ }
+ if !strings.Contains(string(out), "Dry run") {
+ t.Fatalf("expected dry-run output, got:\n%s", string(out))
+ }
+ if _, err := os.Stat(outDir); !os.IsNotExist(err) {
+ t.Fatalf("generate dry-run should not create output dir, stat err=%v", err)
+ }
+}
+
+func TestTemplateUsesConfigDefaults(t *testing.T) {
+ bin := buildBinary(t)
+ home := t.TempDir()
+ configDir := filepath.Join(home, ".yankrun")
+ if err := os.MkdirAll(configDir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ config := "start_delim: '<%'\nend_delim: '%>'\nfile_size_limit: '1 mb'\n"
+ if err := os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(config), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ testDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(testDir, "app.txt"), []byte("App: <%NAME%>"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ valsPath := writeFile(t, t.TempDir(), "values.yaml", `variables: [{key: NAME, value: MyApp}]`)
+
+ cmd := exec.Command(bin, "template", "--dir", testDir, "--input", valsPath)
+ cmd.Dir = repoRoot(t)
+ cmd.Env = append(os.Environ(), "HOME="+home)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("template with config defaults failed: %v\n%s", err, string(out))
+ }
+
+ got, err := os.ReadFile(filepath.Join(testDir, "app.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "App: MyApp" {
+ t.Fatalf("app.txt = %q, want config delimiters to apply", string(got))
+ }
+}
+
+func TestTUIDryRunDoesNotModifyFiles(t *testing.T) {
+ bin := buildBinary(t)
+ testDir := t.TempDir()
+ path := filepath.Join(testDir, "app.txt")
+ if err := os.WriteFile(path, []byte("App: [[NAME]]"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ valsPath := writeFile(t, t.TempDir(), "values.yaml", `variables: [{key: NAME, value: MyApp}]`)
+
+ cmd := exec.Command(bin, "tui", "--dir", testDir, "--input", valsPath, "--dryRun")
+ cmd.Dir = repoRoot(t)
+ cmd.Stdin = strings.NewReader("\n")
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("tui dry-run failed: %v\n%s", err, string(out))
+ }
+ got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
- if !strings.Contains(string(got), "[[NAME]]") {
- t.Errorf("dry-run should not replace placeholders. Got: %q", string(got))
+ if string(got) != "App: [[NAME]]" {
+ t.Fatalf("tui dry-run modified file: %q", string(got))
}
}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
new file mode 100644
index 0000000..ed4af31
--- /dev/null
+++ b/internal/tui/tui.go
@@ -0,0 +1,129 @@
+package tui
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/AxeForging/yankrun/domain"
+ "github.com/AxeForging/yankrun/helpers"
+ "github.com/AxeForging/yankrun/internal/workflow"
+ "github.com/AxeForging/yankrun/services"
+)
+
+type Options struct {
+ Dir string
+ StartDelim string
+ EndDelim string
+ FileSizeLimit string
+ IgnorePatterns []string
+ ProcessTemplates bool
+ OnlyTemplates bool
+ DryRun bool
+ Verbose bool
+ Provided domain.InputReplacement
+ Replacer services.Replacer
+ In io.Reader
+ Out io.Writer
+}
+
+func Run(opts Options) error {
+ if opts.In == nil {
+ opts.In = os.Stdin
+ }
+ if opts.Out == nil {
+ opts.Out = os.Stdout
+ }
+ if opts.StartDelim == "" {
+ opts.StartDelim = "[["
+ }
+ if opts.EndDelim == "" {
+ opts.EndDelim = "]]"
+ }
+ if opts.FileSizeLimit == "" {
+ opts.FileSizeLimit = "3 mb"
+ }
+
+ fmt.Fprint(opts.Out, "\033[2J\033[H")
+ fmt.Fprintln(opts.Out, "YankRun TUI")
+ fmt.Fprintln(opts.Out, "Scanning template surface")
+ for i := 0; i < 3; i++ {
+ fmt.Fprint(opts.Out, ".")
+ time.Sleep(80 * time.Millisecond)
+ }
+ fmt.Fprintln(opts.Out)
+
+ engine := workflow.Engine{Replacer: opts.Replacer}
+ settings := workflow.TemplateSettings{
+ StartDelim: opts.StartDelim,
+ EndDelim: opts.EndDelim,
+ FileSizeLimit: opts.FileSizeLimit,
+ ProcessTemplates: opts.ProcessTemplates,
+ OnlyTemplates: opts.OnlyTemplates,
+ Verbose: opts.Verbose,
+ IgnorePatterns: opts.IgnorePatterns,
+ }
+ summary, err := engine.ScanDir(opts.Dir, settings, opts.Provided)
+ if err != nil {
+ return err
+ }
+ if len(summary.Counts) == 0 {
+ helpers.Log.Info().Msg("No placeholders found.")
+ return nil
+ }
+
+ keys := summary.Keys
+ values := summary.Values
+ reader := bufio.NewReader(opts.In)
+
+ fmt.Fprintln(opts.Out)
+ fmt.Fprintln(opts.Out, "Discovered placeholders")
+ for _, k := range keys {
+ def := values[k]
+ if def == "" {
+ def = "(unset)"
+ }
+ fmt.Fprintf(opts.Out, " %-24s matches=%-6d value=%s\n", k, summary.Counts[k], def)
+ }
+ fmt.Fprintln(opts.Out)
+
+ for _, k := range keys {
+ fmt.Fprintf(opts.Out, "Value for %s [%s]: ", k, values[k])
+ answer, _ := reader.ReadString('\n')
+ answer = strings.TrimSpace(answer)
+ if answer != "" {
+ values[k] = answer
+ }
+ }
+
+ final := workflow.BuildFinal(keys, values)
+ if len(final.Variables) == 0 {
+ helpers.Log.Info().Msg("No values provided; nothing to replace.")
+ return nil
+ }
+
+ total := workflow.ReplacementMatchCount(keys, summary.Counts, values)
+ fmt.Fprintln(opts.Out)
+ fmt.Fprintf(opts.Out, "Preview: %d replacements across %d placeholders.\n", total, len(final.Variables))
+ if opts.DryRun {
+ helpers.Log.Info().Msg("Dry run enabled; no files modified.")
+ return nil
+ }
+
+ fmt.Fprint(opts.Out, "Apply these changes? Type yes to continue: ")
+ answer, _ := reader.ReadString('\n')
+ if strings.ToLower(strings.TrimSpace(answer)) != "yes" {
+ helpers.Log.Info().Msg("Cancelled; no files modified.")
+ return nil
+ }
+
+ if err := engine.ApplyFinal(opts.Dir, settings, final); err != nil {
+ return err
+ }
+
+ helpers.Log.Info().Msg("Templating complete.")
+ return nil
+}
diff --git a/internal/web/server.go b/internal/web/server.go
new file mode 100644
index 0000000..80fa308
--- /dev/null
+++ b/internal/web/server.go
@@ -0,0 +1,459 @@
+package web
+
+import (
+ "context"
+ "embed"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/AxeForging/yankrun/domain"
+ "github.com/AxeForging/yankrun/internal/workflow"
+ "github.com/AxeForging/yankrun/services"
+)
+
+//go:embed templates/*.html.tmpl
+var templatesFS embed.FS
+
+//go:embed static
+var staticFS embed.FS
+
+type Server struct {
+ addr string
+ mux *http.ServeMux
+ page *template.Template
+ mu sync.Mutex
+ dir string
+ input string
+ startDelim string
+ endDelim string
+ fileSizeLimit string
+ ignorePatterns []string
+ processTemplates bool
+ onlyTemplates bool
+ forceDryRun bool
+ verbose bool
+ parser services.ReplacementParser
+ replacer services.Replacer
+ cloner services.Cloner
+ config *domain.Config
+}
+
+type Options struct {
+ Addr string
+ Dir string
+ Input string
+ StartDelim string
+ EndDelim string
+ FileSizeLimit string
+ IgnorePatterns []string
+ ProcessTemplates bool
+ OnlyTemplates bool
+ ForceDryRun bool
+ Verbose bool
+ Parser services.ReplacementParser
+ Replacer services.Replacer
+ Cloner services.Cloner
+ Config *domain.Config
+}
+
+type PlaceholderSummary struct {
+ Counts map[string]int `json:"counts"`
+ Files []workflow.FileSummary `json:"files"`
+ Keys []string `json:"keys"`
+ Values map[string]string `json:"values"`
+}
+
+type ApplyRequest struct {
+ Values map[string]string `json:"values"`
+ DryRun bool `json:"dryRun"`
+}
+
+type EvaluateRequest struct {
+ Summary PlaceholderSummary `json:"summary"`
+ Values map[string]string `json:"values"`
+}
+
+type ApplyResponse struct {
+ Applied bool `json:"applied"`
+ ForcedDryRun bool `json:"forcedDryRun"`
+ TotalMatches int `json:"totalMatches"`
+ Placeholders int `json:"placeholders"`
+ Summary PlaceholderSummary `json:"summary"`
+}
+
+type TemplateOption struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Description string `json:"description"`
+ DefaultBranch string `json:"defaultBranch"`
+}
+
+type CloneRequest struct {
+ Repo string `json:"repo"`
+ OutputDir string `json:"outputDir"`
+ Branch string `json:"branch"`
+ Values map[string]string `json:"values"`
+ DryRun bool `json:"dryRun"`
+}
+
+type GenerateRequest struct {
+ Template string `json:"template"`
+ OutputDir string `json:"outputDir"`
+ Branch string `json:"branch"`
+ Values map[string]string `json:"values"`
+ DryRun bool `json:"dryRun"`
+}
+
+func New(opts Options) (*Server, error) {
+ page, err := loadTemplate()
+ if err != nil {
+ return nil, err
+ }
+ if opts.Addr == "" {
+ opts.Addr = "127.0.0.1:17817"
+ }
+ if opts.StartDelim == "" {
+ opts.StartDelim = "[["
+ }
+ if opts.EndDelim == "" {
+ opts.EndDelim = "]]"
+ }
+ if opts.FileSizeLimit == "" {
+ opts.FileSizeLimit = "3 mb"
+ }
+ s := &Server{
+ addr: opts.Addr,
+ mux: http.NewServeMux(),
+ page: page,
+ dir: opts.Dir,
+ input: opts.Input,
+ startDelim: opts.StartDelim,
+ endDelim: opts.EndDelim,
+ fileSizeLimit: opts.FileSizeLimit,
+ ignorePatterns: opts.IgnorePatterns,
+ processTemplates: opts.ProcessTemplates,
+ onlyTemplates: opts.OnlyTemplates,
+ forceDryRun: opts.ForceDryRun,
+ verbose: opts.Verbose,
+ parser: opts.Parser,
+ replacer: opts.Replacer,
+ cloner: opts.Cloner,
+ config: opts.Config,
+ }
+ if s.config == nil {
+ s.config = &domain.Config{}
+ }
+ s.routes()
+ return s, nil
+}
+
+func (s *Server) Addr() string { return s.addr }
+
+func (s *Server) Handler() http.Handler { return s.mux }
+
+func (s *Server) ListenAndServe() error {
+ srv := &http.Server{
+ Addr: s.addr,
+ Handler: s.mux,
+ ReadHeaderTimeout: 5 * time.Second,
+ }
+ return srv.ListenAndServe()
+}
+
+func (s *Server) ListenAndServeContext(ctx context.Context) error {
+ srv := &http.Server{
+ Addr: s.addr,
+ Handler: s.mux,
+ ReadHeaderTimeout: 5 * time.Second,
+ }
+ go func() {
+ <-ctx.Done()
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ _ = srv.Shutdown(shutdownCtx)
+ }()
+ return srv.ListenAndServe()
+}
+
+func (s *Server) routes() {
+ sub, _ := fs.Sub(staticFS, "static")
+ s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
+ s.mux.HandleFunc("/", s.handleIndex)
+ s.mux.HandleFunc("/api/scan", s.handleScan)
+ s.mux.HandleFunc("/api/apply", s.handleApply)
+ s.mux.HandleFunc("/api/evaluate", s.handleEvaluate)
+ s.mux.HandleFunc("/api/templates", s.handleTemplates)
+ s.mux.HandleFunc("/api/clone", s.handleClone)
+ s.mux.HandleFunc("/api/generate", s.handleGenerate)
+}
+
+func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _ = s.page.Execute(w, map[string]any{
+ "Dir": s.dir,
+ "StartDelim": s.startDelim,
+ "EndDelim": s.endDelim,
+ "ForceDryRun": s.forceDryRun,
+ })
+}
+
+func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ summary, err := s.Scan()
+ writeJSON(w, summary, err)
+}
+
+func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ defer r.Body.Close()
+ var req ApplyRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
+ http.Error(w, "invalid JSON body", http.StatusBadRequest)
+ return
+ }
+ resp, err := s.Apply(req)
+ writeJSON(w, resp, err)
+}
+
+func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ defer r.Body.Close()
+ var req EvaluateRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&req); err != nil {
+ http.Error(w, "invalid JSON body", http.StatusBadRequest)
+ return
+ }
+ summary := req.Summary
+ summary.Values = req.Values
+ for i := range summary.Files {
+ for j := range summary.Files[i].Previews {
+ preview := &summary.Files[i].Previews[j]
+ value, found, err := s.replacer.EvaluatePlaceholder(preview.Expression, req.Values)
+ preview.Value = value
+ preview.Missing = !found
+ preview.Error = ""
+ if err != nil {
+ preview.Error = err.Error()
+ }
+ }
+ }
+ writeJSON(w, summary, nil)
+}
+
+func (s *Server) Scan() (PlaceholderSummary, error) {
+ s.mu.Lock()
+ dir := s.dir
+ s.mu.Unlock()
+ summary, err := s.scanDir(dir)
+ return PlaceholderSummary(summary), err
+}
+
+func (s *Server) scanDir(dir string) (workflow.Summary, error) {
+ var provided domain.InputReplacement
+ if s.input != "" {
+ parsed, err := s.parser.Parse(s.input)
+ if err != nil {
+ return workflow.Summary{}, err
+ }
+ provided = parsed
+ }
+ return s.engine().ScanDir(dir, s.settings(), provided)
+}
+
+func (s *Server) Apply(req ApplyRequest) (ApplyResponse, error) {
+ s.mu.Lock()
+ dir := s.dir
+ s.mu.Unlock()
+ result, err := s.engine().ApplyDir(dir, s.settings(), s.provided(), req.Values, req.DryRun, s.forceDryRun)
+ if err != nil {
+ return ApplyResponse{}, err
+ }
+ return applyResponseFromWorkflow(result), nil
+}
+
+func (s *Server) handleTemplates(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ writeJSON(w, s.TemplateOptions(r.Context()), nil)
+}
+
+func (s *Server) handleClone(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ defer r.Body.Close()
+ var req CloneRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
+ http.Error(w, "invalid JSON body", http.StatusBadRequest)
+ return
+ }
+ resp, err := s.Clone(req)
+ writeJSON(w, resp, err)
+}
+
+func (s *Server) handleGenerate(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ defer r.Body.Close()
+ var req GenerateRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
+ http.Error(w, "invalid JSON body", http.StatusBadRequest)
+ return
+ }
+ resp, err := s.Generate(req)
+ writeJSON(w, resp, err)
+}
+
+func (s *Server) TemplateOptions(ctx context.Context) []TemplateOption {
+ out := make([]TemplateOption, 0, len(s.config.Templates))
+ for _, t := range s.config.Templates {
+ out = append(out, TemplateOption{Name: t.Name, URL: t.URL, Description: t.Description, DefaultBranch: t.DefaultBranch})
+ }
+ if s.config.GitHub.User != "" || len(s.config.GitHub.Orgs) > 0 {
+ found, err := services.NewGitHubClient().ListRepos(ctx, s.config.GitHub)
+ if err == nil {
+ for _, r := range found {
+ out = append(out, TemplateOption{Name: r.FullName, URL: r.SSHURL, Description: r.Description, DefaultBranch: r.DefaultBranch})
+ }
+ }
+ }
+ return out
+}
+
+func (s *Server) Clone(req CloneRequest) (ApplyResponse, error) {
+ if strings.TrimSpace(req.Repo) == "" {
+ return ApplyResponse{}, fmt.Errorf("repo is required")
+ }
+ return s.cloneInto(req.Repo, req.Branch, req.OutputDir, req.Values, req.DryRun, false)
+}
+
+func (s *Server) Generate(req GenerateRequest) (ApplyResponse, error) {
+ if strings.TrimSpace(req.Template) == "" {
+ return ApplyResponse{}, fmt.Errorf("template is required")
+ }
+ selected, ok := s.findTemplate(req.Template)
+ if !ok {
+ return ApplyResponse{}, fmt.Errorf("template not found")
+ }
+ branch := req.Branch
+ if branch == "" {
+ branch = selected.DefaultBranch
+ }
+ if branch == "" {
+ branch = "main"
+ }
+ return s.cloneInto(selected.URL, branch, req.OutputDir, req.Values, req.DryRun, true)
+}
+
+func (s *Server) findTemplate(needle string) (TemplateOption, bool) {
+ needle = strings.ToLower(needle)
+ for _, t := range s.TemplateOptions(context.Background()) {
+ if strings.ToLower(t.Name) == needle || strings.ToLower(t.URL) == needle || strings.Contains(strings.ToLower(t.Name), needle) || strings.Contains(strings.ToLower(t.URL), needle) {
+ return t, true
+ }
+ }
+ return TemplateOption{}, false
+}
+
+func (s *Server) cloneInto(repo, branch, outputDir string, values map[string]string, dryRun bool, removeGit bool) (ApplyResponse, error) {
+ workDir, result, err := s.engine().CloneAndApply(workflow.CloneOptions{
+ Repo: repo,
+ Branch: branch,
+ OutputDir: outputDir,
+ RemoveGit: removeGit,
+ DryRun: dryRun,
+ ForceDry: s.forceDryRun,
+ }, s.settings(), values)
+ if err != nil {
+ return ApplyResponse{}, err
+ }
+ if !(dryRun || s.forceDryRun) {
+ s.mu.Lock()
+ s.dir = workDir
+ s.mu.Unlock()
+ }
+ return applyResponseFromWorkflow(result), nil
+}
+
+func loadTemplate() (*template.Template, error) {
+ b, err := fs.ReadFile(templatesFS, "templates/workspace.html.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("read workspace template: %w", err)
+ }
+ t, err := template.New("workspace").Parse(string(b))
+ if err != nil {
+ return nil, fmt.Errorf("parse workspace template: %w", err)
+ }
+ return t, nil
+}
+
+func writeJSON(w http.ResponseWriter, payload any, err error) {
+ w.Header().Set("Content-Type", "application/json")
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
+ return
+ }
+ _ = json.NewEncoder(w).Encode(payload)
+}
+
+func (s *Server) settings() workflow.TemplateSettings {
+ return workflow.TemplateSettings{
+ StartDelim: s.startDelim,
+ EndDelim: s.endDelim,
+ FileSizeLimit: s.fileSizeLimit,
+ ProcessTemplates: s.processTemplates,
+ OnlyTemplates: s.onlyTemplates,
+ Verbose: s.verbose,
+ IgnorePatterns: s.ignorePatterns,
+ }
+}
+
+func (s *Server) engine() workflow.Engine {
+ return workflow.Engine{Parser: s.parser, Replacer: s.replacer, Cloner: s.cloner}
+}
+
+func (s *Server) provided() domain.InputReplacement {
+ if s.input == "" {
+ return domain.InputReplacement{}
+ }
+ provided, err := s.parser.Parse(s.input)
+ if err != nil {
+ return domain.InputReplacement{}
+ }
+ return provided
+}
+
+func applyResponseFromWorkflow(result workflow.ApplyResult) ApplyResponse {
+ return ApplyResponse{
+ Applied: result.Applied,
+ ForcedDryRun: result.ForcedDryRun,
+ TotalMatches: result.TotalMatches,
+ Placeholders: result.Placeholders,
+ Summary: PlaceholderSummary(result.Summary),
+ }
+}
diff --git a/internal/web/server_test.go b/internal/web/server_test.go
new file mode 100644
index 0000000..8b56ce0
--- /dev/null
+++ b/internal/web/server_test.go
@@ -0,0 +1,286 @@
+package web
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/AxeForging/yankrun/domain"
+ "github.com/AxeForging/yankrun/services"
+)
+
+type fakeCloner struct {
+ lastRepo string
+ lastBranch string
+}
+
+func (f *fakeCloner) CloneRepository(repoURL, outputDir string) error {
+ f.lastRepo = repoURL
+ return os.WriteFile(filepath.Join(outputDir, "app.txt"), []byte("Hello [[NAME]]"), 0644)
+}
+
+func (f *fakeCloner) CloneRepositoryBranch(repoURL, branch, outputDir string) error {
+ f.lastRepo = repoURL
+ f.lastBranch = branch
+ if err := os.MkdirAll(outputDir, 0755); err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Join(outputDir, ".git"), 0755); err != nil {
+ return err
+ }
+ return os.WriteFile(filepath.Join(outputDir, "app.txt"), []byte(fmt.Sprintf("%s [[NAME]]", branch)), 0644)
+}
+
+func (f *fakeCloner) ListRemoteBranches(repoURL string) ([]string, error) {
+ f.lastRepo = repoURL
+ return []string{"main", "feature"}, nil
+}
+
+func (f *fakeCloner) SetSSHKeyPath(path string) {}
+
+func testServer(t *testing.T, dir string, forceDryRun bool) *Server {
+ t.Helper()
+ parser := &services.YAMLJSONParser{FileSystem: &services.OsFileSystem{}}
+ replacer := &services.FileReplacer{FileSystem: &services.OsFileSystem{}}
+ s, err := New(Options{
+ Addr: "127.0.0.1:0",
+ Dir: dir,
+ StartDelim: "[[",
+ EndDelim: "]]",
+ FileSizeLimit: "3 mb",
+ ForceDryRun: forceDryRun,
+ Parser: parser,
+ Replacer: replacer,
+ Cloner: &fakeCloner{},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s
+}
+
+func TestScanAndDryRun(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "app.txt")
+ if err := os.WriteFile(path, []byte("Hello [[NAME:toUpperCase]]"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ server := httptest.NewServer(testServer(t, dir, false).Handler())
+ defer server.Close()
+
+ resp, err := http.Get(server.URL + "/api/scan")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("scan status = %d", resp.StatusCode)
+ }
+ var summary PlaceholderSummary
+ if err := json.NewDecoder(resp.Body).Decode(&summary); err != nil {
+ t.Fatal(err)
+ }
+ if summary.Counts["NAME"] != 1 {
+ t.Fatalf("NAME count = %d, want 1", summary.Counts["NAME"])
+ }
+ if len(summary.Files) != 1 || summary.Files[0].Path != "app.txt" || summary.Files[0].Counts["NAME"] != 1 {
+ t.Fatalf("summary files = %+v, want app.txt with NAME count", summary.Files)
+ }
+
+ body := bytes.NewBufferString(`{"values":{"NAME":"World"},"dryRun":true}`)
+ resp, err = http.Post(server.URL+"/api/apply", "application/json", body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var applied ApplyResponse
+ if err := json.NewDecoder(resp.Body).Decode(&applied); err != nil {
+ t.Fatal(err)
+ }
+ if applied.Applied {
+ t.Fatal("dry-run apply should not modify files")
+ }
+ if len(applied.Summary.Files) != 1 || len(applied.Summary.Files[0].Previews) != 1 {
+ t.Fatalf("preview summary files = %+v, want evaluated preview", applied.Summary.Files)
+ }
+ preview := applied.Summary.Files[0].Previews[0]
+ if preview.Expression != "NAME:toUpperCase" || preview.Value != "WORLD" || preview.Missing || preview.Error != "" {
+ t.Fatalf("preview = %+v, want NAME:toUpperCase -> WORLD", preview)
+ }
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "Hello [[NAME:toUpperCase]]" {
+ t.Fatalf("file changed during dry run: %q", string(got))
+ }
+}
+
+func TestEvaluateUpdatesPreviewValues(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "app.txt"), []byte("Env [[ENVIRONMENT|default:dev]] App [[APP_NAME:toUpperCase]]"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ server := httptest.NewServer(testServer(t, dir, false).Handler())
+ defer server.Close()
+
+ resp, err := http.Get(server.URL + "/api/scan")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var summary PlaceholderSummary
+ if err := json.NewDecoder(resp.Body).Decode(&summary); err != nil {
+ t.Fatal(err)
+ }
+ if summary.Counts["ENVIRONMENT"] != 1 {
+ t.Fatalf("ENVIRONMENT count = %d, want 1; summary=%+v", summary.Counts["ENVIRONMENT"], summary)
+ }
+
+ body, err := json.Marshal(EvaluateRequest{
+ Summary: summary,
+ Values: map[string]string{"ENVIRONMENT": "prod", "APP_NAME": "demo"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err = http.Post(server.URL+"/api/evaluate", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var evaluated PlaceholderSummary
+ if err := json.NewDecoder(resp.Body).Decode(&evaluated); err != nil {
+ t.Fatal(err)
+ }
+ got := map[string]string{}
+ for _, file := range evaluated.Files {
+ for _, preview := range file.Previews {
+ got[preview.Expression] = preview.Value
+ }
+ }
+ if got["ENVIRONMENT|default:dev"] != "prod" || got["APP_NAME:toUpperCase"] != "DEMO" {
+ t.Fatalf("evaluated previews = %+v", got)
+ }
+}
+
+func TestApplyAndForcedDryRun(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "app.txt")
+ if err := os.WriteFile(path, []byte("Hello [[NAME]]"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ forced := httptest.NewServer(testServer(t, dir, true).Handler())
+ body := bytes.NewBufferString(`{"values":{"NAME":"Blocked"},"dryRun":false}`)
+ resp, err := http.Post(forced.URL+"/api/apply", "application/json", body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var forcedResp ApplyResponse
+ if err := json.NewDecoder(resp.Body).Decode(&forcedResp); err != nil {
+ t.Fatal(err)
+ }
+ _ = resp.Body.Close()
+ forced.Close()
+ if forcedResp.Applied || !forcedResp.ForcedDryRun {
+ t.Fatalf("forced dry-run response = %+v", forcedResp)
+ }
+
+ live := httptest.NewServer(testServer(t, dir, false).Handler())
+ defer live.Close()
+ body = bytes.NewBufferString(`{"values":{"NAME":"World"},"dryRun":false}`)
+ resp, err = http.Post(live.URL+"/api/apply", "application/json", body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var liveResp ApplyResponse
+ if err := json.NewDecoder(resp.Body).Decode(&liveResp); err != nil {
+ t.Fatal(err)
+ }
+ if !liveResp.Applied {
+ t.Fatalf("expected live apply, got %+v", liveResp)
+ }
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "Hello World" {
+ t.Fatalf("file = %q, want applied value", string(got))
+ }
+}
+
+func TestCloneAndGenerateUseSharedWorkflow(t *testing.T) {
+ dir := t.TempDir()
+ cloner := &fakeCloner{}
+ parser := &services.YAMLJSONParser{FileSystem: &services.OsFileSystem{}}
+ replacer := &services.FileReplacer{FileSystem: &services.OsFileSystem{}}
+ s, err := New(Options{
+ Addr: "127.0.0.1:0",
+ Dir: dir,
+ StartDelim: "[[",
+ EndDelim: "]]",
+ FileSizeLimit: "3 mb",
+ Parser: parser,
+ Replacer: replacer,
+ Cloner: cloner,
+ Config: &domain.Config{Templates: []domain.TemplateRepo{
+ {Name: "starter", URL: "git@example.com:org/starter.git", DefaultBranch: "feature"},
+ }},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cloneOut := filepath.Join(t.TempDir(), "clone")
+ cloneResp, err := s.Clone(CloneRequest{
+ Repo: "https://example.com/repo.git",
+ Branch: "main",
+ OutputDir: cloneOut,
+ Values: map[string]string{"NAME": "World"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !cloneResp.Applied || cloner.lastRepo != "https://example.com/repo.git" || cloner.lastBranch != "main" {
+ t.Fatalf("clone response=%+v cloner=%+v", cloneResp, cloner)
+ }
+ got, err := os.ReadFile(filepath.Join(cloneOut, "app.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "main World" {
+ t.Fatalf("clone output = %q", string(got))
+ }
+
+ generateOut := filepath.Join(t.TempDir(), "generate")
+ generateResp, err := s.Generate(GenerateRequest{
+ Template: "starter",
+ OutputDir: generateOut,
+ Values: map[string]string{"NAME": "Team"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !generateResp.Applied || cloner.lastRepo != "git@example.com:org/starter.git" || cloner.lastBranch != "feature" {
+ t.Fatalf("generate response=%+v cloner=%+v", generateResp, cloner)
+ }
+ if _, err := os.Stat(filepath.Join(generateOut, ".git")); !os.IsNotExist(err) {
+ t.Fatalf("generate should remove .git, stat err=%v", err)
+ }
+ got, err = os.ReadFile(filepath.Join(generateOut, "app.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "feature Team" {
+ t.Fatalf("generate output = %q", string(got))
+ }
+}
diff --git a/internal/web/static/app.js b/internal/web/static/app.js
new file mode 100644
index 0000000..3d02717
--- /dev/null
+++ b/internal/web/static/app.js
@@ -0,0 +1,418 @@
+const rows = document.querySelector("#rows");
+const statusEl = document.querySelector("#status");
+const notice = document.querySelector("#notice");
+const cloneRepo = document.querySelector("#cloneRepo");
+const templateSelect = document.querySelector("#templateSelect");
+const savedRunsList = document.querySelector("#savedRunsList");
+const presetSearch = document.querySelector("#presetSearch");
+let summary = { keys: [], counts: {}, values: {} };
+let repoType = "ssh";
+let activeMode = "local";
+let lastRunMeta = { mode: "local" };
+let savedRuns = [];
+let presetFilter = "all";
+let evaluateTimer = 0;
+
+function show(msg, kind) {
+ notice.textContent = msg;
+ notice.className = "banner show " + kind;
+}
+
+function esc(v) {
+ return String(v || "").replace(/[&<>"']/g, m => ({
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ "\"": """,
+ "'": "'"
+ }[m]));
+}
+
+function values() {
+ const out = {};
+ document.querySelectorAll("[data-key]").forEach(i => out[i.dataset.key] = i.value);
+ return out;
+}
+
+function db() {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open("yankrun-workbench", 1);
+ req.onupgradeneeded = () => req.result.createObjectStore("runs", { keyPath: "id" });
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+async function storeRun(run) {
+ const database = await db();
+ return new Promise((resolve, reject) => {
+ const tx = database.transaction("runs", "readwrite");
+ tx.objectStore("runs").put(run);
+ tx.oncomplete = resolve;
+ tx.onerror = () => reject(tx.error);
+ });
+}
+
+async function allRuns() {
+ const database = await db();
+ return new Promise((resolve, reject) => {
+ const tx = database.transaction("runs", "readonly");
+ const req = tx.objectStore("runs").getAll();
+ req.onsuccess = () => resolve(req.result || []);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+async function refreshSavedRuns() {
+ savedRuns = (await allRuns()).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+ renderSavedRuns();
+}
+
+function renderSavedRuns() {
+ const targets = new Set(savedRuns.map(runTarget));
+ document.querySelector("#savedMeta").textContent = savedRuns.length + " presets · " + targets.size + " targets";
+ const query = presetSearch.value.trim().toLowerCase();
+ const visible = savedRuns.filter(r => {
+ if (presetFilter !== "all" && r.kind !== presetFilter) return false;
+ if (!query) return true;
+ return [
+ r.kind,
+ runTarget(r),
+ r.payload.branch || "",
+ r.payload.outputDir || "",
+ Object.keys(r.values || {}).join(" ")
+ ].join(" ").toLowerCase().includes(query);
+ });
+ savedRunsList.innerHTML = visible.length
+ ? visible.slice(0, 8).map(renderSavedRun).join("")
+ : 'No matching presets.
';
+ savedRunsList.querySelectorAll("[data-run-id]").forEach(b => b.addEventListener("click", () => loadRun(b.dataset.runId)));
+}
+
+async function rememberRun(kind, payload, body) {
+ const labelTarget = payload.repo || payload.template || "local";
+ const run = {
+ id: Date.now().toString(36) + Math.random().toString(36).slice(2),
+ label: kind + " · " + labelTarget,
+ kind,
+ payload,
+ values: values(),
+ summary: body.summary || summary,
+ target: labelTarget,
+ placeholders: body.summary && body.summary.keys ? body.summary.keys.length : summary.keys.length,
+ matches: body.summary && body.summary.counts ? totalMatches(body.summary.counts) : totalMatches(summary.counts),
+ createdAt: new Date().toISOString()
+ };
+ await storeRun(run);
+ await refreshSavedRuns();
+}
+
+function runTarget(run) {
+ return run.target || run.payload.repo || run.payload.template || "local";
+}
+
+function totalMatches(counts) {
+ return counts ? Object.values(counts).reduce((n, v) => n + v, 0) : 0;
+}
+
+function shortTarget(target) {
+ return target.replace(/^https:\/\/github\.com\//, "").replace(/^git@github\.com:/, "").replace(/\.git$/, "");
+}
+
+function renderSavedRun(run) {
+ const target = runTarget(run);
+ const branch = run.payload.branch ? " · " + run.payload.branch : "";
+ const matches = run.matches || (run.summary && run.summary.counts ? totalMatches(run.summary.counts) : 0);
+ const placeholders = run.placeholders || (run.summary && run.summary.keys ? run.summary.keys.length : 0);
+ return '' +
+ '' + esc(run.kind) + ' ' + esc(shortTarget(target)) + '
' +
+ '' + placeholders + ' keys ' + matches + ' hits
' +
+ '' + esc(new Date(run.createdAt).toLocaleString()) + branch + ' ' +
+ ' ';
+}
+
+function setBusy(label) {
+ statusEl.textContent = label;
+ document.querySelectorAll("button").forEach(b => b.disabled = true);
+}
+
+function setReady() {
+ statusEl.textContent = "ready";
+ document.querySelectorAll("button").forEach(b => b.disabled = false);
+ const applyButton = document.querySelector("#apply");
+ if (applyButton && applyButton.dataset.forceDry === "true") {
+ applyButton.disabled = true;
+ }
+}
+
+async function readJSON(r) {
+ const body = await r.json().catch(() => ({}));
+ if (!r.ok) throw new Error(body.error || "Request failed");
+ return body;
+}
+
+async function scan() {
+ setBusy("scanning");
+ try {
+ const r = await fetch("/api/scan");
+ summary = await readJSON(r);
+ render();
+ } catch (err) {
+ show(err.message || "Scan failed", "err");
+ } finally {
+ setReady();
+ }
+}
+
+function render() {
+ const total = summary.keys.reduce((n, k) => n + (summary.counts[k] || 0), 0);
+ document.querySelector("#metricKeys").textContent = summary.keys.length;
+ document.querySelector("#metricMatches").textContent = total;
+ if (!summary.keys.length) {
+ rows.innerHTML = 'No placeholders found.
';
+ return;
+ }
+ rows.innerHTML = summary.keys.map((k, i) =>
+ '' +
+ '
' + esc(k) + ' ' +
+ ' ' +
+ renderTree(k) + '
' +
+ '
' + (summary.counts[k] || 0) + ' hits
'
+ ).join("");
+ document.querySelectorAll("[data-key]").forEach(i => i.addEventListener("input", scheduleEvaluate));
+}
+
+function renderTree(key) {
+ const files = Array.isArray(summary.files) ? summary.files.filter(f => f.counts && f.counts[key]) : [];
+ if (!files.length) return "";
+ return '' +
+ '
./
' +
+ files.map((f, i) => {
+ const branch = i === files.length - 1 ? "`--" : "+--";
+ return '
' + branch + ' ' + esc(f.path) + ' ' + f.counts[key] + '
' +
+ renderPreviews(f, key);
+ }).join("") +
+ '
';
+}
+
+function renderPreviews(file, key) {
+ const previews = Array.isArray(file.previews) ? file.previews.filter(p => p.key === key) : [];
+ if (!previews.length) return "";
+ return previews.map(p => {
+ const value = p.error ? "error: " + p.error : (p.missing ? "missing value" : p.value);
+ const cls = p.error ? "bad" : (p.missing ? "missing" : "good");
+ return '' + esc(p.expression) + ' ' + esc(value) + '
';
+ }).join("");
+}
+
+async function apply(dryRun) {
+ const payload = { values: values(), dryRun };
+ setBusy(dryRun ? "previewing" : "applying");
+ try {
+ const body = await postJSON("/api/apply", payload);
+ reportResult(body, "Applied", "Preview");
+ await rememberRun("local", payload, body);
+ if (body.applied) await scan();
+ } catch (err) {
+ show(err.message || "Request failed", "err");
+ } finally {
+ setReady();
+ }
+}
+
+function scheduleEvaluate() {
+ clearTimeout(evaluateTimer);
+ statusEl.textContent = "editing";
+ evaluateTimer = setTimeout(evaluateCurrent, 2000);
+}
+
+async function evaluateCurrent() {
+ if (!summary.keys || !summary.keys.length) return;
+ const active = document.activeElement && document.activeElement.dataset ? document.activeElement.dataset.key : "";
+ const start = document.activeElement && document.activeElement.selectionStart;
+ const end = document.activeElement && document.activeElement.selectionEnd;
+ statusEl.textContent = "evaluating";
+ try {
+ summary = await postJSON("/api/evaluate", { summary, values: values() });
+ render();
+ if (active) {
+ const input = document.querySelector('[data-key="' + CSS.escape(active) + '"]');
+ if (input) {
+ input.focus();
+ if (Number.isInteger(start) && Number.isInteger(end)) input.setSelectionRange(start, end);
+ }
+ }
+ } catch (err) {
+ show(err.message || "Evaluate failed", "err");
+ } finally {
+ setReady();
+ }
+}
+
+async function postJSON(url, payload) {
+ const r = await fetch(url, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(payload)
+ });
+ return readJSON(r);
+}
+
+function reportResult(body, appliedVerb, previewVerb) {
+ if (body.summary && Array.isArray(body.summary.keys)) {
+ summary = body.summary;
+ render();
+ }
+ if (body.applied) {
+ show(appliedVerb + " " + body.totalMatches + " replacements across " + body.placeholders + " placeholders.", "ok");
+ return;
+ }
+ show(previewVerb + ": " + body.totalMatches + " replacements across " + body.placeholders + " placeholders. No files modified.", body.forcedDryRun ? "warn" : "ok");
+}
+
+function setMode(mode) {
+ activeMode = mode;
+ document.querySelectorAll("[data-mode]").forEach(b => b.classList.toggle("active", b.dataset.mode === mode));
+ document.querySelectorAll(".mode").forEach(p => p.classList.toggle("active", p.id === "mode-" + mode));
+}
+
+function setRepoType(next) {
+ repoType = next;
+ document.querySelectorAll("[data-repo-type]").forEach(b => b.classList.toggle("active", b.dataset.repoType === next));
+ cloneRepo.placeholder = next === "ssh" ? "git@github.com:org/repo.git" : "https://github.com/org/repo.git";
+}
+
+async function loadTemplates() {
+ try {
+ const r = await fetch("/api/templates");
+ const templates = await readJSON(r);
+ const options = Array.isArray(templates) ? templates : [];
+ if (!options.length) {
+ templateSelect.innerHTML = 'No configured templates ';
+ return;
+ }
+ templateSelect.innerHTML = options.map(t =>
+ '' + esc(t.name) + (t.defaultBranch ? " · " + esc(t.defaultBranch) : "") + ' '
+ ).join("");
+ } catch (err) {
+ templateSelect.innerHTML = 'Template lookup failed ';
+ show(err.message || "Template lookup failed", "err");
+ }
+}
+
+async function cloneApply(dryRun) {
+ const payload = {
+ repo: cloneRepo.value.trim(),
+ branch: document.querySelector("#cloneBranch").value.trim(),
+ outputDir: document.querySelector("#cloneOutput").value.trim(),
+ values: values(),
+ dryRun
+ };
+ lastRunMeta = { mode: "clone", repo: payload.repo, branch: payload.branch, outputDir: payload.outputDir };
+ setBusy(dryRun ? "previewing clone" : "cloning");
+ try {
+ const body = await postJSON("/api/clone", payload);
+ reportResult(body, "Cloned and applied", "Clone preview");
+ await rememberRun("clone", payload, body);
+ if (body.applied) await scan();
+ } catch (err) {
+ show(err.message || "Clone failed", "err");
+ } finally {
+ setReady();
+ }
+}
+
+async function generateApply(dryRun) {
+ const payload = {
+ template: templateSelect.value,
+ branch: document.querySelector("#generateBranch").value.trim(),
+ outputDir: document.querySelector("#generateOutput").value.trim(),
+ values: values(),
+ dryRun
+ };
+ lastRunMeta = { mode: "generate", template: payload.template, branch: payload.branch, outputDir: payload.outputDir };
+ setBusy(dryRun ? "previewing generate" : "generating");
+ try {
+ const body = await postJSON("/api/generate", payload);
+ reportResult(body, "Generated and applied", "Generate preview");
+ await rememberRun("generate", payload, body);
+ if (body.applied) await scan();
+ } catch (err) {
+ show(err.message || "Generate failed", "err");
+ } finally {
+ setReady();
+ }
+}
+
+function loadRun(id) {
+ const selected = savedRuns.find(r => r.id === id);
+ if (!selected) return;
+ Object.entries(selected.values || {}).forEach(([key, value]) => {
+ const input = document.querySelector('[data-key="' + CSS.escape(key) + '"]');
+ if (input) input.value = value;
+ });
+ if (selected.summary) {
+ summary = selected.summary;
+ render();
+ }
+ if (selected.kind === "clone") {
+ setMode("clone");
+ cloneRepo.value = selected.payload.repo || "";
+ document.querySelector("#cloneBranch").value = selected.payload.branch || "";
+ document.querySelector("#cloneOutput").value = selected.payload.outputDir || "";
+ }
+ if (selected.kind === "generate") {
+ setMode("generate");
+ templateSelect.value = selected.payload.template || "";
+ document.querySelector("#generateBranch").value = selected.payload.branch || "";
+ document.querySelector("#generateOutput").value = selected.payload.outputDir || "";
+ }
+ if (selected.kind === "local") setMode("local");
+ show("Loaded saved run.", "ok");
+}
+
+function exportRuns() {
+ const blob = new Blob([JSON.stringify({ version: 1, runs: savedRuns }, null, 2)], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = "yankrun-workbench-runs.json";
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
+async function importRuns(file) {
+ const parsed = JSON.parse(await file.text());
+ const runs = Array.isArray(parsed.runs) ? parsed.runs : [];
+ for (const run of runs) {
+ if (run && run.id) await storeRun(run);
+ }
+ await refreshSavedRuns();
+ show("Imported " + runs.length + " saved runs.", "ok");
+}
+
+document.querySelectorAll("[data-mode]").forEach(b => b.addEventListener("click", () => setMode(b.dataset.mode)));
+document.querySelectorAll("[data-repo-type]").forEach(b => b.addEventListener("click", () => setRepoType(b.dataset.repoType)));
+document.querySelectorAll("[data-preset-filter]").forEach(b => b.addEventListener("click", () => {
+ presetFilter = b.dataset.presetFilter;
+ document.querySelectorAll("[data-preset-filter]").forEach(x => x.classList.toggle("active", x.dataset.presetFilter === presetFilter));
+ renderSavedRuns();
+}));
+presetSearch.addEventListener("input", renderSavedRuns);
+document.querySelector("#refresh").addEventListener("click", scan);
+document.querySelector("#preview").addEventListener("click", () => apply(true));
+document.querySelector("#apply").addEventListener("click", () => apply(false));
+document.querySelector("#clonePreview").addEventListener("click", () => cloneApply(true));
+document.querySelector("#cloneApply").addEventListener("click", () => cloneApply(false));
+document.querySelector("#generatePreview").addEventListener("click", () => generateApply(true));
+document.querySelector("#generateApply").addEventListener("click", () => generateApply(false));
+document.querySelector("#exportRuns").addEventListener("click", exportRuns);
+document.querySelector("#importRuns").addEventListener("click", () => document.querySelector("#importFile").click());
+document.querySelector("#importFile").addEventListener("change", e => {
+ const file = e.target.files && e.target.files[0];
+ if (file) importRuns(file).catch(err => show(err.message || "Import failed", "err"));
+ e.target.value = "";
+});
+loadTemplates();
+refreshSavedRuns().catch(() => show("Saved runs unavailable in this browser.", "warn"));
+scan();
diff --git a/internal/web/static/style.css b/internal/web/static/style.css
new file mode 100644
index 0000000..262db22
--- /dev/null
+++ b/internal/web/static/style.css
@@ -0,0 +1,97 @@
+:root{--ink:#111827;--muted:#667085;--line:#e5e7eb;--panel:#ffffff;--soft:#f7f8fb;--brand:#ff5a24;--brand-dark:#c33a14;--ok:#0f9f6e}
+*{box-sizing:border-box}
+body{margin:0;background:radial-gradient(circle at 12% 10%,#fff1eb 0,#fff7f3 26%,transparent 42%),linear-gradient(135deg,#fbfcff 0,#f3f5f9 100%);color:var(--ink);font-family:Geist,Satoshi,Aptos,Segoe UI,sans-serif;min-height:100dvh}
+.app-frame{display:grid;grid-template-columns:260px minmax(0,1180px);gap:28px;max-width:1496px;margin:0 auto;padding:28px 24px 42px;align-items:start}
+.shell{min-width:0}
+.preset-rail{position:sticky;top:28px;z-index:5;padding-top:58px}
+.preset-rail .card{padding:14px}
+.top{display:flex;justify-content:space-between;gap:18px;align-items:center;margin-bottom:34px}
+.brand{display:flex;align-items:center;gap:12px;font-weight:850;letter-spacing:-.02em}
+.mark{width:36px;height:36px;border-radius:11px;background:linear-gradient(135deg,#ff5a24,#f03716);display:grid;place-items:center;color:white;font-weight:900;box-shadow:0 18px 30px -18px rgba(255,90,36,.9)}
+.pill{border:1px solid var(--line);background:rgba(255,255,255,.72);backdrop-filter:blur(14px);border-radius:999px;padding:10px 14px;color:var(--muted);font-size:13px}
+.grid{display:grid;grid-template-columns:minmax(0,1.08fr) minmax(320px,.72fr);gap:24px;align-items:start}
+.hero{padding:18px 0}
+.eyebrow{display:inline-flex;gap:8px;align-items:center;border:1px solid #ffd6c7;background:#fff7f2;color:#9f3418;border-radius:999px;padding:8px 12px;font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase}
+.dot{width:8px;height:8px;border-radius:999px;background:var(--brand);animation:pulse 1.8s infinite}
+.hero h1{font-size:clamp(42px,5vw,82px);line-height:.92;margin:22px 0 18px;letter-spacing:-.055em}
+.hero p{font-size:18px;line-height:1.65;color:var(--muted);max-width:680px}
+.panel{border:1px solid rgba(17,24,39,.08);background:rgba(255,255,255,.78);box-shadow:0 30px 80px -55px rgba(17,24,39,.35),inset 0 1px 0 rgba(255,255,255,.9);backdrop-filter:blur(18px);border-radius:18px;overflow:hidden}
+.placeholder-panel{margin-top:24px}
+.toolbar{display:flex;justify-content:space-between;gap:12px;align-items:center;padding:18px;border-bottom:1px solid var(--line)}
+.toolbar.mini{padding:0 0 12px;border-bottom:0}
+.saved-head{align-items:flex-start}
+.saved-head>div:first-child{display:grid;gap:2px}
+.saved-head span{color:#667085;font-size:11px;font-weight:800}
+.mini-actions{display:flex;gap:6px}
+.toolbar strong{font-size:15px}
+.status{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted)}
+.rows{display:grid;gap:10px;padding:16px}
+.row{display:grid;grid-template-columns:1fr 82px;gap:12px;align-items:start;padding:12px;border:1px solid #edf0f5;background:white;border-radius:12px;animation:rise .42s cubic-bezier(.16,1,.3,1) both;animation-delay:calc(var(--i)*45ms)}
+.value-cell{min-width:0}
+label{display:block;font-size:12px;font-weight:800;color:#344054;margin-bottom:7px}
+input{width:100%;border:1px solid #d9dee8;border-radius:10px;padding:11px 12px;font:14px ui-monospace,SFMono-Regular,Menlo,monospace;background:#fbfcff;color:var(--ink);outline:none;transition:border .2s,box-shadow .2s,transform .2s}
+input:focus{border-color:#ff8a63;box-shadow:0 0 0 4px rgba(255,90,36,.12)}
+.count{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted);padding-top:35px}
+.file-tree{margin-top:10px;border:1px solid #eef1f6;background:linear-gradient(180deg,#fbfcff,#fff);border-radius:10px;padding:10px 12px;font:12px ui-monospace,SFMono-Regular,Menlo,monospace;color:#475467;overflow:hidden}
+.tree-root{color:#98a2b3;margin-bottom:4px}
+.tree-file{display:grid;grid-template-columns:30px minmax(0,1fr) auto;gap:6px;align-items:center;min-height:22px;animation:treeGlow .36s cubic-bezier(.16,1,.3,1) both}
+.branch{color:#ff5a24}
+.path{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.hit-pill{border:1px solid #ffd6c7;background:#fff7f2;color:#9f3418;border-radius:999px;padding:2px 7px;font-size:11px}
+.eval{grid-column:2/4;display:grid;grid-template-columns:minmax(0,1fr) minmax(120px,.8fr);gap:8px;align-items:center;margin:2px 0 7px;padding:7px 9px;border-radius:8px;background:#f6f8fb}
+.eval span,.eval strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.eval span{color:#667085}
+.eval strong{font-weight:800;color:#111827}
+.eval.good strong{color:#05603a}
+.eval.missing strong{color:#9a3412}
+.eval.bad strong{color:#991b1b}
+.side{display:grid;gap:16px}
+.card{padding:18px}
+.metric{display:flex;justify-content:space-between;gap:16px;padding:14px 0;border-bottom:1px solid var(--line)}
+.metric:last-child{border-bottom:0}
+.metric span{color:var(--muted);font-size:13px}
+.metric strong{font:700 18px ui-monospace,SFMono-Regular,Menlo,monospace}
+.actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:16px}
+.mode-tabs{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:16px}
+.mode-tabs button.active{background:#111827;color:white}
+.choice-row{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin-bottom:12px}
+.choice-row button.active{background:#111827;color:white}
+.mode{display:none}
+.mode.active{display:block;animation:rise .28s cubic-bezier(.16,1,.3,1)}
+.helper{margin:0;color:var(--muted);font-size:14px;line-height:1.55}
+.helper.compact{font-size:12px;margin-top:10px}
+code{font:13px ui-monospace,SFMono-Regular,Menlo,monospace;background:#eef1f6;border-radius:7px;padding:2px 5px}
+select{width:100%;border:1px solid #d9dee8;border-radius:10px;padding:11px 12px;font:14px ui-monospace,SFMono-Regular,Menlo,monospace;background:#fbfcff;color:var(--ink);outline:none;margin-bottom:12px}
+.mode label:not(:first-child){margin-top:12px}
+button{border:0;border-radius:12px;padding:12px 14px;font-weight:850;cursor:pointer;transition:transform .22s cubic-bezier(.16,1,.3,1),background .2s,opacity .2s}
+button:active{transform:translateY(1px) scale(.99)}
+button:disabled{cursor:not-allowed;opacity:.48}
+.primary{background:var(--brand);color:white;box-shadow:0 16px 35px -22px var(--brand-dark)}
+.ghost{background:#111827;color:white}
+.quiet{background:#eef1f6;color:#344054}
+.icon-btn{padding:7px 9px;border-radius:9px;font-size:11px}
+.preset-search{margin:0 0 10px;font-size:12px;padding:9px 10px}
+.preset-filters{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:10px}
+.preset-filters button{padding:7px 6px;border-radius:9px;font-size:10px}
+.preset-filters button.active{background:#111827;color:white}
+.saved-list{display:grid;gap:8px;max-height:calc(100dvh - 190px);overflow:auto}
+.saved-run{width:100%;display:grid;gap:8px;text-align:left;border:1px solid #edf0f5;background:#fbfcff;border-radius:10px;padding:10px}
+.saved-run:hover{background:#fff7f2;border-color:#ffd6c7}
+.saved-main{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px;align-items:center}
+.saved-main strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
+.saved-stats{display:flex;gap:6px;flex-wrap:wrap}
+.saved-stats span{border:1px solid #e5e7eb;background:white;color:#344054;border-radius:999px;padding:3px 7px;font-size:10px;font-weight:900}
+.saved-time{color:#667085;font-size:11px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.saved-kind{align-self:start;border:1px solid #ffd6c7;background:#fff7f2;color:#9f3418;border-radius:999px;padding:2px 7px;font-size:10px;text-transform:uppercase;font-weight:900}
+.saved-empty{border:1px dashed #d9dee8;border-radius:10px;padding:16px;text-align:center;color:#667085;font-size:13px;background:#fbfcff}
+.empty{padding:34px;color:var(--muted);text-align:center}
+.banner{margin-top:14px;border-radius:12px;padding:13px 14px;font-size:14px;display:none}
+.banner.show{display:block;animation:rise .32s cubic-bezier(.16,1,.3,1)}
+.banner.ok{background:#ecfdf5;color:#05603a}
+.banner.warn{background:#fff7ed;color:#9a3412}
+.banner.err{background:#fef2f2;color:#991b1b}
+@keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
+@keyframes treeGlow{from{opacity:0;transform:translateX(-6px)}to{opacity:1;transform:translateX(0)}}
+@keyframes pulse{0%,100%{transform:scale(1);opacity:.72}50%{transform:scale(1.65);opacity:.28}}
+@media(max-width:1260px){.app-frame{grid-template-columns:1fr;max-width:1180px}.preset-rail{position:static;padding-top:0;order:2}.preset-rail .saved-list{max-height:220px}}
+@media(max-width:860px){.app-frame{padding:20px 16px 34px}.grid{grid-template-columns:1fr}.top{align-items:flex-start;flex-direction:column}.hero h1{font-size:44px}.row{grid-template-columns:1fr}}
diff --git a/internal/web/templates/workspace.html.tmpl b/internal/web/templates/workspace.html.tmpl
new file mode 100644
index 0000000..d5d52f7
--- /dev/null
+++ b/internal/web/templates/workspace.html.tmpl
@@ -0,0 +1,99 @@
+
+
+
+
+
+YankRun Workbench
+
+
+
+
+
+
+
+
+ {{.Dir}} · {{.StartDelim}}KEY{{.EndDelim}}
+
+
+
+
Scan · Preview · Apply
+
Template repos without guessing.
+
Edit discovered values, preview the replacement count, and apply only when the result is clear. The server is local by default and dry-run mode blocks writes.
+
+
+
Discovered placeholders loading
+
+
+
+
+
+
+ Local
+ Clone
+ Generate
+
+
+
Use the directory passed with --dir.
+
+
+
Repository access
+
+ SSH
+ HTTPS
+
+
Repository URL
+
+
Branch
+
+
Output directory
+
+
SSH uses your local git/agent auth. HTTPS works for public repos and credential-managed private repos.
+
Preview clone Clone and apply
+
+
+
Template
+
+
Branch
+
+
Output directory
+
+
Templates come from ~/.yankrun/config.yaml and configured GitHub template discovery.
+
Preview generate Generate and apply
+
+
+
+
Placeholders 0
+
Total matches 0
+
Write mode {{if .ForceDryRun}}DRY{{else}}LIVE{{end}}
+
+ Rescan
+ Preview
+ Apply
+
+
+
+
+
+
+
+
+
diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go
new file mode 100644
index 0000000..951e5c3
--- /dev/null
+++ b/internal/workflow/workflow.go
@@ -0,0 +1,257 @@
+package workflow
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+
+ "github.com/AxeForging/yankrun/domain"
+ "github.com/AxeForging/yankrun/services"
+)
+
+type Engine struct {
+ Parser services.ReplacementParser
+ Replacer services.Replacer
+ Cloner services.Cloner
+}
+
+type TemplateSettings struct {
+ StartDelim string
+ EndDelim string
+ FileSizeLimit string
+ ProcessTemplates bool
+ OnlyTemplates bool
+ Verbose bool
+ IgnorePatterns []string
+}
+
+type Summary struct {
+ Counts map[string]int `json:"counts"`
+ Files []FileSummary `json:"files"`
+ Keys []string `json:"keys"`
+ Values map[string]string `json:"values"`
+}
+
+type FileSummary struct {
+ Path string `json:"path"`
+ Counts map[string]int `json:"counts"`
+ Previews []ValuePreview `json:"previews"`
+}
+
+type ValuePreview struct {
+ Key string `json:"key"`
+ Expression string `json:"expression"`
+ Value string `json:"value"`
+ Count int `json:"count"`
+ Missing bool `json:"missing"`
+ Error string `json:"error,omitempty"`
+}
+
+type ApplyResult struct {
+ Applied bool `json:"applied"`
+ ForcedDryRun bool `json:"forcedDryRun"`
+ TotalMatches int `json:"totalMatches"`
+ Placeholders int `json:"placeholders"`
+ Summary Summary `json:"summary"`
+}
+
+func (e Engine) LoadInput(input string) (domain.InputReplacement, error) {
+ if input == "" {
+ return domain.InputReplacement{}, nil
+ }
+ return e.Parser.Parse(input)
+}
+
+func (e Engine) ScanDir(dir string, settings TemplateSettings, provided domain.InputReplacement) (Summary, error) {
+ files, err := e.Replacer.AnalyzeDirDetails(dir, settings.FileSizeLimit, settings.StartDelim, settings.EndDelim, settings.OnlyTemplates, settings.IgnorePatterns)
+ if err != nil {
+ return Summary{}, err
+ }
+ summary := Summarize(files, provided)
+ e.EvaluateSummary(&summary, files, summary.Values)
+ return summary, nil
+}
+
+func (e Engine) ApplyDir(dir string, settings TemplateSettings, provided domain.InputReplacement, values map[string]string, dryRun bool, forceDryRun bool) (ApplyResult, error) {
+ files, err := e.Replacer.AnalyzeDirDetails(dir, settings.FileSizeLimit, settings.StartDelim, settings.EndDelim, settings.OnlyTemplates, settings.IgnorePatterns)
+ if err != nil {
+ return ApplyResult{}, err
+ }
+ summary := Summarize(files, provided)
+ merged := MergeValues(summary.Values, values)
+ summary.Values = merged
+ e.EvaluateSummary(&summary, files, merged)
+ final := BuildFinal(summary.Keys, merged)
+ result := ApplyResult{
+ Applied: false,
+ ForcedDryRun: forceDryRun,
+ TotalMatches: ReplacementMatchCount(summary.Keys, summary.Counts, merged),
+ Placeholders: len(final.Variables),
+ Summary: summary,
+ }
+ if dryRun || forceDryRun || len(final.Variables) == 0 {
+ return result, nil
+ }
+ if err := e.ApplyFinal(dir, settings, final); err != nil {
+ return ApplyResult{}, err
+ }
+ result.Applied = true
+ return result, nil
+}
+
+func (e Engine) ApplyFinal(dir string, settings TemplateSettings, final domain.InputReplacement) error {
+ if !settings.OnlyTemplates {
+ if err := e.Replacer.ReplaceInDir(dir, final, settings.FileSizeLimit, settings.StartDelim, settings.EndDelim, settings.Verbose, settings.IgnorePatterns); err != nil {
+ return err
+ }
+ }
+ if settings.ProcessTemplates {
+ if err := e.Replacer.ProcessTemplateFiles(dir, final, settings.FileSizeLimit, settings.StartDelim, settings.EndDelim, settings.Verbose, settings.IgnorePatterns); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+type CloneOptions struct {
+ Repo string
+ Branch string
+ OutputDir string
+ RemoveGit bool
+ DryRun bool
+ ForceDry bool
+}
+
+func (e Engine) CloneAndApply(opts CloneOptions, settings TemplateSettings, values map[string]string) (string, ApplyResult, error) {
+ if e.Cloner == nil {
+ return "", ApplyResult{}, fmt.Errorf("clone support is not configured")
+ }
+ if opts.Repo == "" {
+ return "", ApplyResult{}, fmt.Errorf("repo is required")
+ }
+ workDir := opts.OutputDir
+ if opts.DryRun || opts.ForceDry {
+ tmp, err := os.MkdirTemp("", "yankrun-dryrun-*")
+ if err != nil {
+ return "", ApplyResult{}, err
+ }
+ defer os.RemoveAll(tmp)
+ workDir = tmp
+ } else if workDir == "" {
+ return "", ApplyResult{}, fmt.Errorf("outputDir is required")
+ }
+
+ if opts.Branch != "" {
+ if err := e.Cloner.CloneRepositoryBranch(opts.Repo, opts.Branch, workDir); err != nil {
+ return "", ApplyResult{}, err
+ }
+ } else if err := e.Cloner.CloneRepository(opts.Repo, workDir); err != nil {
+ return "", ApplyResult{}, err
+ }
+ if opts.RemoveGit {
+ if err := os.RemoveAll(filepath.Join(workDir, ".git")); err != nil {
+ return "", ApplyResult{}, err
+ }
+ }
+ result, err := e.ApplyDir(workDir, settings, domain.InputReplacement{}, values, opts.DryRun, opts.ForceDry)
+ if err != nil {
+ return "", ApplyResult{}, err
+ }
+ return workDir, result, nil
+}
+
+func Summarize(files []services.ReplacementFile, provided domain.InputReplacement) Summary {
+ counts := map[string]int{}
+ fileSummaries := make([]FileSummary, 0, len(files))
+ for _, file := range files {
+ fileCounts := map[string]int{}
+ for key, count := range file.Counts {
+ counts[key] += count
+ fileCounts[key] = count
+ }
+ fileSummaries = append(fileSummaries, FileSummary{Path: file.Path, Counts: fileCounts})
+ }
+ sort.Slice(fileSummaries, func(i, j int) bool {
+ return fileSummaries[i].Path < fileSummaries[j].Path
+ })
+ keys := make([]string, 0, len(counts))
+ for k := range counts {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ values := map[string]string{}
+ for _, r := range provided.Variables {
+ values[r.Key] = r.Value
+ }
+ return Summary{Counts: counts, Files: fileSummaries, Keys: keys, Values: values}
+}
+
+func (e Engine) EvaluateSummary(summary *Summary, files []services.ReplacementFile, values map[string]string) {
+ if summary == nil || e.Replacer == nil {
+ return
+ }
+ byPath := map[string]services.ReplacementFile{}
+ for _, file := range files {
+ byPath[file.Path] = file
+ }
+ for i := range summary.Files {
+ file, ok := byPath[summary.Files[i].Path]
+ if !ok {
+ continue
+ }
+ previews := make([]ValuePreview, 0, len(file.Placeholders))
+ for _, occurrence := range file.Placeholders {
+ value, found, err := e.Replacer.EvaluatePlaceholder(occurrence.Expression, values)
+ preview := ValuePreview{
+ Key: occurrence.Key,
+ Expression: occurrence.Expression,
+ Value: value,
+ Count: occurrence.Count,
+ Missing: !found,
+ }
+ if err != nil {
+ preview.Error = err.Error()
+ }
+ previews = append(previews, preview)
+ }
+ sort.Slice(previews, func(a, b int) bool {
+ if previews[a].Key == previews[b].Key {
+ return previews[a].Expression < previews[b].Expression
+ }
+ return previews[a].Key < previews[b].Key
+ })
+ summary.Files[i].Previews = previews
+ }
+}
+
+func MergeValues(base map[string]string, override map[string]string) map[string]string {
+ merged := map[string]string{}
+ for k, v := range base {
+ merged[k] = v
+ }
+ for k, v := range override {
+ merged[k] = v
+ }
+ return merged
+}
+
+func BuildFinal(keys []string, values map[string]string) domain.InputReplacement {
+ final := domain.InputReplacement{}
+ for _, k := range keys {
+ if v, ok := values[k]; ok && v != "" {
+ final.Variables = append(final.Variables, domain.Replacement{Key: k, Value: v})
+ }
+ }
+ return final
+}
+
+func ReplacementMatchCount(keys []string, counts map[string]int, values map[string]string) int {
+ total := 0
+ for _, k := range keys {
+ if v, ok := values[k]; ok && v != "" {
+ total += counts[k]
+ }
+ }
+ return total
+}
diff --git a/main.go b/main.go
index e12e6ee..654bfd4 100644
--- a/main.go
+++ b/main.go
@@ -29,6 +29,8 @@ func main() {
templateAction := actions.NewTemplateAction(fs, parser, replacer)
cloneAction := actions.NewCloneAction(fs, parser, replacer, cloner)
generateAction := actions.NewGenerateAction(fs, cloner, parser, replacer)
+ serveAction := actions.NewServeAction(fs, parser, replacer, cloner)
+ tuiAction := actions.NewTUIAction(fs, parser, replacer)
app := cli.NewApp()
app.Name = "yankrun"
@@ -62,6 +64,18 @@ func main() {
Flags: []cli.Flag{inputFlag, outputDirFlag, verboseFlag, fileSizeLimitFlag, startDelimFlag, endDelimFlag, interactiveFlag, templateNameFlag, branchFlag, processTemplatesFlag, onlyTemplatesFlag, dryRunFlag, ignoreFlag, noCacheFlag, sshKeyFlag},
Action: generateAction.Execute,
},
+ {
+ Name: "tui",
+ Usage: "Open a safe terminal workflow for templating a directory",
+ Flags: []cli.Flag{inputFlag, dirFlag, verboseFlag, fileSizeLimitFlag, startDelimFlag, endDelimFlag, processTemplatesFlag, onlyTemplatesFlag, dryRunFlag, ignoreFlag},
+ Action: tuiAction.Execute,
+ },
+ {
+ Name: "serve",
+ Usage: "Run a local web UI for scanning, previewing, and applying template values",
+ Flags: []cli.Flag{inputFlag, dirFlag, addrFlag, verboseFlag, fileSizeLimitFlag, startDelimFlag, endDelimFlag, processTemplatesFlag, onlyTemplatesFlag, dryRunFlag, ignoreFlag},
+ Action: serveAction.Execute,
+ },
{
Name: "setup",
Usage: "create or update ~/.yankrun/config.yaml (use --show to display, --reset to delete)",
diff --git a/services/configio.go b/services/configio.go
index 845d02d..3a43244 100644
--- a/services/configio.go
+++ b/services/configio.go
@@ -1,61 +1,67 @@
package services
import (
- "os"
- "path/filepath"
+ "os"
+ "path/filepath"
- "github.com/mitchellh/go-homedir"
- "gopkg.in/yaml.v3"
+ "github.com/mitchellh/go-homedir"
+ "gopkg.in/yaml.v3"
- "github.com/AxeForging/yankrun/domain"
+ "github.com/AxeForging/yankrun/domain"
)
func configPath() (string, error) {
- home, err := homedir.Dir()
- if err != nil {
- return "", err
- }
- return filepath.Join(home, ".yankrun", "config.yaml"), nil
+ home, err := homedir.Dir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, ".yankrun", "config.yaml"), nil
}
func Load() (*domain.Config, error) {
- cfg := &domain.Config{}
- path, err := configPath()
- if err != nil {
- return cfg, err
- }
- f, err := os.OpenFile(path, os.O_RDONLY|os.O_CREATE, 0600)
- if err != nil {
- return cfg, err
- }
- defer f.Close()
- _ = yaml.NewDecoder(f).Decode(cfg)
- return cfg, nil
+ cfg := &domain.Config{}
+ path, err := configPath()
+ if err != nil {
+ return cfg, err
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ return cfg, err
+ }
+ f, err := os.OpenFile(path, os.O_RDONLY|os.O_CREATE, 0600)
+ if err != nil {
+ return cfg, err
+ }
+ defer f.Close()
+ _ = yaml.NewDecoder(f).Decode(cfg)
+ return cfg, nil
}
func Save(cfg *domain.Config) error {
- path, err := configPath()
- if err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
- return err
- }
- f, err := os.Create(path)
- if err != nil {
- return err
- }
- defer f.Close()
- enc := yaml.NewEncoder(f)
- enc.SetIndent(2)
- return enc.Encode(cfg)
+ path, err := configPath()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ return err
+ }
+ f, err := os.Create(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ enc := yaml.NewEncoder(f)
+ enc.SetIndent(2)
+ return enc.Encode(cfg)
}
// Reset deletes the configuration file entirely
func Reset() error {
- path, err := configPath()
- if err != nil { return err }
- return os.Remove(path)
+ path, err := configPath()
+ if err != nil {
+ return err
+ }
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
}
-
-
diff --git a/services/configio_test.go b/services/configio_test.go
new file mode 100644
index 0000000..bff529b
--- /dev/null
+++ b/services/configio_test.go
@@ -0,0 +1,33 @@
+package services
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLoadCreatesConfigDirectory(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load failed: %v", err)
+ }
+ if cfg == nil {
+ t.Fatal("Load returned nil config")
+ }
+
+ path := filepath.Join(home, ".yankrun", "config.yaml")
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("expected config file to exist: %v", err)
+ }
+}
+
+func TestResetIgnoresMissingConfig(t *testing.T) {
+ t.Setenv("HOME", t.TempDir())
+
+ if err := Reset(); err != nil {
+ t.Fatalf("Reset failed for missing config: %v", err)
+ }
+}
diff --git a/services/replacer.go b/services/replacer.go
index a60786c..77da079 100644
--- a/services/replacer.go
+++ b/services/replacer.go
@@ -3,6 +3,7 @@ package services
import (
"bytes"
"fmt"
+ "path"
"path/filepath"
"regexp"
"strconv"
@@ -17,9 +18,23 @@ import (
type Replacer interface {
ReplaceInDir(dir string, replacements domain.InputReplacement, fileSizeLimit string, startDelim string, endDelim string, verbose bool, ignorePatterns []string) error
AnalyzeDir(dir string, fileSizeLimit string, startDelim string, endDelim string, onlyTemplates bool, ignorePatterns []string) (map[string]int, error)
+ AnalyzeDirDetails(dir string, fileSizeLimit string, startDelim string, endDelim string, onlyTemplates bool, ignorePatterns []string) ([]ReplacementFile, error)
+ EvaluatePlaceholder(expression string, values map[string]string) (string, bool, error)
ProcessTemplateFiles(dir string, replacements domain.InputReplacement, fileSizeLimit string, startDelim string, endDelim string, verbose bool, ignorePatterns []string) error
}
+type ReplacementFile struct {
+ Path string `json:"path"`
+ Counts map[string]int `json:"counts"`
+ Placeholders []ReplacementOccurrence `json:"placeholders"`
+}
+
+type ReplacementOccurrence struct {
+ Key string `json:"key"`
+ Expression string `json:"expression"`
+ Count int `json:"count"`
+}
+
type FileReplacer struct {
FileSystem FileSystem
}
@@ -30,13 +45,31 @@ func shouldIgnore(basePath, fullPath string, ignorePatterns []string) bool {
if err != nil {
return false
}
+ rel = filepath.ToSlash(rel)
+ base := path.Base(rel)
for _, pattern := range ignorePatterns {
- if matched, _ := filepath.Match(pattern, rel); matched {
+ pattern = filepath.ToSlash(pattern)
+ if matched, _ := path.Match(pattern, rel); matched {
return true
}
- if matched, _ := filepath.Match(pattern, filepath.Base(fullPath)); matched {
+ if matched, _ := path.Match(pattern, base); matched {
return true
}
+ if strings.HasSuffix(pattern, "/**") {
+ prefix := strings.TrimSuffix(pattern, "/**")
+ if rel == prefix || strings.HasPrefix(rel, prefix+"/") {
+ return true
+ }
+ }
+ if strings.HasPrefix(pattern, "**/") {
+ suffix := strings.TrimPrefix(pattern, "**/")
+ if matched, _ := path.Match(suffix, base); matched {
+ return true
+ }
+ if matched, _ := path.Match(suffix, rel); matched {
+ return true
+ }
+ }
}
return false
}
@@ -52,25 +85,62 @@ func (fr *FileReplacer) ReplaceInDir(dir string, replacements domain.InputReplac
// AnalyzeDir returns a map of placeholder -> count discovered in files within size limit
func (fr *FileReplacer) AnalyzeDir(dir string, fileSizeLimit string, startDelim string, endDelim string, onlyTemplates bool, ignorePatterns []string) (map[string]int, error) {
+ files, err := fr.AnalyzeDirDetails(dir, fileSizeLimit, startDelim, endDelim, onlyTemplates, ignorePatterns)
result := map[string]int{}
+ if err != nil {
+ return result, err
+ }
+ for _, file := range files {
+ for key, count := range file.Counts {
+ result[key] += count
+ }
+ }
+ return result, nil
+}
+
+func (fr *FileReplacer) AnalyzeDirDetails(dir string, fileSizeLimit string, startDelim string, endDelim string, onlyTemplates bool, ignorePatterns []string) ([]ReplacementFile, error) {
+ var result []ReplacementFile
fileSizeInBytes, err := fr.stringToBytes(fileSizeLimit)
if err != nil {
return result, err
}
- err = fr.walkAndAnalyze(dir, dir, fileSizeInBytes, startDelim, endDelim, result, onlyTemplates, ignorePatterns)
+ err = fr.walkAndAnalyzeDetails(dir, dir, fileSizeInBytes, startDelim, endDelim, &result, onlyTemplates, ignorePatterns)
return result, err
}
func (fr *FileReplacer) walkAndAnalyze(dir string, basePath string, fileSizeInBytes int64, startDelim string, endDelim string, result map[string]int, onlyTemplates bool, ignorePatterns []string) error {
- files, err := fr.FileSystem.ReadDir(dir)
+ files, err := fr.walkAndAnalyzeFiles(dir, basePath, fileSizeInBytes, startDelim, endDelim, onlyTemplates, ignorePatterns)
if err != nil {
return err
}
+ for _, file := range files {
+ for key, count := range file.Counts {
+ result[key] += count
+ }
+ }
+ return nil
+}
+
+func (fr *FileReplacer) walkAndAnalyzeDetails(dir string, basePath string, fileSizeInBytes int64, startDelim string, endDelim string, result *[]ReplacementFile, onlyTemplates bool, ignorePatterns []string) error {
+ files, err := fr.walkAndAnalyzeFiles(dir, basePath, fileSizeInBytes, startDelim, endDelim, onlyTemplates, ignorePatterns)
+ if err != nil {
+ return err
+ }
+ *result = append(*result, files...)
+ return nil
+}
+
+func (fr *FileReplacer) walkAndAnalyzeFiles(dir string, basePath string, fileSizeInBytes int64, startDelim string, endDelim string, onlyTemplates bool, ignorePatterns []string) ([]ReplacementFile, error) {
+ var result []ReplacementFile
+ files, err := fr.FileSystem.ReadDir(dir)
+ if err != nil {
+ return result, err
+ }
for _, file := range files {
path := fr.FileSystem.Join(dir, file.Name())
info, err := fr.FileSystem.Stat(path)
if err != nil {
- return err
+ return result, err
}
if info.IsDir() {
// Skip common directories
@@ -81,9 +151,11 @@ func (fr *FileReplacer) walkAndAnalyze(dir string, basePath string, fileSizeInBy
if shouldIgnore(basePath, path, ignorePatterns) {
continue
}
- if err := fr.walkAndAnalyze(path, basePath, fileSizeInBytes, startDelim, endDelim, result, onlyTemplates, ignorePatterns); err != nil {
- return err
+ nested, err := fr.walkAndAnalyzeFiles(path, basePath, fileSizeInBytes, startDelim, endDelim, onlyTemplates, ignorePatterns)
+ if err != nil {
+ return result, err
}
+ result = append(result, nested...)
continue
}
@@ -100,12 +172,15 @@ func (fr *FileReplacer) walkAndAnalyze(dir string, basePath string, fileSizeInBy
}
content, err := fr.FileSystem.ReadFile(path)
if err != nil {
- return err
+ return result, err
}
if isBinary(content) || isBinaryByExt(path) {
continue
}
text := string(content)
+ counts := map[string]int{}
+ expressionCounts := map[string]int{}
+ expressionKeys := map[string]string{}
// simple scan for startDelim ... endDelim occurrences
for {
start := strings.Index(text, startDelim)
@@ -125,11 +200,40 @@ func (fr *FileReplacer) walkAndAnalyze(dir string, basePath string, fileSizeInBy
text = text[end+len(endDelim):]
continue
}
- result[baseKey] = result[baseKey] + 1
+ counts[baseKey] = counts[baseKey] + 1
+ expressionCounts[keyWithTransforms]++
+ expressionKeys[keyWithTransforms] = baseKey
text = text[end+len(endDelim):]
}
+ if len(counts) > 0 {
+ rel, err := filepath.Rel(basePath, path)
+ if err != nil {
+ rel = path
+ }
+ occurrences := make([]ReplacementOccurrence, 0, len(expressionCounts))
+ for expr, count := range expressionCounts {
+ occurrences = append(occurrences, ReplacementOccurrence{Key: expressionKeys[expr], Expression: expr, Count: count})
+ }
+ result = append(result, ReplacementFile{Path: filepath.ToSlash(rel), Counts: counts, Placeholders: occurrences})
+ }
}
- return nil
+ return result, nil
+}
+
+func (fr *FileReplacer) EvaluatePlaceholder(expression string, values map[string]string) (string, bool, error) {
+ key, transformations, err := fr.parsePlaceholder(expression)
+ if err != nil {
+ return "", false, err
+ }
+ baseValue, ok := values[key]
+ if !ok || baseValue == "" {
+ return "", false, nil
+ }
+ finalValue, err := fr.applyTransformations(baseValue, transformations)
+ if err != nil {
+ return "", true, err
+ }
+ return finalValue, true, nil
}
// ProcessTemplateFiles processes .tpl files by evaluating templates and removing .tpl suffix
@@ -195,7 +299,7 @@ func (fr *FileReplacer) processTemplateFilesRecursive(dir string, basePath strin
// Process the template content
newContent := string(content)
numReplacements := 0
-
+
// Create a map for quick lookup of replacement values by base key
replacementValues := make(map[string]string)
for _, r := range replacements.Variables {
@@ -247,9 +351,12 @@ func (fr *FileReplacer) processTemplateFilesRecursive(dir string, basePath strin
// Create new filename without .tpl suffix
newPath := strings.TrimSuffix(path, ".tpl")
-
+ if _, err := fr.FileSystem.Stat(newPath); err == nil {
+ return fmt.Errorf("template target already exists: %s", newPath)
+ }
+
// Write the processed content to the new file
- err = fr.FileSystem.WriteFile(newPath, []byte(newContent), 0644)
+ err = fr.FileSystem.WriteFile(newPath, []byte(newContent), info.Mode().Perm())
if err != nil {
return err
}
@@ -271,15 +378,24 @@ func (fr *FileReplacer) processTemplateFilesRecursive(dir string, basePath strin
// parsePlaceholder extracts the base key and transformation functions from a placeholder string.
// Example: "WORLD:gsub(WORLD,galaxy):toUpperCase" -> "WORLD", ["gsub(WORLD,galaxy)", "toUpperCase"]
func (fr *FileReplacer) parsePlaceholder(placeholder string) (string, []string, error) {
+ if key, _, ok := strings.Cut(placeholder, "|default:"); ok {
+ placeholder = key
+ }
parts := strings.Split(placeholder, ":")
if len(parts) == 0 {
return "", nil, fmt.Errorf("empty placeholder")
}
- baseKey := parts[0]
+ baseKey := strings.TrimSpace(parts[0])
+ if baseKey == "" {
+ return "", nil, fmt.Errorf("empty placeholder")
+ }
var transformations []string
if len(parts) > 1 {
- transformations = parts[1:]
+ transformations = make([]string, 0, len(parts)-1)
+ for _, p := range parts[1:] {
+ transformations = append(transformations, strings.TrimSpace(p))
+ }
}
return baseKey, transformations, nil
}
@@ -378,7 +494,7 @@ func (fr *FileReplacer) replacePatterns(dir string, basePath string, replacement
numReplacements++
}
- err = fr.FileSystem.WriteFile(path, []byte(newContent), 0644)
+ err = fr.FileSystem.WriteFile(path, []byte(newContent), info.Mode().Perm())
if err != nil {
return err
}
@@ -397,11 +513,11 @@ func (fr *FileReplacer) applyTransformations(value string, transformations []str
for _, t := range transformations {
var err error
switch {
- case strings.HasPrefix(t, "toUpperCase"):
+ case t == "toUpperCase":
transformedValue = strings.ToUpper(transformedValue)
- case strings.HasPrefix(t, "toLowerCase"), strings.HasPrefix(t, "toDownCase"):
+ case t == "toLowerCase", t == "toDownCase":
transformedValue = strings.ToLower(transformedValue)
- case strings.HasPrefix(t, "gsub("):
+ case strings.HasPrefix(t, "gsub(") && strings.HasSuffix(t, ")"):
transformedValue, err = fr.applyGsub(transformedValue, t)
if err != nil {
return "", err
@@ -416,8 +532,11 @@ func (fr *FileReplacer) applyTransformations(value string, transformations []str
// applyGsub applies the gsub transformation.
// It parses arguments like "gsub(old,new)" or "gsub( ,new)"
func (fr *FileReplacer) applyGsub(value, transformFunc string) (string, error) {
+ if !strings.HasPrefix(transformFunc, "gsub(") || !strings.HasSuffix(transformFunc, ")") {
+ return "", fmt.Errorf("invalid gsub syntax: %s. Expected gsub(old,new)", transformFunc)
+ }
// Extract arguments from gsub(arg1,arg2)
- argsStr := transformFunc[len("gsub("):strings.LastIndex(transformFunc, ")")]
+ argsStr := transformFunc[len("gsub(") : len(transformFunc)-1]
// Split arguments, handling escaped commas or commas within quotes if necessary
// For now, a simple split by comma, assuming no escaped commas or quotes
diff --git a/services/replacer_test.go b/services/replacer_test.go
index 292ce9b..d643404 100644
--- a/services/replacer_test.go
+++ b/services/replacer_test.go
@@ -60,6 +60,8 @@ func TestParsePlaceholder(t *testing.T) {
{"APP_NAME", "APP_NAME", nil, false},
{"APP_NAME:toLowerCase", "APP_NAME", []string{"toLowerCase"}, false},
{"APP_NAME:gsub(-,_):toUpperCase", "APP_NAME", []string{"gsub(-,_)", "toUpperCase"}, false},
+ {"", "", nil, true},
+ {" :toUpperCase", "", nil, true},
}
for _, tt := range tests {
@@ -101,6 +103,8 @@ func TestApplyTransformations(t *testing.T) {
{"gsub", "hello world", []string{"gsub( ,-)"}, "hello-world", false},
{"chained", "Hello World", []string{"gsub( ,-)", "toLowerCase"}, "hello-world", false},
{"unsupported", "hello", []string{"capitalize"}, "", true},
+ {"malformed gsub", "hello", []string{"gsub(world,earth"}, "", true},
+ {"prefix is not accepted", "hello", []string{"toUpperCaseNow"}, "", true},
}
for _, tt := range tests {
@@ -136,6 +140,8 @@ func TestApplyGsub(t *testing.T) {
{"spaces to dashes", "hello world", "gsub( ,-)", "hello-world", false},
{"empty old replaces spaces", "hello world", "gsub(,_)", "hello_world", false},
{"invalid args", "hello", "gsub(only_one_arg)", "", true},
+ {"missing close paren", "hello", "gsub(world,earth", "", true},
+ {"missing open paren", "hello", "gsubworld,earth)", "", true},
}
for _, tt := range tests {
@@ -212,6 +218,8 @@ func TestShouldIgnore(t *testing.T) {
}{
{"no patterns", "/base", "/base/file.go", nil, false},
{"match basename", "/base", "/base/file.generated.go", []string{"*.generated.go"}, true},
+ {"match globstar directory", "/base", "/base/generated/deep/file.go", []string{"generated/**"}, true},
+ {"match globstar basename", "/base", "/base/subdir/file.lock", []string{"**/*.lock"}, true},
{"no match", "/base", "/base/file.go", []string{"*.generated.go"}, false},
{"empty patterns", "/base", "/base/file.go", []string{}, false},
}
@@ -234,12 +242,12 @@ type mockFileInfo struct {
modTime time.Time
}
-func (m mockFileInfo) Name() string { return m.name }
-func (m mockFileInfo) Size() int64 { return m.size }
-func (m mockFileInfo) Mode() fs.FileMode { return m.mode }
-func (m mockFileInfo) IsDir() bool { return m.dir }
-func (m mockFileInfo) Sys() interface{} { return nil }
-func (m mockFileInfo) ModTime() time.Time { return m.modTime }
+func (m mockFileInfo) Name() string { return m.name }
+func (m mockFileInfo) Size() int64 { return m.size }
+func (m mockFileInfo) Mode() fs.FileMode { return m.mode }
+func (m mockFileInfo) IsDir() bool { return m.dir }
+func (m mockFileInfo) Sys() interface{} { return nil }
+func (m mockFileInfo) ModTime() time.Time { return m.modTime }
func TestCheckFileSize(t *testing.T) {
fr := &FileReplacer{}
@@ -289,6 +297,31 @@ func TestReplaceInDir(t *testing.T) {
}
}
+func TestReplaceInDirPreservesPermissions(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "script.sh")
+ if err := os.WriteFile(path, []byte("echo [[NAME]]"), 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ fr := &FileReplacer{FileSystem: &OsFileSystem{}}
+ replacements := domain.InputReplacement{
+ Variables: []domain.Replacement{{Key: "NAME", Value: "Alice"}},
+ }
+
+ if err := fr.ReplaceInDir(dir, replacements, "3 mb", "[[", "]]", false, nil); err != nil {
+ t.Fatalf("ReplaceInDir failed: %v", err)
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := info.Mode().Perm(); got != 0755 {
+ t.Fatalf("mode = %o, want 755", got)
+ }
+}
+
func TestReplaceInDirWithIgnorePatterns(t *testing.T) {
dir := t.TempDir()
@@ -459,6 +492,60 @@ Port: 8080`
}
}
+func TestProcessTemplateFilesPreservesPermissions(t *testing.T) {
+ tempDir := t.TempDir()
+ tplFile := filepath.Join(tempDir, "script.sh.tpl")
+ if err := os.WriteFile(tplFile, []byte("echo [[NAME]]"), 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ replacer := &FileReplacer{FileSystem: &OsFileSystem{}}
+ replacements := domain.InputReplacement{
+ Variables: []domain.Replacement{{Key: "NAME", Value: "Alice"}},
+ }
+
+ if err := replacer.ProcessTemplateFiles(tempDir, replacements, "3 mb", "[[", "]]", false, nil); err != nil {
+ t.Fatalf("ProcessTemplateFiles failed: %v", err)
+ }
+
+ info, err := os.Stat(filepath.Join(tempDir, "script.sh"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := info.Mode().Perm(); got != 0755 {
+ t.Fatalf("mode = %o, want 755", got)
+ }
+}
+
+func TestProcessTemplateFilesErrorsWhenTargetExists(t *testing.T) {
+ tempDir := t.TempDir()
+ tplFile := filepath.Join(tempDir, "readme.md.tpl")
+ targetFile := filepath.Join(tempDir, "readme.md")
+ if err := os.WriteFile(tplFile, []byte("Hello [[NAME]]"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(targetFile, []byte("existing"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ replacer := &FileReplacer{FileSystem: &OsFileSystem{}}
+ replacements := domain.InputReplacement{
+ Variables: []domain.Replacement{{Key: "NAME", Value: "Alice"}},
+ }
+
+ if err := replacer.ProcessTemplateFiles(tempDir, replacements, "3 mb", "[[", "]]", false, nil); err == nil {
+ t.Fatal("expected error when template target already exists")
+ }
+
+ got, err := os.ReadFile(targetFile)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "existing" {
+ t.Fatalf("target file was overwritten: %q", string(got))
+ }
+}
+
func TestProcessTemplateFilesWithSubdirectories(t *testing.T) {
tempDir := t.TempDir()