From f20a927dcbf57ea8d8eb07270e465f63c8cb7790 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Thu, 19 Feb 2026 10:35:44 +0100 Subject: [PATCH 1/8] 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 b8afd64b05..f45989692e 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -806,7 +806,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 @@ -968,20 +968,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 e7aff8c7bf..922e94ad9d 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -460,6 +460,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 9157f7980568ba2198d4d49e53175d7183e6a237 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Thu, 19 Feb 2026 14:07:15 +0100 Subject: [PATCH 2/8] 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 f45989692e..604bf76e63 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -804,14 +804,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) @@ -968,28 +960,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 922e94ad9d..e7aff8c7bf 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -460,59 +460,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 df3a1a6b75dd18203575d7705bbf29d6a1e74415 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Fri, 20 Feb 2026 13:20:32 +0100 Subject: [PATCH 3/8] Refactor the templateVars, skip empty files --- cmd/apps/init.go | 118 ++++++++++++++++++++++++----------- cmd/apps/init_test.go | 141 +++++++++++++++++++++++++++++------------- 2 files changed, 182 insertions(+), 77 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 604bf76e63..7c0066a998 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" @@ -264,21 +266,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 - AppEnv 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. @@ -758,9 +773,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) @@ -770,20 +782,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, - AppEnv: generator.GenerateAppEnv(selectedPluginList, genConfig), - 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 @@ -1095,6 +1114,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 @@ -1122,20 +1150,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, - "app_env": vars.AppEnv, - "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 e7aff8c7bf..8a311d3ccf 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 3f2b9403a4936945ebff410e98a3b4ee5884e1b7 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Fri, 20 Feb 2026 18:06:57 +0100 Subject: [PATCH 4/8] 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 7c0066a998..3995775062 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" @@ -1146,9 +1147,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. From f066fcce1e35852151f471a450aa83fd5cfba186 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Mon, 23 Feb 2026 17:46:36 +0100 Subject: [PATCH 5/8] show setup instructions --- cmd/apps/init.go | 13 +++++++++++++ libs/apps/manifest/manifest.go | 1 + libs/apps/prompt/prompt.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 3995775062..abb8ee8a85 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -853,6 +853,19 @@ func runCreate(ctx context.Context, opts createOptions) error { prompt.PrintSuccess(ctx, opts.name, absOutputDir, fileCount, "") } + // Print any onSetupMessage declared by selected plugins in the template manifest. + var notes []prompt.SetupNote + for _, name := range selectedPlugins { + p, ok := m.Plugins[name] + if !ok || p.OnSetupMessage == "" { + continue + } + notes = append(notes, prompt.SetupNote{Name: p.DisplayName, Message: p.OnSetupMessage}) + } + if len(notes) > 0 { + prompt.PrintSetupNotes(ctx, notes) + } + // Recommend skills installation if coding agents are detected without skills. // In flags mode, only print a hint — never prompt interactively. if flagsMode { diff --git a/libs/apps/manifest/manifest.go b/libs/apps/manifest/manifest.go index b0eccebc9d..299cfa43f4 100644 --- a/libs/apps/manifest/manifest.go +++ b/libs/apps/manifest/manifest.go @@ -68,6 +68,7 @@ type Plugin struct { Package string `json:"package"` RequiredByTemplate bool `json:"requiredByTemplate"` Resources Resources `json:"resources"` + OnSetupMessage string `json:"onSetupMessage"` } // Manifest represents the appkit.plugins.json file structure. diff --git a/libs/apps/prompt/prompt.go b/libs/apps/prompt/prompt.go index 95f5d2c04a..97221d1612 100644 --- a/libs/apps/prompt/prompt.go +++ b/libs/apps/prompt/prompt.go @@ -8,6 +8,7 @@ import ( "path/filepath" "regexp" "strconv" + "strings" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" @@ -684,3 +685,32 @@ func PrintSuccess(ctx context.Context, projectName, outputDir string, fileCount } cmdio.LogString(ctx, "") } + +// SetupNote holds the display name and message for a single plugin setup note. +type SetupNote struct { + Name string + Message string +} + +// PrintSetupNotes renders a styled "Setup Notes" section for selected plugins. +func PrintSetupNotes(ctx context.Context, notes []SetupNote) { + headerStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFAB00")). // Databricks yellow + Bold(true) + + nameStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#71717A")). // Mid-tone gray + Bold(true) + + msgStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#71717A")) // Mid-tone gray + + cmdio.LogString(ctx, headerStyle.Render(" Setup Notes")) + cmdio.LogString(ctx, "") + for _, n := range notes { + cmdio.LogString(ctx, nameStyle.Render(" "+n.Name)) + indented := strings.ReplaceAll(n.Message, "\n", "\n ") + cmdio.LogString(ctx, msgStyle.Render(" "+indented)) + cmdio.LogString(ctx, "") + } +} From e3ab8510d392ac54d7c2a6a00ca4781b3d33acf0 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Mon, 23 Feb 2026 17:55:51 +0100 Subject: [PATCH 6/8] Remove unnecessary `postinit` function execution --- libs/apps/initializer/nodejs.go | 40 ------------------- libs/apps/initializer/nodejs_test.go | 57 ---------------------------- 2 files changed, 97 deletions(-) diff --git a/libs/apps/initializer/nodejs.go b/libs/apps/initializer/nodejs.go index b30db46caa..1e96f43f72 100644 --- a/libs/apps/initializer/nodejs.go +++ b/libs/apps/initializer/nodejs.go @@ -40,9 +40,6 @@ 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", @@ -105,43 +102,6 @@ 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 c390517b76..eb9095453f 100644 --- a/libs/apps/initializer/nodejs_test.go +++ b/libs/apps/initializer/nodejs_test.go @@ -65,60 +65,3 @@ 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")) -} From f8e51aba1138e6cdf6fa3adfab93e56ea698cc67 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Tue, 24 Feb 2026 14:03:14 +0100 Subject: [PATCH 7/8] Support `postgres` resource for upcoming plugins --- libs/apps/generator/generator.go | 5 +++ libs/apps/prompt/listers.go | 55 ++++++++++++++++++++++++ libs/apps/prompt/prompt.go | 61 +++++++++++++++++++++++++++ libs/apps/prompt/resource_registry.go | 5 ++- 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/libs/apps/generator/generator.go b/libs/apps/generator/generator.go index 07c9dddb97..62df97befe 100644 --- a/libs/apps/generator/generator.go +++ b/libs/apps/generator/generator.go @@ -299,6 +299,11 @@ var appResourceSpecs = map[string]appResourceSpec{ varFields: [][2]string{{"instance_name", "instance_name"}, {"database_name", "database_name"}}, permission: "CAN_CONNECT_AND_CREATE", }, + "postgres": { + yamlKey: "postgres", + varFields: [][2]string{{"branch", "branch"}, {"database", "database"}}, + permission: "CAN_CONNECT_AND_CREATE", + }, "genie_space": { yamlKey: "genie_space", varFields: [][2]string{{"name", "name"}, {"id", "space_id"}}, diff --git a/libs/apps/prompt/listers.go b/libs/apps/prompt/listers.go index 6903dd66f7..2a4c992ad9 100644 --- a/libs/apps/prompt/listers.go +++ b/libs/apps/prompt/listers.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/log" @@ -18,6 +19,7 @@ import ( "github.com/databricks/databricks-sdk-go/service/database" "github.com/databricks/databricks-sdk-go/service/jobs" "github.com/databricks/databricks-sdk-go/service/ml" + "github.com/databricks/databricks-sdk-go/service/postgres" "github.com/databricks/databricks-sdk-go/service/sql" "github.com/databricks/databricks-sdk-go/service/vectorsearch" "github.com/databricks/databricks-sdk-go/service/workspace" @@ -342,6 +344,59 @@ func ListDatabases(ctx context.Context, instanceName string) ([]ListItem, error) return out, nil } +// extractIDFromName extracts the ID segment after a named component in a resource path. +// For example, extractIDFromName("projects/foo/branches/bar", "branches") returns "bar". +func extractIDFromName(name, component string) string { + parts := strings.Split(name, "/") + for i := range len(parts) - 1 { + if parts[i] == component { + return parts[i+1] + } + } + return name +} + +// ListPostgresProjects returns Lakebase Autoscaling (V2) projects as selectable items. +func ListPostgresProjects(ctx context.Context) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.Postgres.ListProjects(ctx, postgres.ListProjectsRequest{}) + projects, err := listing.ToSlice(ctx, iter) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, len(projects)) + for _, p := range projects { + label := p.Name + if p.Status != nil && p.Status.DisplayName != "" { + label = p.Status.DisplayName + } + out = append(out, ListItem{ID: p.Name, Label: label}) + } + return out, nil +} + +// ListPostgresBranches returns branches within a Lakebase Autoscaling project as selectable items. +func ListPostgresBranches(ctx context.Context, projectName string) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.Postgres.ListBranches(ctx, postgres.ListBranchesRequest{Parent: projectName}) + branches, err := listing.ToSlice(ctx, iter) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, len(branches)) + for _, b := range branches { + label := extractIDFromName(b.Name, "branches") + out = append(out, ListItem{ID: b.Name, Label: label}) + } + return out, nil +} + // ListGenieSpaces returns Genie spaces as selectable items. func ListGenieSpaces(ctx context.Context) ([]ListItem, error) { w, err := workspaceClient(ctx) diff --git a/libs/apps/prompt/prompt.go b/libs/apps/prompt/prompt.go index 97221d1612..3ebbcadc69 100644 --- a/libs/apps/prompt/prompt.go +++ b/libs/apps/prompt/prompt.go @@ -524,6 +524,67 @@ func PromptForDatabase(ctx context.Context, r manifest.Resource, required bool) }, nil } +// PromptForPostgres shows a three-step picker for Lakebase Autoscaling (V2): project, branch, then database. +func PromptForPostgres(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { + // Step 1: pick a project + var projects []ListItem + err := RunWithSpinnerCtx(ctx, "Fetching Postgres projects...", func() error { + var fetchErr error + projects, fetchErr = ListPostgresProjects(ctx) + return fetchErr + }) + if err != nil { + return nil, err + } + projectName, err := PromptFromList(ctx, "Select Postgres Project", "no Postgres projects found", projects, required) + if err != nil { + return nil, err + } + if projectName == "" { + return nil, nil + } + + // Step 2: pick a branch within the project + var branches []ListItem + err = RunWithSpinnerCtx(ctx, "Fetching branches...", func() error { + var fetchErr error + branches, fetchErr = ListPostgresBranches(ctx, projectName) + return fetchErr + }) + if err != nil { + return nil, err + } + branchName, err := PromptFromList(ctx, "Select Branch", "no branches found in project "+projectName, branches, required) + if err != nil { + return nil, err + } + if branchName == "" { + return nil, nil + } + + // Step 3: enter a database name (pre-filled with default) + dbName := "databricks_postgres" + theme := AppkitTheme() + err = huh.NewInput(). + Title("Database name"). + Description("Enter the database name to connect to"). + Value(&dbName). + WithTheme(theme). + Run() + if err != nil { + return nil, err + } + if dbName == "" { + return nil, nil + } + printAnswered(ctx, "Database", dbName) + + return map[string]string{ + r.Key() + ".branch": branchName, + r.Key() + ".database": dbName, + }, nil +} + // PromptForGenieSpace shows a picker for Genie spaces. // Captures both the space ID and name since the DABs schema requires both fields. func PromptForGenieSpace(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { diff --git a/libs/apps/prompt/resource_registry.go b/libs/apps/prompt/resource_registry.go index f3d6ec640c..92b6331c62 100644 --- a/libs/apps/prompt/resource_registry.go +++ b/libs/apps/prompt/resource_registry.go @@ -16,7 +16,8 @@ const ( ResourceTypeVectorSearchIndex = "vector_search_index" ResourceTypeUCFunction = "uc_function" ResourceTypeUCConnection = "uc_connection" - ResourceTypeDatabase = "database" + ResourceTypeDatabase = "database" // Lakebase Provisioned (V1) + ResourceTypePostgres = "postgres" // Lakebase Autoscaling (V2) ResourceTypeGenieSpace = "genie_space" ResourceTypeExperiment = "experiment" // TODO: uncomment when bundles support app as an app resource type. @@ -50,6 +51,8 @@ func GetPromptFunc(resourceType string) (PromptResourceFunc, bool) { return PromptForUCConnection, true case ResourceTypeDatabase: return PromptForDatabase, true + case ResourceTypePostgres: + return PromptForPostgres, true case ResourceTypeGenieSpace: return PromptForGenieSpace, true case ResourceTypeExperiment: From 9d74e9dd4298d2c1f3e12b2acf6859bde451f098 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Tue, 24 Feb 2026 15:28:33 +0100 Subject: [PATCH 8/8] Bring back app.yaml env generation --- cmd/apps/init.go | 4 ++++ cmd/apps/init_test.go | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index abb8ee8a85..ad928213bf 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -292,6 +292,7 @@ type templateVars struct { WorkspaceHost string Bundle tmplBundle DotEnv dotEnvVars + AppEnv string // Plugins maps plugin name to its metadata // Missing keys return nil, enabling {{if .plugins.analytics}} conditionals. Plugins map[string]*pluginVar @@ -803,6 +804,7 @@ func runCreate(ctx context.Context, opts createOptions) error { Content: generator.GenerateDotEnv(selectedPluginList, genConfig), Example: generator.GenerateDotEnvExample(selectedPluginList), }, + AppEnv: generator.GenerateAppEnv(selectedPluginList, genConfig), Plugins: plugins, } @@ -1213,6 +1215,7 @@ func templateData(vars templateVars) map[string]any { "content": vars.DotEnv.Content, "example": vars.DotEnv.Example, }, + "appEnv": vars.AppEnv, // backward compatibility (deprecated) "variables": vars.Bundle.Variables, @@ -1225,6 +1228,7 @@ func templateData(vars templateVars) map[string]any { "workspace_host": vars.WorkspaceHost, "plugin_imports": pluginImports, "plugin_usages": pluginUsages, + "app_env": vars.AppEnv, } } diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index 8a311d3ccf..700cbe602d 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -85,6 +85,7 @@ func testVars() templateVars { Content: "WH_ID=abc123", Example: "WH_ID=your_sql_warehouse_id", }, + AppEnv: "- name: SQL_WAREHOUSE_ID\n valueFrom: sql_warehouse", Plugins: map[string]*pluginVar{"analytics": {}}, } } @@ -148,6 +149,11 @@ func TestExecuteTemplateBackwardCompat(t *testing.T) { input: "{{.plugin_usages}}", expected: "analytics()", }, + { + name: "app_env", + input: "{{.app_env}}", + expected: "- name: SQL_WAREHOUSE_ID\n valueFrom: sql_warehouse", + }, } for _, tt := range tests { @@ -218,6 +224,11 @@ func TestExecuteTemplateNewKeys(t *testing.T) { input: `{{if .plugins.nonexistent}}yes{{end}}`, expected: "", }, + { + name: "appEnv", + input: "{{.appEnv}}", + expected: "- name: SQL_WAREHOUSE_ID\n valueFrom: sql_warehouse", + }, } for _, tt := range tests {