From 77d76087ee04fa6b6c3c6d6f55c3653a0bfc9300 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Thu, 19 Feb 2026 10:35:44 +0100 Subject: [PATCH 1/4] Read plugin-owned paths from manifest during the `apps init` --- cmd/apps/init.go | 14 +++----- cmd/apps/init_test.go | 53 +++++++++++++++++++++++++++++ libs/apps/manifest/manifest.go | 13 +++++++ libs/apps/manifest/manifest_test.go | 28 +++++++++++++++ 4 files changed, 98 insertions(+), 10 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 199cde4e24..92e6221f73 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -796,7 +796,7 @@ func runCreate(ctx context.Context, opts createOptions) error { // Apply plugin-specific post-processing (e.g., remove config/queries if analytics not selected) runErr = prompt.RunWithSpinnerCtx(ctx, "Configuring plugins...", func() error { - return applyPlugins(absOutputDir, selectedPlugins) + return applyPlugins(absOutputDir, selectedPlugins, m.GetTemplatePaths()) }) if runErr != nil { return runErr @@ -958,20 +958,14 @@ func buildPluginStrings(pluginNames []string) (pluginImport, pluginUsage string) return pluginImport, pluginUsage } -// pluginOwnedPaths maps plugin names to directories they own. -// When a plugin is not selected, its owned paths are removed from the project. -var pluginOwnedPaths = map[string][]string{ - "analytics": {"config/queries"}, -} - -// applyPlugins removes directories owned by unselected plugins. -func applyPlugins(projectDir string, pluginNames []string) error { +// applyPlugins removes template directories owned by unselected plugins. +func applyPlugins(projectDir string, pluginNames []string, templatePaths map[string][]string) error { selectedSet := make(map[string]bool) for _, name := range pluginNames { selectedSet[name] = true } - for plugin, paths := range pluginOwnedPaths { + for plugin, paths := range templatePaths { if selectedSet[plugin] { continue } diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index 703e28a326..049fe2f252 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -458,6 +458,59 @@ func TestAppendUniqueNoValues(t *testing.T) { assert.Equal(t, []string{"a", "b"}, result) } +func TestApplyPlugins(t *testing.T) { + tests := []struct { + name string + selected []string + templatePaths map[string][]string + expectRemoved []string + expectKept []string + }{ + { + name: "unselected plugin directory is removed", + selected: []string{"server"}, + templatePaths: map[string][]string{"analytics": {"config/queries"}}, + expectRemoved: []string{"config/queries"}, + }, + { + name: "selected plugin directory is kept", + selected: []string{"analytics", "server"}, + templatePaths: map[string][]string{"analytics": {"config/queries"}}, + expectKept: []string{"config/queries"}, + }, + { + name: "empty templatePaths is a no-op", + selected: []string{"server"}, + templatePaths: map[string][]string{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + + // Create all directories referenced in templatePaths + for _, paths := range tc.templatePaths { + for _, p := range paths { + require.NoError(t, os.MkdirAll(filepath.Join(dir, p), 0o755)) + } + } + + err := applyPlugins(dir, tc.selected, tc.templatePaths) + require.NoError(t, err) + + for _, p := range tc.expectRemoved { + _, statErr := os.Stat(filepath.Join(dir, p)) + assert.True(t, os.IsNotExist(statErr), "expected %s to be removed", p) + } + for _, p := range tc.expectKept { + _, statErr := os.Stat(filepath.Join(dir, p)) + assert.NoError(t, statErr, "expected %s to exist", p) + } + }) + } +} + func TestRunManifestOnlyFound(t *testing.T) { dir := t.TempDir() manifestPath := filepath.Join(dir, manifest.ManifestFileName) diff --git a/libs/apps/manifest/manifest.go b/libs/apps/manifest/manifest.go index b0eccebc9d..3b106b638f 100644 --- a/libs/apps/manifest/manifest.go +++ b/libs/apps/manifest/manifest.go @@ -67,6 +67,7 @@ type Plugin struct { Description string `json:"description"` Package string `json:"package"` RequiredByTemplate bool `json:"requiredByTemplate"` + TemplatePaths []string `json:"templatePaths,omitempty"` Resources Resources `json:"resources"` } @@ -205,6 +206,18 @@ func (m *Manifest) CollectResources(pluginNames []string) []Resource { return resources } +// GetTemplatePaths returns a map of plugin name to template directory paths. +// Only plugins that declare at least one path are included. +func (m *Manifest) GetTemplatePaths() map[string][]string { + result := make(map[string][]string) + for name, p := range m.Plugins { + if len(p.TemplatePaths) > 0 { + result[name] = p.TemplatePaths + } + } + return result +} + // CollectOptionalResources returns all optional resources for the given plugin names. func (m *Manifest) CollectOptionalResources(pluginNames []string) []Resource { seen := make(map[string]bool) diff --git a/libs/apps/manifest/manifest_test.go b/libs/apps/manifest/manifest_test.go index 5a1c4f8212..bb1279f797 100644 --- a/libs/apps/manifest/manifest_test.go +++ b/libs/apps/manifest/manifest_test.go @@ -320,6 +320,34 @@ func TestResourceKey(t *testing.T) { assert.Equal(t, "sql_warehouse", r.VarPrefix()) } +func TestGetTemplatePaths(t *testing.T) { + m := &manifest.Manifest{ + Plugins: map[string]manifest.Plugin{ + "analytics": { + Name: "analytics", + TemplatePaths: []string{"config/queries"}, + }, + "server": { + Name: "server", + }, + }, + } + + paths := m.GetTemplatePaths() + assert.Equal(t, map[string][]string{"analytics": {"config/queries"}}, paths) +} + +func TestGetTemplatePathsEmpty(t *testing.T) { + m := &manifest.Manifest{ + Plugins: map[string]manifest.Plugin{ + "server": {Name: "server"}, + }, + } + + paths := m.GetTemplatePaths() + assert.Empty(t, paths) +} + func TestCollectOptionalResources(t *testing.T) { m := &manifest.Manifest{ Plugins: map[string]manifest.Plugin{ From 4814591b138973706a0bf6504f989456adab5c3b Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Thu, 19 Feb 2026 14:07:15 +0100 Subject: [PATCH 2/4] Run an optional "postinit" command in a template directory --- cmd/apps/init.go | 30 --------------- cmd/apps/init_test.go | 53 -------------------------- libs/apps/initializer/nodejs.go | 40 +++++++++++++++++++ libs/apps/initializer/nodejs_test.go | 57 ++++++++++++++++++++++++++++ libs/apps/manifest/manifest.go | 13 ------- libs/apps/manifest/manifest_test.go | 28 -------------- 6 files changed, 97 insertions(+), 124 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 92e6221f73..89b1966e2c 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -794,14 +794,6 @@ func runCreate(ctx context.Context, opts createOptions) error { absOutputDir = destDir } - // Apply plugin-specific post-processing (e.g., remove config/queries if analytics not selected) - runErr = prompt.RunWithSpinnerCtx(ctx, "Configuring plugins...", func() error { - return applyPlugins(absOutputDir, selectedPlugins, m.GetTemplatePaths()) - }) - if runErr != nil { - return runErr - } - // Initialize project based on type (Node.js, Python, etc.) var nextStepsCmd string projectInitializer := initializer.GetProjectInitializer(absOutputDir) @@ -958,28 +950,6 @@ func buildPluginStrings(pluginNames []string) (pluginImport, pluginUsage string) return pluginImport, pluginUsage } -// applyPlugins removes template directories owned by unselected plugins. -func applyPlugins(projectDir string, pluginNames []string, templatePaths map[string][]string) error { - selectedSet := make(map[string]bool) - for _, name := range pluginNames { - selectedSet[name] = true - } - - for plugin, paths := range templatePaths { - if selectedSet[plugin] { - continue - } - for _, p := range paths { - target := filepath.Join(projectDir, p) - if err := os.RemoveAll(target); err != nil && !os.IsNotExist(err) { - return err - } - } - } - - return nil -} - // renameFiles maps source file names to destination names (for files that can't use special chars). var renameFiles = map[string]string{ "_gitignore": ".gitignore", diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index 049fe2f252..703e28a326 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -458,59 +458,6 @@ func TestAppendUniqueNoValues(t *testing.T) { assert.Equal(t, []string{"a", "b"}, result) } -func TestApplyPlugins(t *testing.T) { - tests := []struct { - name string - selected []string - templatePaths map[string][]string - expectRemoved []string - expectKept []string - }{ - { - name: "unselected plugin directory is removed", - selected: []string{"server"}, - templatePaths: map[string][]string{"analytics": {"config/queries"}}, - expectRemoved: []string{"config/queries"}, - }, - { - name: "selected plugin directory is kept", - selected: []string{"analytics", "server"}, - templatePaths: map[string][]string{"analytics": {"config/queries"}}, - expectKept: []string{"config/queries"}, - }, - { - name: "empty templatePaths is a no-op", - selected: []string{"server"}, - templatePaths: map[string][]string{}, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - dir := t.TempDir() - - // Create all directories referenced in templatePaths - for _, paths := range tc.templatePaths { - for _, p := range paths { - require.NoError(t, os.MkdirAll(filepath.Join(dir, p), 0o755)) - } - } - - err := applyPlugins(dir, tc.selected, tc.templatePaths) - require.NoError(t, err) - - for _, p := range tc.expectRemoved { - _, statErr := os.Stat(filepath.Join(dir, p)) - assert.True(t, os.IsNotExist(statErr), "expected %s to be removed", p) - } - for _, p := range tc.expectKept { - _, statErr := os.Stat(filepath.Join(dir, p)) - assert.NoError(t, statErr, "expected %s to exist", p) - } - }) - } -} - func TestRunManifestOnlyFound(t *testing.T) { dir := t.TempDir() manifestPath := filepath.Join(dir, manifest.ManifestFileName) diff --git a/libs/apps/initializer/nodejs.go b/libs/apps/initializer/nodejs.go index 1e96f43f72..b30db46caa 100644 --- a/libs/apps/initializer/nodejs.go +++ b/libs/apps/initializer/nodejs.go @@ -40,6 +40,9 @@ func (i *InitializerNodeJs) Initialize(ctx context.Context, workDir string) *Ini } } + // Step 3: Run postinit script if defined (fully optional — errors are logged, not fatal) + i.runNpmPostInit(ctx, workDir) + return &InitResult{ Success: true, Message: "Node.js project initialized successfully", @@ -102,6 +105,43 @@ func (i *InitializerNodeJs) runAppkitSetup(ctx context.Context, workDir string) }) } +// runNpmPostInit runs "npm run postinit" if the script is defined in package.json. +// Failures are logged as warnings and never propagate — postinit is fully optional. +func (i *InitializerNodeJs) runNpmPostInit(ctx context.Context, workDir string) { + if !i.hasNpmScript(workDir, "postinit") { + return + } + err := prompt.RunWithSpinnerCtx(ctx, "Running post-init...", func() error { + cmd := exec.CommandContext(ctx, "npm", "run", "postinit") + cmd.Dir = workDir + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() + }) + if err != nil { + log.Debugf(ctx, "postinit script failed (non-fatal): %v", err) + } +} + +// hasNpmScript reports whether the given script name is defined in the project's package.json. +func (i *InitializerNodeJs) hasNpmScript(workDir, script string) bool { + packageJSONPath := filepath.Join(workDir, "package.json") + data, err := os.ReadFile(packageJSONPath) + if err != nil { + return false + } + + var pkg struct { + Scripts map[string]string `json:"scripts"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return false + } + + _, ok := pkg.Scripts[script] + return ok +} + // hasAppkit checks if the project has @databricks/appkit in its dependencies. func (i *InitializerNodeJs) hasAppkit(workDir string) bool { packageJSONPath := filepath.Join(workDir, "package.json") diff --git a/libs/apps/initializer/nodejs_test.go b/libs/apps/initializer/nodejs_test.go index eb9095453f..c390517b76 100644 --- a/libs/apps/initializer/nodejs_test.go +++ b/libs/apps/initializer/nodejs_test.go @@ -65,3 +65,60 @@ func TestHasAppkitNoPackageJSON(t *testing.T) { init := &InitializerNodeJs{} assert.False(t, init.hasAppkit(tmpDir)) } + +func TestHasNpmScript(t *testing.T) { + tests := []struct { + name string + packageJSON string + script string + want bool + }{ + { + name: "script present", + packageJSON: `{"scripts": {"postinit": "appkit postinit"}}`, + script: "postinit", + want: true, + }, + { + name: "script absent", + packageJSON: `{"scripts": {"build": "tsc"}}`, + script: "postinit", + want: false, + }, + { + name: "no scripts section", + packageJSON: `{}`, + script: "postinit", + want: false, + }, + { + name: "invalid json", + packageJSON: `not json`, + script: "postinit", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "nodejs-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + err = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(tt.packageJSON), 0o644) + require.NoError(t, err) + + i := &InitializerNodeJs{} + assert.Equal(t, tt.want, i.hasNpmScript(tmpDir, tt.script)) + }) + } +} + +func TestHasNpmScriptNoPackageJSON(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "nodejs-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + i := &InitializerNodeJs{} + assert.False(t, i.hasNpmScript(tmpDir, "postinit")) +} diff --git a/libs/apps/manifest/manifest.go b/libs/apps/manifest/manifest.go index 3b106b638f..b0eccebc9d 100644 --- a/libs/apps/manifest/manifest.go +++ b/libs/apps/manifest/manifest.go @@ -67,7 +67,6 @@ type Plugin struct { Description string `json:"description"` Package string `json:"package"` RequiredByTemplate bool `json:"requiredByTemplate"` - TemplatePaths []string `json:"templatePaths,omitempty"` Resources Resources `json:"resources"` } @@ -206,18 +205,6 @@ func (m *Manifest) CollectResources(pluginNames []string) []Resource { return resources } -// GetTemplatePaths returns a map of plugin name to template directory paths. -// Only plugins that declare at least one path are included. -func (m *Manifest) GetTemplatePaths() map[string][]string { - result := make(map[string][]string) - for name, p := range m.Plugins { - if len(p.TemplatePaths) > 0 { - result[name] = p.TemplatePaths - } - } - return result -} - // CollectOptionalResources returns all optional resources for the given plugin names. func (m *Manifest) CollectOptionalResources(pluginNames []string) []Resource { seen := make(map[string]bool) diff --git a/libs/apps/manifest/manifest_test.go b/libs/apps/manifest/manifest_test.go index bb1279f797..5a1c4f8212 100644 --- a/libs/apps/manifest/manifest_test.go +++ b/libs/apps/manifest/manifest_test.go @@ -320,34 +320,6 @@ func TestResourceKey(t *testing.T) { assert.Equal(t, "sql_warehouse", r.VarPrefix()) } -func TestGetTemplatePaths(t *testing.T) { - m := &manifest.Manifest{ - Plugins: map[string]manifest.Plugin{ - "analytics": { - Name: "analytics", - TemplatePaths: []string{"config/queries"}, - }, - "server": { - Name: "server", - }, - }, - } - - paths := m.GetTemplatePaths() - assert.Equal(t, map[string][]string{"analytics": {"config/queries"}}, paths) -} - -func TestGetTemplatePathsEmpty(t *testing.T) { - m := &manifest.Manifest{ - Plugins: map[string]manifest.Plugin{ - "server": {Name: "server"}, - }, - } - - paths := m.GetTemplatePaths() - assert.Empty(t, paths) -} - func TestCollectOptionalResources(t *testing.T) { m := &manifest.Manifest{ Plugins: map[string]manifest.Plugin{ From 4ac86378a18e1c02e8f04a7b152ddb50dcca5456 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Fri, 20 Feb 2026 13:20:32 +0100 Subject: [PATCH 3/4] Refactor the templateVars, skip empty files --- cmd/apps/init.go | 115 ++++++++++++++++++++++++---------- cmd/apps/init_test.go | 141 +++++++++++++++++++++++++++++------------- 2 files changed, 182 insertions(+), 74 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 89b1966e2c..0f9e31b7d1 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -5,9 +5,11 @@ import ( "context" "errors" "fmt" + "maps" "os" "os/exec" "path/filepath" + "slices" "strings" "text/template" @@ -256,20 +258,34 @@ func pluginHasResourceField(p *manifest.Plugin, resourceKey, fieldName string) b return false } +// tmplBundle holds the generated bundle configuration strings. +type tmplBundle struct { + Variables string + Resources string + TargetVariables string +} + +// dotEnvVars holds the generated .env file content. +type dotEnvVars struct { + Content string + Example string +} + +// pluginVar represents a selected plugin. Currently empty, but extensible +// with properties as the plugin model evolves. +type pluginVar struct{} + // templateVars holds the variables for template substitution. type templateVars struct { ProjectName string AppDescription string Profile string WorkspaceHost string - PluginImports string - PluginUsages string - // Generated resource configuration from selected plugins. - BundleVariables string - BundleResources string - TargetVariables string - DotEnv string - DotEnvExample string + Bundle tmplBundle + DotEnv dotEnvVars + // Plugins maps plugin name to its metadata + // Missing keys return nil, enabling {{if .plugins.analytics}} conditionals. + Plugins map[string]*pluginVar } // parseDeployAndRunFlags parses the deploy and run flag values into typed values. @@ -749,9 +765,6 @@ func runCreate(ctx context.Context, opts createOptions) error { ResourceValues: resourceValues, } - // Build plugin import/usage strings from selected plugins - pluginImport, pluginUsage := buildPluginStrings(selectedPlugins) - // Generate configurations from selected plugins bundleVars := generator.GenerateBundleVariables(selectedPluginList, genConfig) bundleRes := generator.GenerateBundleResources(selectedPluginList, genConfig) @@ -761,19 +774,27 @@ func runCreate(ctx context.Context, opts createOptions) error { log.Debugf(ctx, "Generated bundle resources:\n%s", bundleRes) log.Debugf(ctx, "Generated target variables:\n%s", targetVars) + plugins := make(map[string]*pluginVar, len(selectedPlugins)) + for _, name := range selectedPlugins { + plugins[name] = &pluginVar{} + } + // Template variables with generated content vars := templateVars{ - ProjectName: opts.name, - AppDescription: opts.description, - Profile: profile, - WorkspaceHost: workspaceHost, - PluginImports: pluginImport, - PluginUsages: pluginUsage, - BundleVariables: bundleVars, - BundleResources: bundleRes, - TargetVariables: targetVars, - DotEnv: generator.GenerateDotEnv(selectedPluginList, genConfig), - DotEnvExample: generator.GenerateDotEnvExample(selectedPluginList), + ProjectName: opts.name, + AppDescription: opts.description, + Profile: profile, + WorkspaceHost: workspaceHost, + Bundle: tmplBundle{ + Variables: bundleVars, + Resources: bundleRes, + TargetVariables: targetVars, + }, + DotEnv: dotEnvVars{ + Content: generator.GenerateDotEnv(selectedPluginList, genConfig), + Example: generator.GenerateDotEnvExample(selectedPluginList), + }, + Plugins: plugins, } // Copy template with variable substitution @@ -1085,6 +1106,15 @@ func copyTemplate(ctx context.Context, src, dest string, vars templateVars) (int } } + // Skip files whose template rendered to only whitespace. + // This enables conditional file creation: plugin-specific files wrap + // their entire content in {{if .plugins.}}...{{end}}, rendering + // to empty when the plugin is not selected. + if len(bytes.TrimSpace(content)) == 0 { + log.Debugf(ctx, "Skipping conditionally empty file: %s", relPath) + return nil + } + // Create parent directory if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { return err @@ -1112,19 +1142,40 @@ func copyTemplate(ctx context.Context, src, dest string, vars templateVars) (int } // templateData builds the data map for Go template execution. -func templateData(vars templateVars) map[string]string { - return map[string]string{ +func templateData(vars templateVars) map[string]any { + // Sort plugin names for deterministic deprecated compat output. + pluginNames := slices.Sorted(maps.Keys(vars.Plugins)) + + // Only computed for deprecated backward compat keys. + pluginImports, pluginUsages := buildPluginStrings(pluginNames) + + return map[string]any{ + "profile": vars.Profile, + "plugins": vars.Plugins, + "projectName": vars.ProjectName, + "appDescription": vars.AppDescription, + "workspaceHost": vars.WorkspaceHost, + "bundle": map[string]any{ + "variables": vars.Bundle.Variables, + "resources": vars.Bundle.Resources, + "targetVariables": vars.Bundle.TargetVariables, + }, + "dotEnv": map[string]any{ + "content": vars.DotEnv.Content, + "example": vars.DotEnv.Example, + }, + + // backward compatibility (deprecated) + "variables": vars.Bundle.Variables, + "resources": vars.Bundle.Resources, + "dotenv": vars.DotEnv.Content, + "target_variables": vars.Bundle.TargetVariables, "project_name": vars.ProjectName, "app_description": vars.AppDescription, - "profile": vars.Profile, + "dotenv_example": vars.DotEnv.Example, "workspace_host": vars.WorkspaceHost, - "plugin_imports": vars.PluginImports, - "plugin_usages": vars.PluginUsages, - "variables": vars.BundleVariables, - "resources": vars.BundleResources, - "target_variables": vars.TargetVariables, - "dotenv": vars.DotEnv, - "dotenv_example": vars.DotEnvExample, + "plugin_imports": pluginImports, + "plugin_usages": pluginUsages, } } diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index 703e28a326..207156848f 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -70,16 +70,28 @@ func TestIsTextFile(t *testing.T) { } } -func TestExecuteTemplate(t *testing.T) { - ctx := context.Background() - vars := templateVars{ +func testVars() templateVars { + return templateVars{ ProjectName: "my-app", AppDescription: "My awesome app", Profile: "default", WorkspaceHost: "https://dbc-123.cloud.databricks.com", - PluginImports: "analytics", - PluginUsages: "analytics()", + Bundle: tmplBundle{ + Variables: "sql_warehouse_id:", + Resources: "- name: sql-warehouse", + TargetVariables: "sql_warehouse_id: abc123", + }, + DotEnv: dotEnvVars{ + Content: "WH_ID=abc123", + Example: "WH_ID=your_sql_warehouse_id", + }, + Plugins: map[string]*pluginVar{"analytics": {}}, } +} + +func TestExecuteTemplateBackwardCompat(t *testing.T) { + ctx := context.Background() + vars := testVars() tests := []struct { name string @@ -87,44 +99,54 @@ func TestExecuteTemplate(t *testing.T) { expected string }{ { - name: "project name substitution", - input: "name: {{.project_name}}", - expected: "name: my-app", + name: "project_name", + input: "{{.project_name}}", + expected: "my-app", + }, + { + name: "app_description", + input: "{{.app_description}}", + expected: "My awesome app", + }, + { + name: "workspace_host", + input: "{{.workspace_host}}", + expected: "https://dbc-123.cloud.databricks.com", }, { - name: "description substitution", - input: "description: {{.app_description}}", - expected: "description: My awesome app", + name: "dotenv", + input: "{{.dotenv}}", + expected: "WH_ID=abc123", }, { - name: "profile substitution", - input: "profile: {{.profile}}", - expected: "profile: default", + name: "dotenv_example", + input: "{{.dotenv_example}}", + expected: "WH_ID=your_sql_warehouse_id", }, { - name: "workspace host substitution", - input: "host: {{.workspace_host}}", - expected: "host: https://dbc-123.cloud.databricks.com", + name: "variables", + input: "{{.variables}}", + expected: "sql_warehouse_id:", }, { - name: "plugin import substitution", - input: "import { {{.plugin_imports}} } from 'appkit'", - expected: "import { analytics } from 'appkit'", + name: "resources", + input: "{{.resources}}", + expected: "- name: sql-warehouse", }, { - name: "plugin usage substitution", - input: "plugins: [{{.plugin_usages}}]", - expected: "plugins: [analytics()]", + name: "target_variables", + input: "{{.target_variables}}", + expected: "sql_warehouse_id: abc123", }, { - name: "multiple substitutions", - input: "{{.project_name}} - {{.app_description}}", - expected: "my-app - My awesome app", + name: "plugin_imports", + input: "{{.plugin_imports}}", + expected: "analytics", }, { - name: "no substitutions needed", - input: "plain text without variables", - expected: "plain text without variables", + name: "plugin_usages", + input: "{{.plugin_usages}}", + expected: "analytics()", }, } @@ -137,14 +159,9 @@ func TestExecuteTemplate(t *testing.T) { } } -func TestExecuteTemplateEmptyPlugins(t *testing.T) { +func TestExecuteTemplateNewKeys(t *testing.T) { ctx := context.Background() - vars := templateVars{ - ProjectName: "my-app", - AppDescription: "My app", - PluginImports: "", - PluginUsages: "", - } + vars := testVars() tests := []struct { name string @@ -152,14 +169,54 @@ func TestExecuteTemplateEmptyPlugins(t *testing.T) { expected string }{ { - name: "empty plugin imports render as empty", - input: "import { core{{if .plugin_imports}}, {{.plugin_imports}}{{end}} } from 'appkit'", - expected: "import { core } from 'appkit'", + name: "projectName", + input: "{{.projectName}}", + expected: "my-app", + }, + { + name: "appDescription", + input: "{{.appDescription}}", + expected: "My awesome app", + }, + { + name: "workspaceHost", + input: "{{.workspaceHost}}", + expected: "https://dbc-123.cloud.databricks.com", + }, + { + name: "bundle.variables", + input: "{{.bundle.variables}}", + expected: "sql_warehouse_id:", + }, + { + name: "bundle.resources", + input: "{{.bundle.resources}}", + expected: "- name: sql-warehouse", + }, + { + name: "bundle.targetVariables", + input: "{{.bundle.targetVariables}}", + expected: "sql_warehouse_id: abc123", + }, + { + name: "dotEnv.content", + input: "{{.dotEnv.content}}", + expected: "WH_ID=abc123", + }, + { + name: "dotEnv.example", + input: "{{.dotEnv.example}}", + expected: "WH_ID=your_sql_warehouse_id", + }, + { + name: "plugins selected", + input: `{{if .plugins.analytics}}yes{{end}}`, + expected: "yes", }, { - name: "empty plugin usages render as empty", - input: "plugins: [{{if .plugin_usages}}\n {{.plugin_usages}},\n{{end}}]", - expected: "plugins: []", + name: "plugins not selected", + input: `{{if .plugins.nonexistent}}yes{{end}}`, + expected: "", }, } From f5687922c000ceb99b88d78bd46e5b669d8df7a0 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Fri, 20 Feb 2026 18:06:57 +0100 Subject: [PATCH 4/4] Remove empty dirs --- cmd/apps/init.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 0f9e31b7d1..63bb983e48 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io/fs" "maps" "os" "os/exec" @@ -1138,9 +1139,36 @@ func copyTemplate(ctx context.Context, src, dest string, vars templateVars) (int } log.Debugf(ctx, "Copied %d files", fileCount) + if err == nil { + err = removeEmptyDirs(dest) + } + return fileCount, err } +// removeEmptyDirs removes empty directories under root, deepest-first. +// It is used to clean up directories that were created eagerly but ended up +// with no files after conditional template rendering skipped their contents. +func removeEmptyDirs(root string) error { + var dirs []string + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() && path != root { + dirs = append(dirs, path) + } + return nil + }) + if err != nil { + return err + } + for i := len(dirs) - 1; i >= 0; i-- { + _ = os.Remove(dirs[i]) + } + return nil +} + // templateData builds the data map for Go template execution. func templateData(vars templateVars) map[string]any { // Sort plugin names for deterministic deprecated compat output.