From 29b6b8c23e275258fcc4e5117285e0f42e164270 Mon Sep 17 00:00:00 2001 From: taliesin-ai Date: Thu, 6 Aug 2026 10:58:09 +1000 Subject: [PATCH] preserve remote template config --- v3/internal/commands/build-assets.go | 9 ++- v3/internal/commands/build-assets_test.go | 31 ++++++++++ v3/internal/commands/init.go | 51 ++++++++++++++++ v3/internal/commands/init_test.go | 74 +++++++++++++++++++++++ v3/internal/templates/templates.go | 12 ++++ v3/internal/templates/templates_test.go | 16 ++++- 6 files changed, 191 insertions(+), 2 deletions(-) diff --git a/v3/internal/commands/build-assets.go b/v3/internal/commands/build-assets.go index 2b8b3a463d5..7c44a36d145 100644 --- a/v3/internal/commands/build-assets.go +++ b/v3/internal/commands/build-assets.go @@ -53,6 +53,7 @@ type BuildAssetsOptions struct { Silent bool `description:"Suppress output to console"` Typescript bool `description:"Use typescript" default:"false"` UseInterfaces bool `description:"Generate TypeScript interfaces instead of classes"` + PreserveConfig bool `description:"Preserve an existing config.yml while generating build assets"` } type TemplateEnrichment struct { @@ -161,7 +162,13 @@ func GenerateBuildAssets(options *BuildAssetsOptions) error { if !options.Silent { println("Generating build assets in " + options.Dir) } - err = gosod.New(tfs).Extract(options.Dir, config) + buildAssetsTemplate := gosod.New(tfs) + if options.PreserveConfig { + // A remote template may provide its own build/config.yml. Keep it instead + // of replacing it with Wails' default configuration during init. + buildAssetsTemplate.IgnoreFile("config.yml") + } + err = buildAssetsTemplate.Extract(options.Dir, config) if err != nil { return err } diff --git a/v3/internal/commands/build-assets_test.go b/v3/internal/commands/build-assets_test.go index 5b5aef43df0..92bdea2f516 100644 --- a/v3/internal/commands/build-assets_test.go +++ b/v3/internal/commands/build-assets_test.go @@ -156,6 +156,37 @@ func TestGenerateBuildAssets(t *testing.T) { } } +func TestGenerateBuildAssetsPreservesExistingConfig(t *testing.T) { + buildDir := filepath.Join(t.TempDir(), "build") + if err := os.MkdirAll(buildDir, 0o755); err != nil { + t.Fatal(err) + } + + const templateConfig = "# remote template settings\ndev_mode:\n debounce: 1000\n" + configPath := filepath.Join(buildDir, "config.yml") + if err := os.WriteFile(configPath, []byte(templateConfig), 0o644); err != nil { + t.Fatal(err) + } + + err := GenerateBuildAssets(&BuildAssetsOptions{ + Dir: buildDir, + Name: "PreservedConfig", + Silent: true, + PreserveConfig: true, + }) + if err != nil { + t.Fatalf("GenerateBuildAssets() error = %v", err) + } + + got, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if string(got) != templateConfig { + t.Fatalf("config.yml was overwritten: got %q, want %q", got, templateConfig) + } +} + func TestUpdateBuildAssets(t *testing.T) { // Create a temporary directory for testing tempDir, err := os.MkdirTemp("", "wails-update-assets-test-*") diff --git a/v3/internal/commands/init.go b/v3/internal/commands/init.go index d17a93fef33..83ac87d6283 100644 --- a/v3/internal/commands/init.go +++ b/v3/internal/commands/init.go @@ -254,11 +254,20 @@ func Init(options *flags.Init) error { } } + remoteTemplate := templates.IsRemoteTemplate(options.TemplateName) err := templates.Install(options) if err != nil { return err } + remoteConfigPath := "" + if remoteTemplate { + remoteConfigPath, err = prepareRemoteTemplateConfig(options.ProjectDir) + if err != nil { + return err + } + } + // Rename gitignore to .gitignore err = os.Rename(filepath.Join(options.ProjectDir, "gitignore"), filepath.Join(options.ProjectDir, ".gitignore")) if err != nil { @@ -286,11 +295,15 @@ func Init(options *flags.Init) error { ProductComments: options.ProductComments, Typescript: isTypescript, UseInterfaces: options.UseInterfaces, + PreserveConfig: remoteConfigPath != "", } err = GenerateBuildAssets(buildAssetsOptions) if err != nil { return err } + if remoteConfigPath != "" { + term.Warningf("Copied %s from the remote template. Please review this file before using the project.\n", filepath.ToSlash(remoteConfigPath)) + } // In UI mode the wizard is explicitly about the project config, so write the // chosen values into build/config.yml itself (the generated assets already @@ -314,6 +327,44 @@ func Init(options *flags.Init) error { return nil } +// prepareRemoteTemplateConfig keeps a remote template's build configuration +// from being replaced by the default build assets. Wails uses config.yml as +// the default path, so a config.yaml supplied by a template is also copied to +// that canonical path while retaining the original file. +func prepareRemoteTemplateConfig(projectDir string) (string, error) { + buildDir := filepath.Join(projectDir, "build") + for _, name := range []string{"config.yml", "config.yaml"} { + path := filepath.Join(buildDir, name) + info, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + continue + } + return "", err + } + if info.Mode()&os.ModeSymlink != 0 { + return "", errors.New("remote template build configuration must not be a symbolic link") + } + + if name == "config.yaml" { + canonicalPath := filepath.Join(buildDir, "config.yml") + if _, err := os.Stat(canonicalPath); os.IsNotExist(err) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + if err := os.WriteFile(canonicalPath, data, 0o644); err != nil { + return "", err + } + } + } + + return filepath.ToSlash(filepath.Join("build", name)), nil + } + + return "", nil +} + // writeProjectConfigYML rewrites the `info:` values in the freshly scaffolded // build/config.yml from the chosen options, preserving the file's comments. The // info keys map 1:1 onto the Product* fields. diff --git a/v3/internal/commands/init_test.go b/v3/internal/commands/init_test.go index 1ebf95abb6f..828ef1d7e75 100644 --- a/v3/internal/commands/init_test.go +++ b/v3/internal/commands/init_test.go @@ -1,6 +1,8 @@ package commands import ( + "os" + "path/filepath" "testing" "time" @@ -8,6 +10,78 @@ import ( "github.com/wailsapp/wails/v3/internal/flags" ) +func TestPrepareRemoteTemplateConfig(t *testing.T) { + tests := []struct { + name string + filename string + wantPath string + wantCopied bool + }{ + {name: "yml", filename: "config.yml", wantPath: "build/config.yml"}, + {name: "yaml", filename: "config.yaml", wantPath: "build/config.yaml", wantCopied: true}, + {name: "missing", wantPath: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + projectDir := t.TempDir() + buildDir := filepath.Join(projectDir, "build") + if tt.filename != "" { + if err := os.MkdirAll(buildDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(buildDir, tt.filename), []byte("template config\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + gotPath, err := prepareRemoteTemplateConfig(projectDir) + if err != nil { + t.Fatalf("prepareRemoteTemplateConfig() error = %v", err) + } + if gotPath != tt.wantPath { + t.Fatalf("prepareRemoteTemplateConfig() path = %q, want %q", gotPath, tt.wantPath) + } + + canonicalPath := filepath.Join(buildDir, "config.yml") + _, canonicalErr := os.Stat(canonicalPath) + if tt.wantCopied { + if canonicalErr != nil { + t.Fatalf("expected config.yaml to be copied to config.yml: %v", canonicalErr) + } + got, err := os.ReadFile(canonicalPath) + if err != nil { + t.Fatal(err) + } + if string(got) != "template config\n" { + t.Errorf("copied config = %q, want original template config", got) + } + } else if tt.filename == "" && !os.IsNotExist(canonicalErr) { + t.Errorf("unexpected canonical config file for missing source") + } + }) + } +} + +func TestPrepareRemoteTemplateConfigRejectsSymlink(t *testing.T) { + projectDir := t.TempDir() + buildDir := filepath.Join(projectDir, "build") + if err := os.MkdirAll(buildDir, 0o755); err != nil { + t.Fatal(err) + } + target := filepath.Join(projectDir, "outside-config.yml") + if err := os.WriteFile(target, []byte("not for copying\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(buildDir, "config.yaml")); err != nil { + t.Skipf("symbolic links are not supported: %v", err) + } + + if _, err := prepareRemoteTemplateConfig(projectDir); err == nil { + t.Fatal("expected symbolic-link configuration to be rejected") + } +} + func TestGitURLToModulePath(t *testing.T) { tests := []struct { name string diff --git a/v3/internal/templates/templates.go b/v3/internal/templates/templates.go index 7dd1c40f940..c134295e115 100644 --- a/v3/internal/templates/templates.go +++ b/v3/internal/templates/templates.go @@ -70,6 +70,18 @@ func ValidTemplateName(name string) bool { }) } +// IsRemoteTemplate reports whether name will be resolved as a remote template +// by Install. Any path lookup error is treated as remote here, matching the +// local-template lookup in Install; Install remains responsible for validating +// and fetching the template. +func IsRemoteTemplate(name string) bool { + if ValidTemplateName(name) { + return false + } + _, err := os.Stat(name) + return err != nil +} + func GetDefaultTemplates() []TemplateData { return defaultTemplates } diff --git a/v3/internal/templates/templates_test.go b/v3/internal/templates/templates_test.go index 8b2cefe5e75..45f55153270 100644 --- a/v3/internal/templates/templates_test.go +++ b/v3/internal/templates/templates_test.go @@ -8,6 +8,21 @@ import ( "testing/fstest" ) +func TestIsRemoteTemplate(t *testing.T) { + if IsRemoteTemplate("vanilla") { + t.Error("built-in template should not be treated as remote") + } + if !IsRemoteTemplate("https://github.com/example/template") { + t.Error("URL template should be treated as remote") + } + if !IsRemoteTemplate(filepath.Join(t.TempDir(), "missing-template")) { + t.Error("missing local path should be treated as remote") + } + if IsRemoteTemplate(t.TempDir()) { + t.Error("existing local directory should not be treated as remote") + } +} + // --- parseTemplate --- func TestParseTemplate_YAML_Valid(t *testing.T) { @@ -280,4 +295,3 @@ func TestGenerateTemplate_GeneratedTemplateCanBeInstalled(t *testing.T) { t.Errorf("WailsVersion = %d, want 3", tmpl.WailsVersion) } } -