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 @@
YankRun

- Go Version + Go Version OS Support License

@@ -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 ''; +} + +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) => + '
' + + '
' + + '' + + 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 = ''; + return; + } + templateSelect.innerHTML = options.map(t => + '' + ).join(""); + } catch (err) { + templateSelect.innerHTML = ''; + 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 + + + +
+ +
+
+
Y
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 placeholdersloading
+
Scanning directory...
+
+
+ +
+
+
+ + + 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()