diff --git a/cmd/apps/init.go b/cmd/apps/init.go index b8afd64b05..ad928213bf 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -5,9 +5,12 @@ import ( "context" "errors" "fmt" + "io/fs" + "maps" "os" "os/exec" "path/filepath" + "slices" "strings" "text/template" @@ -264,21 +267,35 @@ 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 + AppEnv string + // 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 +775,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 +784,28 @@ 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), + }, + AppEnv: generator.GenerateAppEnv(selectedPluginList, genConfig), + Plugins: plugins, } // Copy template with variable substitution @@ -804,14 +826,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) - }) - if runErr != nil { - return runErr - } - // Initialize project based on type (Node.js, Python, etc.) var nextStepsCmd string projectInitializer := initializer.GetProjectInitializer(absOutputDir) @@ -841,6 +855,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 { @@ -968,34 +995,6 @@ 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 { - selectedSet := make(map[string]bool) - for _, name := range pluginNames { - selectedSet[name] = true - } - - for plugin, paths := range pluginOwnedPaths { - 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", @@ -1131,6 +1130,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 @@ -1154,24 +1162,73 @@ 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]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, + }, + "appEnv": vars.AppEnv, + + // 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, + "plugin_imports": pluginImports, + "plugin_usages": pluginUsages, "app_env": vars.AppEnv, - "dotenv": vars.DotEnv, - "dotenv_example": vars.DotEnvExample, } } diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index e7aff8c7bf..700cbe602d 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -70,16 +70,29 @@ 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", + }, + AppEnv: "- name: SQL_WAREHOUSE_ID\n valueFrom: sql_warehouse", + Plugins: map[string]*pluginVar{"analytics": {}}, } +} + +func TestExecuteTemplateBackwardCompat(t *testing.T) { + ctx := context.Background() + vars := testVars() tests := []struct { name string @@ -87,44 +100,59 @@ 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: "description substitution", - input: "description: {{.app_description}}", - expected: "description: My awesome app", + name: "workspace_host", + input: "{{.workspace_host}}", + expected: "https://dbc-123.cloud.databricks.com", }, { - name: "profile substitution", - input: "profile: {{.profile}}", - expected: "profile: default", + name: "dotenv", + input: "{{.dotenv}}", + expected: "WH_ID=abc123", }, { - name: "workspace host substitution", - input: "host: {{.workspace_host}}", - expected: "host: https://dbc-123.cloud.databricks.com", + name: "dotenv_example", + input: "{{.dotenv_example}}", + expected: "WH_ID=your_sql_warehouse_id", }, { - name: "plugin import substitution", - input: "import { {{.plugin_imports}} } from 'appkit'", - expected: "import { analytics } from 'appkit'", + name: "variables", + input: "{{.variables}}", + expected: "sql_warehouse_id:", }, { - name: "plugin usage substitution", - input: "plugins: [{{.plugin_usages}}]", - expected: "plugins: [analytics()]", + name: "resources", + input: "{{.resources}}", + expected: "- name: sql-warehouse", }, { - name: "multiple substitutions", - input: "{{.project_name}} - {{.app_description}}", - expected: "my-app - My awesome app", + name: "target_variables", + input: "{{.target_variables}}", + expected: "sql_warehouse_id: abc123", }, { - name: "no substitutions needed", - input: "plain text without variables", - expected: "plain text without variables", + name: "plugin_imports", + input: "{{.plugin_imports}}", + expected: "analytics", + }, + { + name: "plugin_usages", + input: "{{.plugin_usages}}", + expected: "analytics()", + }, + { + name: "app_env", + input: "{{.app_env}}", + expected: "- name: SQL_WAREHOUSE_ID\n valueFrom: sql_warehouse", }, } @@ -137,14 +165,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 +175,59 @@ 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: "plugins not selected", + input: `{{if .plugins.nonexistent}}yes{{end}}`, + expected: "", }, { - name: "empty plugin usages render as empty", - input: "plugins: [{{if .plugin_usages}}\n {{.plugin_usages}},\n{{end}}]", - expected: "plugins: []", + name: "appEnv", + input: "{{.appEnv}}", + expected: "- name: SQL_WAREHOUSE_ID\n valueFrom: sql_warehouse", }, } 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/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/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 95f5d2c04a..3ebbcadc69 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" @@ -523,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) { @@ -684,3 +746,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, "") + } +} 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: