diff --git a/cmd/apps/init.go b/cmd/apps/init.go index e815241ce9..71c1e03a8f 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -13,8 +13,9 @@ import ( "github.com/charmbracelet/huh" "github.com/databricks/cli/cmd/root" - "github.com/databricks/cli/libs/apps/features" + "github.com/databricks/cli/libs/apps/generator" "github.com/databricks/cli/libs/apps/initializer" + "github.com/databricks/cli/libs/apps/manifest" "github.com/databricks/cli/libs/apps/prompt" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" @@ -52,12 +53,12 @@ func newInitCmd() *cobra.Command { branch string version string name string - warehouseID string description string outputDir string - featuresFlag []string + pluginsFlag []string deploy bool run string + setValues []string ) cmd := &cobra.Command{ @@ -86,8 +87,19 @@ Examples: # Non-interactive with flags databricks apps init --name my-app - # With analytics feature (requires --warehouse-id) - databricks apps init --name my-app --features=analytics --warehouse-id=abc123 + # With analytics feature and SQL Warehouse + databricks apps init --name my-app --features=analytics \ + --set analytics.sql-warehouse.id=abc123 + + # With database resource (all fields required together) + databricks apps init --name my-app --features=analytics \ + --set analytics.database.instance_name=myinst \ + --set analytics.database.database_name=mydb + + # Multiple plugins with different warehouses + databricks apps init --name my-app --features=analytics,reporting \ + --set analytics.sql-warehouse.id=wh1 \ + --set reporting.sql-warehouse.id=wh2 # Create, deploy, and run with dev-remote databricks apps init --name my-app --deploy --run=dev-remote @@ -98,9 +110,10 @@ Examples: # With a GitHub URL databricks apps init --template https://github.com/user/repo --name my-app -Feature dependencies: - Some features require additional flags: - - analytics: requires --warehouse-id (SQL Warehouse ID) +Resource configuration (--set): + Set resource values using --set plugin.resourceKey.field=value + Keys are defined in the template's appkit.plugins.json manifest. + Multi-field resources (e.g., database, secret) require all fields to be set together. Environment variables: DATABRICKS_APPKIT_TEMPLATE_PATH Override the default template source`, @@ -115,20 +128,20 @@ Environment variables: } return runCreate(ctx, createOptions{ - templatePath: templatePath, - branch: branch, - version: version, - name: name, - nameProvided: cmd.Flags().Changed("name"), - warehouseID: warehouseID, - description: description, - outputDir: outputDir, - features: featuresFlag, - deploy: deploy, - deployChanged: cmd.Flags().Changed("deploy"), - run: run, - runChanged: cmd.Flags().Changed("run"), - featuresChanged: cmd.Flags().Changed("features"), + templatePath: templatePath, + branch: branch, + version: version, + name: name, + nameProvided: cmd.Flags().Changed("name"), + description: description, + outputDir: outputDir, + plugins: pluginsFlag, + deploy: deploy, + deployChanged: cmd.Flags().Changed("deploy"), + run: run, + runChanged: cmd.Flags().Changed("run"), + pluginsChanged: cmd.Flags().Changed("features") || cmd.Flags().Changed("plugins"), + setValues: setValues, }) }, } @@ -137,10 +150,12 @@ Environment variables: cmd.Flags().StringVar(&branch, "branch", "", "Git branch or tag (for GitHub templates, mutually exclusive with --version)") cmd.Flags().StringVar(&version, "version", "", "AppKit version to use (default: latest release, use 'latest' for main branch)") cmd.Flags().StringVar(&name, "name", "", "Project name (prompts if not provided)") - cmd.Flags().StringVar(&warehouseID, "warehouse-id", "", "SQL warehouse ID") + cmd.Flags().StringArrayVar(&setValues, "set", nil, "Set resource values (format: plugin.resourceKey.field=value, can specify multiple)") cmd.Flags().StringVar(&description, "description", "", "App description") cmd.Flags().StringVar(&outputDir, "output-dir", "", "Directory to write the project to") - cmd.Flags().StringSliceVar(&featuresFlag, "features", nil, "Features to enable (comma-separated). Available: "+strings.Join(features.GetFeatureIDs(), ", ")) + cmd.Flags().StringSliceVar(&pluginsFlag, "features", nil, "Features/plugins to enable (comma-separated, as defined in template manifest)") + cmd.Flags().StringSliceVar(&pluginsFlag, "plugins", nil, "Alias for --features") + _ = cmd.Flags().MarkHidden("plugins") cmd.Flags().BoolVar(&deploy, "deploy", false, "Deploy the app after creation") cmd.Flags().StringVar(&run, "run", "", "Run the app after creation (none, dev, dev-remote)") @@ -148,46 +163,102 @@ Environment variables: } type createOptions struct { - templatePath string - branch string - version string - name string - nameProvided bool // true if --name flag was explicitly set (enables "flags mode") - warehouseID string - description string - outputDir string - features []string - deploy bool - deployChanged bool // true if --deploy flag was explicitly set - run string - runChanged bool // true if --run flag was explicitly set - featuresChanged bool // true if --features flag was explicitly set + templatePath string + branch string + version string + name string + nameProvided bool // true if --name flag was explicitly set (enables "flags mode") + description string + outputDir string + plugins []string + deploy bool + deployChanged bool // true if --deploy flag was explicitly set + run string + runChanged bool // true if --run flag was explicitly set + pluginsChanged bool // true if --plugins flag was explicitly set + setValues []string // --set plugin.resourceKey.field=value pairs +} + +// parseSetValues parses --set key=value pairs into the resourceValues map. +// Keys use the format "plugin.resourceKey.field=value". +// Validates that plugin names, resource keys, and field names exist in the manifest. +func parseSetValues(setValues []string, m *manifest.Manifest) (map[string]string, error) { + rv := make(map[string]string) + for _, sv := range setValues { + key, value, ok := strings.Cut(sv, "=") + if !ok || key == "" { + return nil, fmt.Errorf("invalid --set format %q, expected plugin.resourceKey.field=value", sv) + } + parts := strings.SplitN(key, ".", 3) + if len(parts) != 3 { + return nil, fmt.Errorf("invalid --set key %q, expected plugin.resourceKey.field", key) + } + pluginName, resourceKey, fieldName := parts[0], parts[1], parts[2] + + plugin := m.GetPluginByName(pluginName) + if plugin == nil { + return nil, fmt.Errorf("unknown plugin %q in --set %q; available: %v", pluginName, sv, m.GetPluginNames()) + } + + if !pluginHasResourceField(plugin, resourceKey, fieldName) { + return nil, fmt.Errorf("plugin %q has no resource with key %q and field %q", pluginName, resourceKey, fieldName) + } + + rv[resourceKey+"."+fieldName] = value + } + + // Validate multi-field resources: if any field is set, all fields must be set. + for _, p := range m.GetPlugins() { + for _, r := range append(p.Resources.Required, p.Resources.Optional...) { + if len(r.Fields) <= 1 { + continue + } + names := r.FieldNames() + setCount := 0 + for _, fn := range names { + if rv[r.Key()+"."+fn] != "" { + setCount++ + } + } + if setCount > 0 && setCount < len(names) { + var missing []string + for _, fn := range names { + if rv[r.Key()+"."+fn] == "" { + missing = append(missing, r.Key()+"."+fn) + } + } + return nil, fmt.Errorf("incomplete resource %q: missing fields %v (all fields must be set together)", r.Key(), missing) + } + } + } + + return rv, nil +} + +// pluginHasResourceField checks whether a plugin declares a resource with the given key and field name. +func pluginHasResourceField(p *manifest.Plugin, resourceKey, fieldName string) bool { + for _, r := range append(p.Resources.Required, p.Resources.Optional...) { + if r.Key() == resourceKey { + if _, ok := r.Fields[fieldName]; ok { + return true + } + } + } + return false } // templateVars holds the variables for template substitution. type templateVars struct { ProjectName string - SQLWarehouseID string AppDescription string Profile string WorkspaceHost string - PluginImport string - PluginUsage string - // Feature resource fragments (aggregated from selected features) - BundleVariables string - BundleResources string - TargetVariables string - AppEnv string - DotEnv string - DotEnvExample string -} - -// featureFragments holds aggregated content from feature resource files. -type featureFragments struct { + PluginImports string + PluginUsages string + // Generated resource configuration from selected plugins. BundleVariables string BundleResources string TargetVariables string - AppEnv string DotEnv string DotEnvExample string } @@ -214,84 +285,72 @@ func parseDeployAndRunFlags(deploy bool, run string) (bool, prompt.RunMode, erro return deploy, runMode, nil } -// promptForFeaturesAndDeps prompts for features and their dependencies. -// Used when the template uses the feature-fragment system. +// promptForPluginsAndDeps prompts for plugins and their resource dependencies using the manifest. // skipDeployRunPrompt indicates whether to skip prompting for deploy/run (because flags were provided). -func promptForFeaturesAndDeps(ctx context.Context, preSelectedFeatures []string, skipDeployRunPrompt bool) (*prompt.CreateProjectConfig, error) { +func promptForPluginsAndDeps(ctx context.Context, m *manifest.Manifest, preSelectedPlugins []string, skipDeployRunPrompt bool) (*prompt.CreateProjectConfig, error) { config := &prompt.CreateProjectConfig{ Dependencies: make(map[string]string), - Features: preSelectedFeatures, + Features: preSelectedPlugins, // Reuse Features field for plugin names } theme := prompt.AppkitTheme() - // Step 1: Feature selection (skip if features already provided via flag) - if len(config.Features) == 0 && len(features.AvailableFeatures) > 0 { - options := make([]huh.Option[string], 0, len(features.AvailableFeatures)) - for _, f := range features.AvailableFeatures { - label := f.Name + " - " + f.Description - options = append(options, huh.NewOption(label, f.ID)) + // Step 1: Plugin selection (skip if plugins already provided via flag) + selectablePlugins := m.GetSelectablePlugins() + if len(config.Features) == 0 && len(selectablePlugins) > 0 { + options := make([]huh.Option[string], 0, len(selectablePlugins)) + for _, p := range selectablePlugins { + label := p.DisplayName + " - " + p.Description + options = append(options, huh.NewOption(label, p.Name)) } + var selected []string err := huh.NewMultiSelect[string](). Title("Select features"). Description("space to toggle, enter to confirm"). Options(options...). - Value(&config.Features). + Value(&selected). Height(8). WithTheme(theme). Run() if err != nil { return nil, err } - if len(config.Features) == 0 { - prompt.PrintAnswered(ctx, "Features", "None") + if len(selected) == 0 { + prompt.PrintAnswered(ctx, "Plugins", "None") } else { - prompt.PrintAnswered(ctx, "Features", fmt.Sprintf("%d selected", len(config.Features))) + prompt.PrintAnswered(ctx, "Plugins", fmt.Sprintf("%d selected", len(selected))) } + config.Features = selected } - // Step 2: Prompt for feature dependencies - deps := features.CollectDependencies(config.Features) - for _, dep := range deps { - // Special handling for SQL warehouse - show picker instead of text input - if dep.ID == "sql_warehouse_id" { - warehouseID, err := prompt.PromptForWarehouse(ctx) - if err != nil { - return nil, err - } - config.Dependencies[dep.ID] = warehouseID - continue - } + // Always include mandatory plugins. + config.Features = appendUnique(config.Features, m.GetMandatoryPluginNames()...) - var value string - description := dep.Description - if !dep.Required { - description += " (optional)" + // Step 2: Prompt for required plugin resource dependencies + resources := m.CollectResources(config.Features) + for _, r := range resources { + values, err := promptForResource(ctx, r, theme, true) + if err != nil { + return nil, err } - - input := huh.NewInput(). - Title(dep.Title). - Description(description). - Placeholder(dep.Placeholder). - Value(&value) - - if dep.Required { - input = input.Validate(func(s string) error { - if s == "" { - return errors.New("this field is required") - } - return nil - }) + for k, v := range values { + config.Dependencies[k] = v } + } - if err := input.WithTheme(theme).Run(); err != nil { + // Step 3: Prompt for optional plugin resource dependencies + optionalResources := m.CollectOptionalResources(config.Features) + for _, r := range optionalResources { + values, err := promptForResource(ctx, r, theme, false) + if err != nil { return nil, err } - prompt.PrintAnswered(ctx, dep.Title, value) - config.Dependencies[dep.ID] = value + for k, v := range values { + config.Dependencies[k] = v + } } - // Step 3: Description + // Step 4: Description config.Description = prompt.DefaultAppDescription err := huh.NewInput(). Title("Description"). @@ -307,7 +366,7 @@ func promptForFeaturesAndDeps(ctx context.Context, preSelectedFeatures []string, } prompt.PrintAnswered(ctx, "Description", config.Description) - // Step 4: Deploy and run options (skip if any deploy/run flag was provided) + // Step 5: Deploy and run options (skip if any deploy/run flag was provided) if !skipDeployRunPrompt { config.Deploy, config.RunMode, err = prompt.PromptForDeployAndRun(ctx) if err != nil { @@ -318,84 +377,67 @@ func promptForFeaturesAndDeps(ctx context.Context, preSelectedFeatures []string, return config, nil } -// loadFeatureFragments reads and aggregates resource fragments for selected features. -// templateDir is the path to the template directory (containing the "features" subdirectory). -func loadFeatureFragments(templateDir string, featureIDs []string, vars templateVars) (*featureFragments, error) { - featuresDir := filepath.Join(templateDir, "features") - - resourceFiles := features.CollectResourceFiles(featureIDs) - if len(resourceFiles) == 0 { - return &featureFragments{}, nil - } - - var bundleVarsList, bundleResList, targetVarsList, appEnvList, dotEnvList, dotEnvExampleList []string - - for _, rf := range resourceFiles { - if rf.BundleVariables != "" { - content, err := readAndSubstitute(filepath.Join(featuresDir, rf.BundleVariables), vars) - if err != nil { - return nil, fmt.Errorf("read bundle variables: %w", err) - } - bundleVarsList = append(bundleVarsList, content) - } - if rf.BundleResources != "" { - content, err := readAndSubstitute(filepath.Join(featuresDir, rf.BundleResources), vars) - if err != nil { - return nil, fmt.Errorf("read bundle resources: %w", err) - } - bundleResList = append(bundleResList, content) - } - if rf.TargetVariables != "" { - content, err := readAndSubstitute(filepath.Join(featuresDir, rf.TargetVariables), vars) +// promptForResource prompts the user for a resource value. +// Returns a map of value keys to values. For single-field resources the key is "resource_key.field". +// For multi-field resources, keys use "resource_key.field_name". +func promptForResource(ctx context.Context, r manifest.Resource, theme *huh.Theme, required bool) (map[string]string, error) { + if fn, ok := prompt.GetPromptFunc(r.Type); ok { + if !required { + var configure bool + err := huh.NewConfirm(). + Title(fmt.Sprintf("Configure %s?", r.Alias)). + Description(r.Description + " (optional)"). + Value(&configure). + WithTheme(theme). + Run() if err != nil { - return nil, fmt.Errorf("read target variables: %w", err) - } - targetVarsList = append(targetVarsList, content) - } - if rf.AppEnv != "" { - content, err := readAndSubstitute(filepath.Join(featuresDir, rf.AppEnv), vars) - if err != nil { - return nil, fmt.Errorf("read app env: %w", err) + return nil, err } - appEnvList = append(appEnvList, content) - } - if rf.DotEnv != "" { - content, err := readAndSubstitute(filepath.Join(featuresDir, rf.DotEnv), vars) - if err != nil { - return nil, fmt.Errorf("read dotenv: %w", err) + if !configure { + prompt.PrintAnswered(ctx, r.Alias, "skipped") + return nil, nil } - dotEnvList = append(dotEnvList, content) } - if rf.DotEnvExample != "" { - content, err := readAndSubstitute(filepath.Join(featuresDir, rf.DotEnvExample), vars) - if err != nil { - return nil, fmt.Errorf("read dotenv example: %w", err) + return fn(ctx, r, required) + } + + // Generic text input for unregistered resource types + var value string + description := r.Description + if !required { + description += " (optional, press enter to skip)" + } + + input := huh.NewInput(). + Title(r.Alias). + Description(description). + Value(&value) + + if required { + input = input.Validate(func(s string) error { + if s == "" { + return errors.New("this field is required") } - dotEnvExampleList = append(dotEnvExampleList, content) - } + return nil + }) } - // Join fragments (they already have proper indentation from the fragment files) - return &featureFragments{ - BundleVariables: strings.TrimSuffix(strings.Join(bundleVarsList, ""), "\n"), - BundleResources: strings.TrimSuffix(strings.Join(bundleResList, ""), "\n"), - TargetVariables: strings.TrimSuffix(strings.Join(targetVarsList, ""), "\n"), - AppEnv: strings.TrimSuffix(strings.Join(appEnvList, ""), "\n"), - DotEnv: strings.TrimSuffix(strings.Join(dotEnvList, ""), "\n"), - DotEnvExample: strings.TrimSuffix(strings.Join(dotEnvExampleList, ""), "\n"), - }, nil -} + if err := input.WithTheme(theme).Run(); err != nil { + return nil, err + } -// readAndSubstitute reads a file and applies variable substitution. -func readAndSubstitute(path string, vars templateVars) (string, error) { - content, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return "", nil // Fragment file doesn't exist, skip it - } - return "", err + if value == "" && !required { + prompt.PrintAnswered(ctx, r.Alias, "skipped") + return nil, nil + } + prompt.PrintAnswered(ctx, r.Alias, value) + + // Use composite key from Fields when available. + names := r.FieldNames() + if len(names) >= 1 { + return map[string]string{r.Key() + "." + names[0]: value}, nil } - return substituteVars(string(content), vars), nil + return map[string]string{r.Key(): value}, nil } // cloneRepo clones a git repository to a temporary directory. @@ -453,15 +495,15 @@ func resolveTemplate(ctx context.Context, templatePath, branch, subdir string) ( } func runCreate(ctx context.Context, opts createOptions) error { - var selectedFeatures []string - var dependencies map[string]string + var selectedPlugins []string + var resourceValues map[string]string var shouldDeploy bool var runMode prompt.RunMode isInteractive := cmdio.IsPromptSupported(ctx) - // Use features from flags if provided - if len(opts.features) > 0 { - selectedFeatures = opts.features + // Use plugins from flags if provided + if len(opts.plugins) > 0 { + selectedPlugins = opts.plugins } // Resolve template path (supports local paths and GitHub URLs) @@ -546,155 +588,106 @@ func runCreate(ctx context.Context, opts createOptions) error { } } - // Step 3: Determine template type and gather configuration - usesFeatureFragments := features.HasFeaturesDirectory(templateDir) + // Step 3: Load manifest from template (optional — templates without it skip plugin/resource logic) + var m *manifest.Manifest + if manifest.HasManifest(templateDir) { + var err error + m, err = manifest.Load(templateDir) + if err != nil { + return fmt.Errorf("load manifest: %w", err) + } + log.Debugf(ctx, "Loaded manifest with %d plugins", len(m.Plugins)) + for name, p := range m.Plugins { + log.Debugf(ctx, " Plugin %q: %d required resources, %d optional resources, requiredByTemplate=%v", + name, len(p.Resources.Required), len(p.Resources.Optional), p.RequiredByTemplate) + } + } else { + log.Debugf(ctx, "No manifest found in template, skipping plugin/resource configuration") + m = &manifest.Manifest{Plugins: map[string]manifest.Plugin{}} + } // When --name is provided, user is in "flags mode" - use defaults instead of prompting flagsMode := opts.nameProvided - if usesFeatureFragments { - // Feature-fragment template: prompt for features and their dependencies - // Skip deploy/run prompts if in flags mode or if deploy/run flags were explicitly set - skipDeployRunPrompt := flagsMode || opts.deployChanged || opts.runChanged + // Skip deploy/run prompts if in flags mode or if deploy/run flags were explicitly set + skipDeployRunPrompt := flagsMode || opts.deployChanged || opts.runChanged - if isInteractive && !opts.featuresChanged && !flagsMode { - // Interactive mode without --features flag: prompt for features, dependencies, description - config, err := promptForFeaturesAndDeps(ctx, selectedFeatures, skipDeployRunPrompt) - if err != nil { + if isInteractive && !opts.pluginsChanged && !flagsMode { + // Interactive mode without --plugins flag: prompt for plugins, dependencies, description + config, err := promptForPluginsAndDeps(ctx, m, selectedPlugins, skipDeployRunPrompt) + if err != nil { + return err + } + selectedPlugins = config.Features // Features field holds plugin names + resourceValues = config.Dependencies + if config.Description != "" { + opts.description = config.Description + } + if !skipDeployRunPrompt { + shouldDeploy = config.Deploy + runMode = config.RunMode + } + } else { + // --plugins flag or flags/non-interactive mode: validate plugin names + if len(selectedPlugins) > 0 { + if err := m.ValidatePluginNames(selectedPlugins); err != nil { return err } - selectedFeatures = config.Features - dependencies = config.Dependencies - if config.Description != "" { - opts.description = config.Description - } - // Use prompted values for deploy/run (only set if we prompted) - if !skipDeployRunPrompt { - shouldDeploy = config.Deploy - runMode = config.RunMode - } - - // Get warehouse from dependencies if provided - if wh, ok := dependencies["sql_warehouse_id"]; ok && wh != "" { - opts.warehouseID = wh - } - } else if isInteractive && opts.featuresChanged && !flagsMode { - // Interactive mode with --features flag: validate features, prompt for deploy/run if no flags - flagValues := map[string]string{ - "warehouse-id": opts.warehouseID, - } - if len(selectedFeatures) > 0 { - if err := features.ValidateFeatureDependencies(selectedFeatures, flagValues); err != nil { - return err - } - } - dependencies = make(map[string]string) - if opts.warehouseID != "" { - dependencies["sql_warehouse_id"] = opts.warehouseID - } - - // Prompt for deploy/run if no flags were set - if !skipDeployRunPrompt { - var err error - shouldDeploy, runMode, err = prompt.PromptForDeployAndRun(ctx) - if err != nil { - return err - } - } - } else { - // Flags mode or non-interactive: validate features and use flag values - flagValues := map[string]string{ - "warehouse-id": opts.warehouseID, - } - if len(selectedFeatures) > 0 { - if err := features.ValidateFeatureDependencies(selectedFeatures, flagValues); err != nil { - return err - } - } - dependencies = make(map[string]string) - if opts.warehouseID != "" { - dependencies["sql_warehouse_id"] = opts.warehouseID - } } - - // Apply flag values for deploy/run when in flags mode, flags were explicitly set, or non-interactive - if skipDeployRunPrompt || !isInteractive { + // Prompt for deploy/run in interactive mode when no flags were set + if isInteractive && !skipDeployRunPrompt { var err error - shouldDeploy, runMode, err = parseDeployAndRunFlags(opts.deploy, opts.run) + shouldDeploy, runMode, err = prompt.PromptForDeployAndRun(ctx) if err != nil { return err } } + } - // Validate feature IDs - if err := features.ValidateFeatureIDs(selectedFeatures); err != nil { - return err + // Parse --set values (override any prompted values) + setVals, err := parseSetValues(opts.setValues, m) + if err != nil { + return err + } + if len(setVals) > 0 { + if resourceValues == nil { + resourceValues = make(map[string]string, len(setVals)) } - } else { - // Pre-assembled template: detect plugins and prompt for their dependencies - detectedPlugins, err := features.DetectPluginsFromServer(templateDir) - if err != nil { - return fmt.Errorf("failed to detect plugins: %w", err) + for k, v := range setVals { + resourceValues[k] = v } + } - log.Debugf(ctx, "Detected plugins: %v", detectedPlugins) - - // Map detected plugins to feature IDs for ApplyFeatures - selectedFeatures = features.MapPluginsToFeatures(detectedPlugins) - log.Debugf(ctx, "Mapped to features: %v", selectedFeatures) - - pluginDeps := features.GetPluginDependencies(detectedPlugins) - - log.Debugf(ctx, "Plugin dependencies: %d", len(pluginDeps)) - - if isInteractive && len(pluginDeps) > 0 { - // Prompt for plugin dependencies - dependencies, err = prompt.PromptForPluginDependencies(ctx, pluginDeps) - if err != nil { - return err - } - if wh, ok := dependencies["sql_warehouse_id"]; ok && wh != "" { - opts.warehouseID = wh - } - } else { - // Non-interactive: check flags - dependencies = make(map[string]string) - if opts.warehouseID != "" { - dependencies["sql_warehouse_id"] = opts.warehouseID + // Always include mandatory plugins regardless of user selection or flags. + selectedPlugins = appendUnique(selectedPlugins, m.GetMandatoryPluginNames()...) + + // In flags/non-interactive mode, validate that all required resources are provided. + if flagsMode || !isInteractive { + resources := m.CollectResources(selectedPlugins) + for _, r := range resources { + found := false + for k := range resourceValues { + if strings.HasPrefix(k, r.Key()+".") { + found = true + break + } } - - // Validate required dependencies are provided - for _, dep := range pluginDeps { - if dep.Required { - if _, ok := dependencies[dep.ID]; !ok { - return fmt.Errorf("missing required flag --%s for detected plugin", dep.FlagName) - } + if !found { + fieldHint := "id" + if names := r.FieldNames(); len(names) > 0 { + fieldHint = names[0] } + return fmt.Errorf("missing required resource %q for selected plugins (use --set %s.%s=value)", r.Alias, r.Key(), fieldHint) } } + } - // Set default description if not provided - if opts.description == "" { - opts.description = prompt.DefaultAppDescription - } - - // Only prompt for deploy/run if not in flags mode and no deploy/run flags were set - if isInteractive && !flagsMode && !opts.deployChanged && !opts.runChanged { - var deployVal bool - var runVal prompt.RunMode - deployVal, runVal, err = prompt.PromptForDeployAndRun(ctx) - if err != nil { - return err - } - shouldDeploy = deployVal - runMode = runVal - } else { - // Flags mode or explicit flags: use flag values (or defaults if not set) - var err error - shouldDeploy, runMode, err = parseDeployAndRunFlags(opts.deploy, opts.run) - if err != nil { - return err - } + // Apply flag values for deploy/run when in flags mode, flags were explicitly set, or non-interactive + if skipDeployRunPrompt || !isInteractive { + var err error + shouldDeploy, runMode, err = parseDeployAndRunFlags(opts.deploy, opts.run) + if err != nil { + return err } } @@ -721,31 +714,47 @@ func runCreate(ctx context.Context, opts createOptions) error { profile = w.Config.Profile } - // Build plugin imports and usages from selected features - pluginImport, pluginUsage := features.BuildPluginStrings(selectedFeatures) + // Get selected plugins for generation + selectedPluginList := generator.GetSelectedPlugins(m, selectedPlugins) - // Template variables (initial, without feature fragments) - vars := templateVars{ + log.Debugf(ctx, "Selected plugins: %v", selectedPlugins) + log.Debugf(ctx, "Selected plugin list count: %d", len(selectedPluginList)) + log.Debugf(ctx, "Resource values: %d entries", len(resourceValues)) + + // Build generator config + genConfig := generator.Config{ ProjectName: opts.name, - SQLWarehouseID: opts.warehouseID, - AppDescription: opts.description, - Profile: profile, WorkspaceHost: workspaceHost, - PluginImport: pluginImport, - PluginUsage: pluginUsage, + Profile: profile, + ResourceValues: resourceValues, } - // Load feature resource fragments - fragments, err := loadFeatureFragments(templateDir, selectedFeatures, vars) - if err != nil { - return fmt.Errorf("load feature fragments: %w", err) + // 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) + targetVars := generator.GenerateTargetVariables(selectedPluginList, genConfig) + + log.Debugf(ctx, "Generated bundle variables:\n%s", bundleVars) + log.Debugf(ctx, "Generated bundle resources:\n%s", bundleRes) + log.Debugf(ctx, "Generated target variables:\n%s", targetVars) + + // 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), } - vars.BundleVariables = fragments.BundleVariables - vars.BundleResources = fragments.BundleResources - vars.TargetVariables = fragments.TargetVariables - vars.AppEnv = fragments.AppEnv - vars.DotEnv = fragments.DotEnv - vars.DotEnvExample = fragments.DotEnvExample // Copy template with variable substitution var fileCount int @@ -765,9 +774,9 @@ func runCreate(ctx context.Context, opts createOptions) error { absOutputDir = destDir } - // Apply features (adds selected features, removes unselected feature files) - runErr = prompt.RunWithSpinnerCtx(ctx, "Configuring features...", func() error { - return features.ApplyFeatures(absOutputDir, selectedFeatures) + // 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 @@ -868,6 +877,71 @@ func runPostCreateDev(ctx context.Context, mode prompt.RunMode, projectInit init } } +// appendUnique appends values to a slice, skipping duplicates. +func appendUnique(base []string, values ...string) []string { + seen := make(map[string]bool, len(base)) + for _, v := range base { + seen[v] = true + } + for _, v := range values { + if !seen[v] { + seen[v] = true + base = append(base, v) + } + } + return base +} + +// buildPluginStrings builds the plugin import and usage strings from selected plugin names. +func buildPluginStrings(pluginNames []string) (pluginImport, pluginUsage string) { + if len(pluginNames) == 0 { + return "", "" + } + + // Plugin names map directly to imports and usage + // e.g., "analytics" -> import "analytics", usage "analytics()" + var imports []string + var usages []string + + for _, name := range pluginNames { + imports = append(imports, name) + usages = append(usages, name+"()") + } + + pluginImport = strings.Join(imports, ", ") + pluginUsage = strings.Join(usages, ",\n ") + + 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", @@ -990,8 +1064,13 @@ func copyTemplate(ctx context.Context, src, dest string, vars templateVars) (int return err } - // Write file - if err := os.WriteFile(destPath, content, info.Mode()); err != nil { + // Write file — use restrictive permissions for .env files (may contain secrets). + perm := info.Mode() + destName := filepath.Base(destPath) + if strings.HasPrefix(destName, ".env") { + perm = 0o600 + } + if err := os.WriteFile(destPath, content, perm); err != nil { return err } @@ -1013,27 +1092,33 @@ func processPackageJSON(content []byte, vars templateVars) ([]byte, error) { } // substituteVars replaces template variables in a string. +// Note: This is for simple string replacement in non-.tmpl files. +// .tmpl files use Go's text/template engine via executeTemplate. func substituteVars(s string, vars templateVars) string { s = strings.ReplaceAll(s, "{{.project_name}}", vars.ProjectName) - s = strings.ReplaceAll(s, "{{.sql_warehouse_id}}", vars.SQLWarehouseID) s = strings.ReplaceAll(s, "{{.app_description}}", vars.AppDescription) s = strings.ReplaceAll(s, "{{.profile}}", vars.Profile) s = strings.ReplaceAll(s, "{{workspace_host}}", vars.WorkspaceHost) // Handle plugin placeholders - if vars.PluginImport != "" { - s = strings.ReplaceAll(s, "{{.plugin_import}}", vars.PluginImport) - s = strings.ReplaceAll(s, "{{.plugin_usage}}", vars.PluginUsage) + if vars.PluginImports != "" { + s = strings.ReplaceAll(s, "{{.plugin_imports}}", vars.PluginImports) + s = strings.ReplaceAll(s, "{{.plugin_usages}}", vars.PluginUsages) } else { // No plugins selected - clean up the template - // Remove ", {{.plugin_import}}" from import line - s = strings.ReplaceAll(s, ", {{.plugin_import}} ", " ") - s = strings.ReplaceAll(s, ", {{.plugin_import}}", "") - // Remove the plugin_usage line entirely - s = strings.ReplaceAll(s, " {{.plugin_usage}},\n", "") - s = strings.ReplaceAll(s, " {{.plugin_usage}},", "") + // Remove ", {{.plugin_imports}}" from import line + s = strings.ReplaceAll(s, ", {{.plugin_imports}} ", " ") + s = strings.ReplaceAll(s, ", {{.plugin_imports}}", "") + // Remove the plugin_usages line entirely + s = strings.ReplaceAll(s, " {{.plugin_usages}},\n", "") + s = strings.ReplaceAll(s, "{{.plugin_usages}}", "") } + // Handle bundle configuration placeholders + s = strings.ReplaceAll(s, "{{.variables}}", vars.BundleVariables) + s = strings.ReplaceAll(s, "{{.resources}}", vars.BundleResources) + s = strings.ReplaceAll(s, "{{.target_variables}}", vars.TargetVariables) + return s } @@ -1051,16 +1136,14 @@ func executeTemplate(path string, content []byte, vars templateVars) ([]byte, er // Use a map to match template variable names exactly (snake_case) data := map[string]string{ "project_name": vars.ProjectName, - "sql_warehouse_id": vars.SQLWarehouseID, "app_description": vars.AppDescription, "profile": vars.Profile, "workspace_host": vars.WorkspaceHost, - "plugin_import": vars.PluginImport, - "plugin_usage": vars.PluginUsage, - "bundle_variables": vars.BundleVariables, - "bundle_resources": vars.BundleResources, + "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, } diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index c21447cc16..9993a5e135 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -4,6 +4,7 @@ import ( "errors" "testing" + "github.com/databricks/cli/libs/apps/manifest" "github.com/databricks/cli/libs/apps/prompt" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" @@ -67,12 +68,11 @@ func TestIsTextFile(t *testing.T) { func TestSubstituteVars(t *testing.T) { vars := templateVars{ ProjectName: "my-app", - SQLWarehouseID: "warehouse123", AppDescription: "My awesome app", Profile: "default", WorkspaceHost: "https://dbc-123.cloud.databricks.com", - PluginImport: "analytics", - PluginUsage: "analytics()", + PluginImports: "analytics", + PluginUsages: "analytics()", } tests := []struct { @@ -85,11 +85,6 @@ func TestSubstituteVars(t *testing.T) { input: "name: {{.project_name}}", expected: "name: my-app", }, - { - name: "warehouse id substitution", - input: "warehouse: {{.sql_warehouse_id}}", - expected: "warehouse: warehouse123", - }, { name: "description substitution", input: "description: {{.app_description}}", @@ -107,12 +102,12 @@ func TestSubstituteVars(t *testing.T) { }, { name: "plugin import substitution", - input: "import { {{.plugin_import}} } from 'appkit'", + input: "import { {{.plugin_imports}} } from 'appkit'", expected: "import { analytics } from 'appkit'", }, { name: "plugin usage substitution", - input: "plugins: [{{.plugin_usage}}]", + input: "plugins: [{{.plugin_usages}}]", expected: "plugins: [analytics()]", }, { @@ -139,12 +134,11 @@ func TestSubstituteVarsNoPlugins(t *testing.T) { // Test plugin cleanup when no plugins are selected vars := templateVars{ ProjectName: "my-app", - SQLWarehouseID: "", AppDescription: "My app", Profile: "", WorkspaceHost: "", - PluginImport: "", // No plugins - PluginUsage: "", + PluginImports: "", // No plugins + PluginUsages: "", } tests := []struct { @@ -154,12 +148,12 @@ func TestSubstituteVarsNoPlugins(t *testing.T) { }{ { name: "removes plugin import with comma", - input: "import { core, {{.plugin_import}} } from 'appkit'", + input: "import { core, {{.plugin_imports}} } from 'appkit'", expected: "import { core } from 'appkit'", }, { name: "removes plugin usage line", - input: "plugins: [\n {{.plugin_usage}},\n]", + input: "plugins: [\n {{.plugin_usages}},\n]", expected: "plugins: [\n]", }, } @@ -283,3 +277,168 @@ func TestParseDeployAndRunFlags(t *testing.T) { }) } } + +// testManifest returns a manifest with an "analytics" plugin for testing parseSetValues. +func testManifest() *manifest.Manifest { + return &manifest.Manifest{ + Plugins: map[string]manifest.Plugin{ + "analytics": { + Name: "analytics", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + { + Type: "sql_warehouse", + Alias: "SQL Warehouse", + ResourceKey: "sql-warehouse", + Fields: map[string]manifest.ResourceField{"id": {Env: "WH_ID"}}, + }, + }, + Optional: []manifest.Resource{ + { + Type: "database", + Alias: "Database", + ResourceKey: "database", + Fields: map[string]manifest.ResourceField{ + "instance_name": {Env: "DB_INST"}, + "database_name": {Env: "DB_NAME"}, + }, + }, + { + Type: "secret", + Alias: "Secret", + ResourceKey: "secret", + Fields: map[string]manifest.ResourceField{ + "scope": {Env: "SECRET_SCOPE"}, + "key": {Env: "SECRET_KEY"}, + }, + }, + }, + }, + }, + }, + } +} + +func TestParseSetValues(t *testing.T) { + m := testManifest() + + tests := []struct { + name string + setValues []string + wantRV map[string]string + wantErr string + }{ + { + name: "single field", + setValues: []string{"analytics.sql-warehouse.id=abc123"}, + wantRV: map[string]string{"sql-warehouse.id": "abc123"}, + }, + { + name: "multi-field complete", + setValues: []string{"analytics.database.instance_name=inst", "analytics.database.database_name=mydb"}, + wantRV: map[string]string{"database.instance_name": "inst", "database.database_name": "mydb"}, + }, + { + name: "later set overrides earlier", + setValues: []string{"analytics.sql-warehouse.id=first", "analytics.sql-warehouse.id=second"}, + wantRV: map[string]string{"sql-warehouse.id": "second"}, + }, + { + name: "empty set values", + setValues: nil, + wantRV: map[string]string{}, + }, + { + name: "missing equals sign", + setValues: []string{"analytics.sql-warehouse.id"}, + wantErr: "invalid --set format", + }, + { + name: "too few key parts", + setValues: []string{"sql-warehouse.id=abc"}, + wantErr: "invalid --set key", + }, + { + name: "unknown plugin", + setValues: []string{"nosuch.sql-warehouse.id=abc"}, + wantErr: `unknown plugin "nosuch"`, + }, + { + name: "unknown resource key", + setValues: []string{"analytics.nosuch.id=abc"}, + wantErr: `has no resource with key "nosuch"`, + }, + { + name: "unknown field", + setValues: []string{"analytics.sql-warehouse.nosuch=abc"}, + wantErr: `field "nosuch"`, + }, + { + name: "multi-field incomplete database", + setValues: []string{"analytics.database.instance_name=inst"}, + wantErr: `incomplete resource "database"`, + }, + { + name: "multi-field incomplete secret", + setValues: []string{"analytics.secret.scope=myscope"}, + wantErr: `incomplete resource "secret"`, + }, + { + name: "all fields together", + setValues: []string{ + "analytics.sql-warehouse.id=wh1", + "analytics.database.instance_name=inst", + "analytics.database.database_name=mydb", + "analytics.secret.scope=s", + "analytics.secret.key=k", + }, + wantRV: map[string]string{ + "sql-warehouse.id": "wh1", + "database.instance_name": "inst", + "database.database_name": "mydb", + "secret.scope": "s", + "secret.key": "k", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rv, err := parseSetValues(tt.setValues, m) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantRV, rv) + } + }) + } +} + +func TestPluginHasResourceField(t *testing.T) { + m := testManifest() + p := m.GetPluginByName("analytics") + require.NotNil(t, p) + + assert.True(t, pluginHasResourceField(p, "sql-warehouse", "id")) + assert.True(t, pluginHasResourceField(p, "database", "instance_name")) + assert.True(t, pluginHasResourceField(p, "secret", "scope")) + assert.False(t, pluginHasResourceField(p, "sql-warehouse", "nosuch")) + assert.False(t, pluginHasResourceField(p, "nosuch", "id")) +} + +func TestAppendUnique(t *testing.T) { + result := appendUnique([]string{"a", "b"}, "b", "c", "a", "d") + assert.Equal(t, []string{"a", "b", "c", "d"}, result) +} + +func TestAppendUniqueEmptyBase(t *testing.T) { + result := appendUnique(nil, "x", "y", "x") + assert.Equal(t, []string{"x", "y"}, result) +} + +func TestAppendUniqueNoValues(t *testing.T) { + result := appendUnique([]string{"a", "b"}) + assert.Equal(t, []string{"a", "b"}, result) +} diff --git a/libs/apps/features/features.go b/libs/apps/features/features.go deleted file mode 100644 index 64a4ce3949..0000000000 --- a/libs/apps/features/features.go +++ /dev/null @@ -1,329 +0,0 @@ -package features - -import ( - "fmt" - "os" - "path/filepath" - "regexp" - "strings" -) - -// FeatureDependency defines a prompt/input required by a feature. -type FeatureDependency struct { - ID string // e.g., "sql_warehouse_id" - FlagName string // CLI flag name, e.g., "warehouse-id" (maps to --warehouse-id) - Title string // e.g., "SQL Warehouse ID" - Description string // e.g., "Required for executing SQL queries" - Placeholder string - Required bool -} - -// FeatureResourceFiles defines paths to YAML fragment files for a feature's resources. -// Paths are relative to the template's features directory (e.g., "analytics/bundle_variables.yml"). -type FeatureResourceFiles struct { - BundleVariables string // Variables section for databricks.yml - BundleResources string // Resources section for databricks.yml (app resources) - TargetVariables string // Dev target variables section for databricks.yml - AppEnv string // Environment variables for app.yaml - DotEnv string // Environment variables for .env (development) - DotEnvExample string // Environment variables for .env.example -} - -// Feature represents an optional feature that can be added to an AppKit project. -type Feature struct { - ID string - Name string - Description string - PluginImport string - PluginUsage string - Dependencies []FeatureDependency - ResourceFiles FeatureResourceFiles -} - -// AvailableFeatures lists all features that can be selected when creating a project. -var AvailableFeatures = []Feature{ - { - ID: "analytics", - Name: "Analytics", - Description: "SQL analytics with charts and dashboards", - PluginImport: "analytics", - PluginUsage: "analytics()", - Dependencies: []FeatureDependency{ - { - ID: "sql_warehouse_id", - FlagName: "warehouse-id", - Title: "SQL Warehouse ID", - Description: "required for SQL queries", - Required: true, - }, - }, - ResourceFiles: FeatureResourceFiles{ - BundleVariables: "analytics/bundle_variables.yml", - BundleResources: "analytics/bundle_resources.yml", - TargetVariables: "analytics/target_variables.yml", - AppEnv: "analytics/app_env.yml", - DotEnv: "analytics/dotenv.yml", - DotEnvExample: "analytics/dotenv_example.yml", - }, - }, -} - -var featureByID = func() map[string]Feature { - m := make(map[string]Feature, len(AvailableFeatures)) - for _, f := range AvailableFeatures { - m[f.ID] = f - } - return m -}() - -// featureByPluginImport maps plugin import names to features. -var featureByPluginImport = func() map[string]Feature { - m := make(map[string]Feature, len(AvailableFeatures)) - for _, f := range AvailableFeatures { - if f.PluginImport != "" { - m[f.PluginImport] = f - } - } - return m -}() - -// pluginPattern matches plugin function calls dynamically built from AvailableFeatures. -// Matches patterns like: analytics(), genie(), oauth(), etc. -var pluginPattern = func() *regexp.Regexp { - var plugins []string - for _, f := range AvailableFeatures { - if f.PluginImport != "" { - plugins = append(plugins, regexp.QuoteMeta(f.PluginImport)) - } - } - if len(plugins) == 0 { - // Fallback pattern that matches nothing - return regexp.MustCompile(`$^`) - } - // Build pattern: \b(plugin1|plugin2|plugin3)\s*\( - pattern := `\b(` + strings.Join(plugins, "|") + `)\s*\(` - return regexp.MustCompile(pattern) -}() - -// serverFilePaths lists common locations for the server entry file. -var serverFilePaths = []string{ - "src/server/index.ts", - "src/server/index.tsx", - "src/server.ts", - "server/index.ts", - "server/server.ts", - "server.ts", -} - -// TODO: We should come to an agreement if we want to do it like this, -// or maybe we should have an appkit.json manifest file in each project. -func DetectPluginsFromServer(templateDir string) ([]string, error) { - var content []byte - - for _, p := range serverFilePaths { - fullPath := filepath.Join(templateDir, p) - data, err := os.ReadFile(fullPath) - if err == nil { - content = data - break - } - } - - if content == nil { - return nil, nil // No server file found - } - - matches := pluginPattern.FindAllStringSubmatch(string(content), -1) - seen := make(map[string]bool) - var plugins []string - - for _, m := range matches { - plugin := m[1] - if !seen[plugin] { - seen[plugin] = true - plugins = append(plugins, plugin) - } - } - - return plugins, nil -} - -// GetPluginDependencies returns all dependencies required by the given plugin names. -func GetPluginDependencies(pluginNames []string) []FeatureDependency { - seen := make(map[string]bool) - var deps []FeatureDependency - - for _, plugin := range pluginNames { - feature, ok := featureByPluginImport[plugin] - if !ok { - continue - } - for _, dep := range feature.Dependencies { - if !seen[dep.ID] { - seen[dep.ID] = true - deps = append(deps, dep) - } - } - } - - return deps -} - -// MapPluginsToFeatures maps plugin import names to feature IDs. -// This is used to convert detected plugins (e.g., "analytics") to feature IDs -// so that ApplyFeatures can properly retain feature-specific files. -func MapPluginsToFeatures(pluginNames []string) []string { - seen := make(map[string]bool) - var featureIDs []string - - for _, plugin := range pluginNames { - feature, ok := featureByPluginImport[plugin] - if ok && !seen[feature.ID] { - seen[feature.ID] = true - featureIDs = append(featureIDs, feature.ID) - } - } - - return featureIDs -} - -// HasFeaturesDirectory checks if the template uses the feature-fragment system. -func HasFeaturesDirectory(templateDir string) bool { - featuresDir := filepath.Join(templateDir, "features") - info, err := os.Stat(featuresDir) - return err == nil && info.IsDir() -} - -// ValidateFeatureIDs checks that all provided feature IDs are valid. -// Returns an error if any feature ID is unknown. -func ValidateFeatureIDs(featureIDs []string) error { - for _, id := range featureIDs { - if _, ok := featureByID[id]; !ok { - return fmt.Errorf("unknown feature: %q; available: %s", id, strings.Join(GetFeatureIDs(), ", ")) - } - } - return nil -} - -// ValidateFeatureDependencies checks that all required dependencies for the given features -// are provided in the flagValues map. Returns an error listing missing required flags. -func ValidateFeatureDependencies(featureIDs []string, flagValues map[string]string) error { - deps := CollectDependencies(featureIDs) - var missing []string - - for _, dep := range deps { - if !dep.Required { - continue - } - value, ok := flagValues[dep.FlagName] - if !ok || value == "" { - missing = append(missing, "--"+dep.FlagName) - } - } - - if len(missing) > 0 { - return fmt.Errorf("missing required flags for selected features: %s", strings.Join(missing, ", ")) - } - return nil -} - -// GetFeatureIDs returns a list of all available feature IDs for help text. -func GetFeatureIDs() []string { - ids := make([]string, len(AvailableFeatures)) - for i, f := range AvailableFeatures { - ids[i] = f.ID - } - return ids -} - -// BuildPluginStrings builds the plugin import and usage strings from selected feature IDs. -// Returns comma-separated imports and newline-separated usages. -func BuildPluginStrings(featureIDs []string) (pluginImport, pluginUsage string) { - if len(featureIDs) == 0 { - return "", "" - } - - var imports []string - var usages []string - - for _, id := range featureIDs { - feature, ok := featureByID[id] - if !ok || feature.PluginImport == "" { - continue - } - imports = append(imports, feature.PluginImport) - usages = append(usages, feature.PluginUsage) - } - - if len(imports) == 0 { - return "", "" - } - - // Join imports with comma (e.g., "analytics, trpc") - pluginImport = strings.Join(imports, ", ") - - // Join usages with newline and proper indentation - pluginUsage = strings.Join(usages, ",\n ") - - return pluginImport, pluginUsage -} - -// ApplyFeatures applies any post-copy modifications for selected features. -// This removes feature-specific directories if the feature is not selected. -func ApplyFeatures(projectDir string, featureIDs []string) error { - selectedSet := make(map[string]bool) - for _, id := range featureIDs { - selectedSet[id] = true - } - - // Remove analytics-specific files if analytics is not selected - if !selectedSet["analytics"] { - queriesDir := filepath.Join(projectDir, "config", "queries") - if err := os.RemoveAll(queriesDir); err != nil && !os.IsNotExist(err) { - return err - } - } - - return nil -} - -// CollectDependencies returns all unique dependencies required by the selected features. -func CollectDependencies(featureIDs []string) []FeatureDependency { - seen := make(map[string]bool) - var deps []FeatureDependency - - for _, id := range featureIDs { - feature, ok := featureByID[id] - if !ok { - continue - } - for _, dep := range feature.Dependencies { - if !seen[dep.ID] { - seen[dep.ID] = true - deps = append(deps, dep) - } - } - } - - return deps -} - -// CollectResourceFiles returns all resource file paths for the selected features. -func CollectResourceFiles(featureIDs []string) []FeatureResourceFiles { - var resources []FeatureResourceFiles - for _, id := range featureIDs { - feature, ok := featureByID[id] - if !ok { - continue - } - // Only include if at least one resource file is defined - rf := feature.ResourceFiles - if rf.BundleVariables != "" || rf.BundleResources != "" || - rf.TargetVariables != "" || rf.AppEnv != "" || - rf.DotEnv != "" || rf.DotEnvExample != "" { - resources = append(resources, rf) - } - } - - return resources -} diff --git a/libs/apps/features/features_test.go b/libs/apps/features/features_test.go deleted file mode 100644 index dfd2bb2f84..0000000000 --- a/libs/apps/features/features_test.go +++ /dev/null @@ -1,453 +0,0 @@ -package features - -import ( - "fmt" - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestValidateFeatureIDs(t *testing.T) { - tests := []struct { - name string - featureIDs []string - expectError bool - errorMsg string - }{ - { - name: "valid feature - analytics", - featureIDs: []string{"analytics"}, - expectError: false, - }, - { - name: "empty feature list", - featureIDs: []string{}, - expectError: false, - }, - { - name: "nil feature list", - featureIDs: nil, - expectError: false, - }, - { - name: "unknown feature", - featureIDs: []string{"unknown-feature"}, - expectError: true, - errorMsg: "unknown feature", - }, - { - name: "mix of valid and invalid", - featureIDs: []string{"analytics", "invalid"}, - expectError: true, - errorMsg: "unknown feature", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateFeatureIDs(tt.featureIDs) - if tt.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.errorMsg) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestValidateFeatureDependencies(t *testing.T) { - tests := []struct { - name string - featureIDs []string - flagValues map[string]string - expectError bool - errorMsg string - }{ - { - name: "analytics with warehouse provided", - featureIDs: []string{"analytics"}, - flagValues: map[string]string{"warehouse-id": "abc123"}, - expectError: false, - }, - { - name: "analytics without warehouse", - featureIDs: []string{"analytics"}, - flagValues: map[string]string{}, - expectError: true, - errorMsg: "--warehouse-id", - }, - { - name: "analytics with empty warehouse", - featureIDs: []string{"analytics"}, - flagValues: map[string]string{"warehouse-id": ""}, - expectError: true, - errorMsg: "--warehouse-id", - }, - { - name: "no features - no dependencies needed", - featureIDs: []string{}, - flagValues: map[string]string{}, - expectError: false, - }, - { - name: "unknown feature - gracefully ignored", - featureIDs: []string{"unknown"}, - flagValues: map[string]string{}, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateFeatureDependencies(tt.featureIDs, tt.flagValues) - if tt.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.errorMsg) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestGetFeatureIDs(t *testing.T) { - ids := GetFeatureIDs() - - assert.NotEmpty(t, ids) - assert.Contains(t, ids, "analytics") -} - -func TestBuildPluginStrings(t *testing.T) { - tests := []struct { - name string - featureIDs []string - expectedImport string - expectedUsage string - }{ - { - name: "no features", - featureIDs: []string{}, - expectedImport: "", - expectedUsage: "", - }, - { - name: "nil features", - featureIDs: nil, - expectedImport: "", - expectedUsage: "", - }, - { - name: "analytics feature", - featureIDs: []string{"analytics"}, - expectedImport: "analytics", - expectedUsage: "analytics()", - }, - { - name: "unknown feature - ignored", - featureIDs: []string{"unknown"}, - expectedImport: "", - expectedUsage: "", - }, - { - name: "mix of known and unknown", - featureIDs: []string{"analytics", "unknown"}, - expectedImport: "analytics", - expectedUsage: "analytics()", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - importStr, usageStr := BuildPluginStrings(tt.featureIDs) - assert.Equal(t, tt.expectedImport, importStr) - assert.Equal(t, tt.expectedUsage, usageStr) - }) - } -} - -func TestCollectDependencies(t *testing.T) { - tests := []struct { - name string - featureIDs []string - expectedDeps int - expectedIDs []string - }{ - { - name: "no features", - featureIDs: []string{}, - expectedDeps: 0, - expectedIDs: nil, - }, - { - name: "analytics feature", - featureIDs: []string{"analytics"}, - expectedDeps: 1, - expectedIDs: []string{"sql_warehouse_id"}, - }, - { - name: "unknown feature", - featureIDs: []string{"unknown"}, - expectedDeps: 0, - expectedIDs: nil, - }, - { - name: "duplicate features - deduped deps", - featureIDs: []string{"analytics", "analytics"}, - expectedDeps: 1, - expectedIDs: []string{"sql_warehouse_id"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - deps := CollectDependencies(tt.featureIDs) - assert.Len(t, deps, tt.expectedDeps) - - if tt.expectedIDs != nil { - for i, expectedID := range tt.expectedIDs { - assert.Equal(t, expectedID, deps[i].ID) - } - } - }) - } -} - -func TestCollectResourceFiles(t *testing.T) { - tests := []struct { - name string - featureIDs []string - expectedResources int - }{ - { - name: "no features", - featureIDs: []string{}, - expectedResources: 0, - }, - { - name: "analytics feature", - featureIDs: []string{"analytics"}, - expectedResources: 1, - }, - { - name: "unknown feature", - featureIDs: []string{"unknown"}, - expectedResources: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resources := CollectResourceFiles(tt.featureIDs) - assert.Len(t, resources, tt.expectedResources) - - if tt.expectedResources > 0 && tt.featureIDs[0] == "analytics" { - assert.NotEmpty(t, resources[0].BundleVariables) - assert.NotEmpty(t, resources[0].BundleResources) - } - }) - } -} - -func TestDetectPluginsFromServer(t *testing.T) { - tests := []struct { - name string - serverContent string - expectedPlugins []string - }{ - { - name: "analytics plugin", - serverContent: `import { createApp, server, analytics } from '@databricks/appkit'; -createApp({ - plugins: [ - server(), - analytics(), - ], -}).catch(console.error);`, - expectedPlugins: []string{"analytics"}, - }, - { - name: "analytics with other plugins not in AvailableFeatures", - serverContent: `import { createApp, server, analytics, genie } from '@databricks/appkit'; -createApp({ - plugins: [ - server(), - analytics(), - genie(), - ], -}).catch(console.error);`, - expectedPlugins: []string{"analytics"}, // Only analytics is detected since genie is not in AvailableFeatures - }, - { - name: "no recognized plugins", - serverContent: `import { createApp, server } from '@databricks/appkit';`, - expectedPlugins: nil, - }, - { - name: "plugin not in AvailableFeatures", - serverContent: `createApp({ - plugins: [oauth()], -});`, - expectedPlugins: nil, // oauth is not in AvailableFeatures, so not detected - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create temp dir with server file - tempDir := t.TempDir() - serverDir := tempDir + "/src/server" - require.NoError(t, os.MkdirAll(serverDir, 0o755)) - require.NoError(t, os.WriteFile(serverDir+"/index.ts", []byte(tt.serverContent), 0o644)) - - plugins, err := DetectPluginsFromServer(tempDir) - require.NoError(t, err) - assert.Equal(t, tt.expectedPlugins, plugins) - }) - } -} - -func TestDetectPluginsFromServerAlternatePath(t *testing.T) { - // Test server/server.ts path (common in some templates) - tempDir := t.TempDir() - serverDir := tempDir + "/server" - require.NoError(t, os.MkdirAll(serverDir, 0o755)) - - serverContent := `import { createApp, server, analytics } from '@databricks/appkit'; -createApp({ - plugins: [ - server(), - analytics(), - ], -}).catch(console.error);` - - require.NoError(t, os.WriteFile(serverDir+"/server.ts", []byte(serverContent), 0o644)) - - plugins, err := DetectPluginsFromServer(tempDir) - require.NoError(t, err) - assert.Equal(t, []string{"analytics"}, plugins) -} - -func TestDetectPluginsFromServerNoFile(t *testing.T) { - tempDir := t.TempDir() - plugins, err := DetectPluginsFromServer(tempDir) - require.NoError(t, err) - assert.Nil(t, plugins) -} - -func TestGetPluginDependencies(t *testing.T) { - tests := []struct { - name string - pluginNames []string - expectedDeps []string - }{ - { - name: "analytics plugin", - pluginNames: []string{"analytics"}, - expectedDeps: []string{"sql_warehouse_id"}, - }, - { - name: "unknown plugin", - pluginNames: []string{"server"}, - expectedDeps: nil, - }, - { - name: "empty plugins", - pluginNames: []string{}, - expectedDeps: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - deps := GetPluginDependencies(tt.pluginNames) - if tt.expectedDeps == nil { - assert.Empty(t, deps) - } else { - assert.Len(t, deps, len(tt.expectedDeps)) - for i, dep := range deps { - assert.Equal(t, tt.expectedDeps[i], dep.ID) - } - } - }) - } -} - -func TestHasFeaturesDirectory(t *testing.T) { - // Test with features directory - tempDir := t.TempDir() - require.NoError(t, os.MkdirAll(tempDir+"/features", 0o755)) - assert.True(t, HasFeaturesDirectory(tempDir)) - - // Test without features directory - tempDir2 := t.TempDir() - assert.False(t, HasFeaturesDirectory(tempDir2)) -} - -func TestMapPluginsToFeatures(t *testing.T) { - tests := []struct { - name string - pluginNames []string - expectedFeatures []string - }{ - { - name: "analytics plugin maps to analytics feature", - pluginNames: []string{"analytics"}, - expectedFeatures: []string{"analytics"}, - }, - { - name: "unknown plugin", - pluginNames: []string{"server", "unknown"}, - expectedFeatures: nil, - }, - { - name: "empty plugins", - pluginNames: []string{}, - expectedFeatures: nil, - }, - { - name: "duplicate plugins", - pluginNames: []string{"analytics", "analytics"}, - expectedFeatures: []string{"analytics"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - features := MapPluginsToFeatures(tt.pluginNames) - if tt.expectedFeatures == nil { - assert.Empty(t, features) - } else { - assert.Equal(t, tt.expectedFeatures, features) - } - }) - } -} - -func TestPluginPatternGeneration(t *testing.T) { - // Test that the plugin pattern is dynamically generated from AvailableFeatures - // This ensures new features with PluginImport are automatically detected - - // Get all plugin imports from AvailableFeatures - var expectedPlugins []string - for _, f := range AvailableFeatures { - if f.PluginImport != "" { - expectedPlugins = append(expectedPlugins, f.PluginImport) - } - } - - // Test that each plugin is matched by the pattern - for _, plugin := range expectedPlugins { - testCode := fmt.Sprintf("plugins: [%s()]", plugin) - matches := pluginPattern.FindAllStringSubmatch(testCode, -1) - assert.NotEmpty(t, matches, "Pattern should match plugin: %s", plugin) - assert.Equal(t, plugin, matches[0][1], "Captured group should be plugin name: %s", plugin) - } - - // Test that non-plugin function calls are not matched - testCode := "const x = someOtherFunction()" - matches := pluginPattern.FindAllStringSubmatch(testCode, -1) - assert.Empty(t, matches, "Pattern should not match non-plugin functions") -} diff --git a/libs/apps/generator/generator.go b/libs/apps/generator/generator.go new file mode 100644 index 0000000000..d802f02c94 --- /dev/null +++ b/libs/apps/generator/generator.go @@ -0,0 +1,407 @@ +package generator + +import ( + "fmt" + "regexp" + "strings" + + "github.com/databricks/cli/libs/apps/manifest" +) + +// validEnvVar matches safe environment variable names (letters, digits, underscores, starting with a letter or underscore). +var validEnvVar = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// yamlNeedsQuoting is true when a value contains characters that can break YAML parsing. +var yamlNeedsQuoting = regexp.MustCompile(`[:#\[\]{}&*!|>'"%@` + "`" + `\n\r\\]|^\s|\s$|^$`) + +// quoteYAMLValue wraps a value in double quotes if it contains YAML-special characters. +func quoteYAMLValue(v string) string { + if yamlNeedsQuoting.MatchString(v) { + escaped := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(v) + return `"` + escaped + `"` + } + return v +} + +// sanitizeEnvValue removes newlines and carriage returns from a .env value +// to prevent injection of additional environment variables. +func sanitizeEnvValue(v string) string { + v = strings.ReplaceAll(v, "\n", "") + v = strings.ReplaceAll(v, "\r", "") + return v +} + +// Config holds configuration values collected from user prompts. +type Config struct { + ProjectName string + WorkspaceHost string + Profile string + // ResourceValues maps resource value keys to values. + // Keys use "resource_key.field_name" format (e.g., "sql-warehouse.id" -> "abc123"). + ResourceValues map[string]string +} + +// hasResourceValues returns true if any value exists in cfg for the given resource. +func hasResourceValues(r manifest.Resource, cfg Config) bool { + for _, v := range variableNamesForResource(r) { + if cfg.ResourceValues[v.valueKey] != "" { + return true + } + } + return false +} + +// GenerateBundleVariables generates the variables section for databricks.yml. +// Output is indented with 2 spaces for insertion under "variables:". +// Includes both required resources and optional resources that have values. +func GenerateBundleVariables(plugins []manifest.Plugin, cfg Config) string { + var lines []string + + for _, p := range plugins { + for _, r := range p.Resources.Required { + lines = append(lines, generateVariableLines(r)...) + } + for _, r := range p.Resources.Optional { + if hasResourceValues(r, cfg) { + lines = append(lines, generateVariableLines(r)...) + } + } + } + + return strings.Join(lines, "\n") +} + +// generateVariableLines returns the variable definition lines for a resource. +// Multi-field resources (database, secret, genie_space) produce multiple variables. +func generateVariableLines(r manifest.Resource) []string { + var lines []string + for _, v := range variableNamesForResource(r) { + lines = append(lines, fmt.Sprintf(" %s:", v.name)) + if v.description != "" { + lines = append(lines, " description: "+v.description) + } + } + return lines +} + +// varInfo holds a variable name, its description, and the key used to look up its value in ResourceValues. +type varInfo struct { + name string // variable name in databricks.yml (e.g., "cache_instance_name") + description string + valueKey string // key in Config.ResourceValues (e.g., "cache.instance_name", "warehouse.id") +} + +// GenerateBundleResources generates the resources section for databricks.yml (app resources). +// Output is indented with 8 spaces for insertion under "resources: [...app resources...]". +// Includes both required resources and optional resources that have values. +func GenerateBundleResources(plugins []manifest.Plugin, cfg Config) string { + var blocks []string + + for _, p := range plugins { + // Required resources + for _, r := range p.Resources.Required { + resource := generateResourceYAML(r, 8) + if resource != "" { + blocks = append(blocks, resource) + } + } + // Optional resources (only if value provided) + for _, r := range p.Resources.Optional { + if hasResourceValues(r, cfg) { + resource := generateResourceYAML(r, 8) + if resource != "" { + blocks = append(blocks, resource) + } + } + } + } + + return strings.Join(blocks, "\n") +} + +// GenerateTargetVariables generates the dev target variables section for databricks.yml. +// Output is indented with 6 spaces for insertion under "targets: default: variables:". +// Includes both required resources and optional resources that have values. +func GenerateTargetVariables(plugins []manifest.Plugin, cfg Config) string { + var lines []string + + for _, p := range plugins { + for _, r := range p.Resources.Required { + lines = append(lines, generateTargetVarLines(r, cfg)...) + } + for _, r := range p.Resources.Optional { + if hasResourceValues(r, cfg) { + lines = append(lines, generateTargetVarLines(r, cfg)...) + } + } + } + + return strings.Join(lines, "\n") +} + +// generateTargetVarLines returns the target variable assignment lines for a resource. +func generateTargetVarLines(r manifest.Resource, cfg Config) []string { + var lines []string + for _, v := range variableNamesForResource(r) { + value := cfg.ResourceValues[v.valueKey] + if value != "" { + lines = append(lines, fmt.Sprintf(" %s: %s", v.name, quoteYAMLValue(value))) + } + } + return lines +} + +// dotEnvActualLines returns .env lines with actual values from cfg. +// Fields with invalid env var names are skipped. Values are sanitized to prevent injection. +func dotEnvActualLines(r manifest.Resource, cfg Config) []string { + var lines []string + for _, fieldName := range r.FieldNames() { + field := r.Fields[fieldName] + if field.Env == "" || !validEnvVar.MatchString(field.Env) { + continue + } + value := sanitizeEnvValue(cfg.ResourceValues[r.Key()+"."+fieldName]) + lines = append(lines, fmt.Sprintf("%s=%s", field.Env, value)) + } + return lines +} + +// dotEnvExampleLines returns .env.example lines with placeholders. +// Fields with invalid env var names are skipped. +func dotEnvExampleLines(r manifest.Resource, commented bool) []string { + var lines []string + for _, fieldName := range r.FieldNames() { + field := r.Fields[fieldName] + if field.Env == "" || !validEnvVar.MatchString(field.Env) { + continue + } + placeholder := "your_" + r.VarPrefix() + "_" + fieldName + if commented { + lines = append(lines, fmt.Sprintf("# %s=%s", field.Env, placeholder)) + } else { + lines = append(lines, fmt.Sprintf("%s=%s", field.Env, placeholder)) + } + } + return lines +} + +// GenerateDotEnv generates the .env file content with actual values. +// Includes both required resources and optional resources that have values. +func GenerateDotEnv(plugins []manifest.Plugin, cfg Config) string { + var lines []string + + for _, p := range plugins { + for _, r := range p.Resources.Required { + lines = append(lines, dotEnvActualLines(r, cfg)...) + } + for _, r := range p.Resources.Optional { + if hasResourceValues(r, cfg) { + lines = append(lines, dotEnvActualLines(r, cfg)...) + } + } + } + + return strings.Join(lines, "\n") +} + +// GenerateDotEnvExample generates the .env.example file content with placeholders. +// Includes both required and optional resources (optional ones are commented out). +func GenerateDotEnvExample(plugins []manifest.Plugin) string { + var lines []string + + for _, p := range plugins { + for _, r := range p.Resources.Required { + lines = append(lines, dotEnvExampleLines(r, false)...) + } + for _, r := range p.Resources.Optional { + lines = append(lines, dotEnvExampleLines(r, true)...) + } + } + + return strings.Join(lines, "\n") +} + +// appResourceSpec defines how a manifest resource type maps to DABs AppResource YAML. +type appResourceSpec struct { + yamlKey string // DABs YAML key under the resource entry (e.g., "sql_warehouse", "uc_securable") + varFields [][2]string // {manifestFieldName, yamlFieldName} pairs that generate ${var.xxx} references + staticFields [][2]string // {yamlFieldName, literalValue} pairs for constants + permission string // default permission when the manifest doesn't specify one +} + +// appResourceSpecs maps manifest resource types to their DABs AppResource YAML specification. +var appResourceSpecs = map[string]appResourceSpec{ + "sql_warehouse": { + yamlKey: "sql_warehouse", + varFields: [][2]string{{"id", "id"}}, + permission: "CAN_USE", + }, + "job": { + yamlKey: "job", + varFields: [][2]string{{"id", "id"}}, + permission: "CAN_MANAGE_RUN", + }, + "serving_endpoint": { + yamlKey: "serving_endpoint", + varFields: [][2]string{{"id", "name"}}, + permission: "CAN_QUERY", + }, + "experiment": { + yamlKey: "experiment", + varFields: [][2]string{{"id", "experiment_id"}}, + permission: "CAN_READ", + }, + "secret": { + yamlKey: "secret", + varFields: [][2]string{{"scope", "scope"}, {"key", "key"}}, + permission: "READ", + }, + "database": { + yamlKey: "database", + varFields: [][2]string{{"instance_name", "instance_name"}, {"database_name", "database_name"}}, + permission: "CAN_CONNECT_AND_CREATE", + }, + "genie_space": { + yamlKey: "genie_space", + varFields: [][2]string{{"name", "name"}, {"id", "space_id"}}, + permission: "CAN_VIEW", + }, + "volume": { + yamlKey: "uc_securable", + varFields: [][2]string{{"id", "securable_full_name"}}, + staticFields: [][2]string{{"securable_type", "VOLUME"}}, + permission: "READ_VOLUME", + }, + "uc_function": { + yamlKey: "uc_securable", + varFields: [][2]string{{"id", "securable_full_name"}}, + staticFields: [][2]string{{"securable_type", "FUNCTION"}}, + permission: "EXECUTE", + }, + "uc_connection": { + yamlKey: "uc_securable", + varFields: [][2]string{{"id", "securable_full_name"}}, + staticFields: [][2]string{{"securable_type", "CONNECTION"}}, + permission: "USE_CONNECTION", + }, + "vector_search_index": { + yamlKey: "uc_securable", + varFields: [][2]string{{"id", "securable_full_name"}}, + staticFields: [][2]string{{"securable_type", "TABLE"}}, + permission: "SELECT", + }, + // TODO: uncomment when bundles support app as an app resource type. + // "app": { + // yamlKey: "app", + // varFields: [][2]string{{"id", "name"}}, + // permission: "CAN_USE", + // }, +} + +// varNameForField returns the bundle variable name for a specific field of a resource. +// Uses VarPrefix (resource_key with hyphens replaced by underscores). +func varNameForField(r manifest.Resource, fieldName string) string { + return r.VarPrefix() + "_" + fieldName +} + +// variableNamesForResource returns the variable names that a resource type needs. +// It merges manifest Fields with spec varFields so that fields required by the +// DABs YAML (e.g., genie_space name) are included even when the manifest doesn't +// declare them. Manifest Fields take precedence for descriptions. +func variableNamesForResource(r manifest.Resource) []varInfo { + var vars []varInfo + covered := make(map[string]bool) + + for _, fieldName := range r.FieldNames() { + field := r.Fields[fieldName] + desc := field.Description + if desc == "" { + desc = r.Description + } + vars = append(vars, varInfo{ + name: varNameForField(r, fieldName), + description: desc, + valueKey: r.Key() + "." + fieldName, + }) + covered[fieldName] = true + } + + // Include spec varFields not already covered by manifest Fields. + if spec, ok := appResourceSpecs[r.Type]; ok { + for _, f := range spec.varFields { + if !covered[f[0]] { + vars = append(vars, varInfo{ + name: varNameForField(r, f[0]), + description: r.Description, + valueKey: r.Key() + "." + f[0], + }) + } + } + } + + if len(vars) > 0 { + return vars + } + // Fallback for resources without explicit Fields and no spec. + // Uses "key.id" to stay consistent with the composite key convention. + return []varInfo{ + {name: aliasToVarName(r.VarPrefix()), description: r.Description, valueKey: r.Key() + ".id"}, + } +} + +// generateResourceYAML generates YAML for a single app resource based on its type. +// Uses the appResourceSpecs mapping to produce the correct DABs AppResource structure. +func generateResourceYAML(r manifest.Resource, indent int) string { + spec, ok := appResourceSpecs[r.Type] + if !ok { + return "" + } + + permission := r.Permission + if permission == "" { + permission = spec.permission + } + + pad := strings.Repeat(" ", indent) + + var lines []string + lines = append(lines, fmt.Sprintf("%s- name: %s", pad, r.Key())) + lines = append(lines, fmt.Sprintf("%s %s:", pad, spec.yamlKey)) + + for _, f := range spec.varFields { + manifestField, yamlField := f[0], f[1] + lines = append(lines, fmt.Sprintf("%s %s: ${var.%s}", pad, yamlField, varNameForField(r, manifestField))) + } + for _, sf := range spec.staticFields { + yamlField, value := sf[0], sf[1] + lines = append(lines, fmt.Sprintf("%s %s: %s", pad, yamlField, value)) + } + + lines = append(lines, fmt.Sprintf("%s permission: %s", pad, permission)) + return strings.Join(lines, "\n") +} + +// aliasToVarName converts a variable prefix to a variable name by appending "_id". +// e.g., "sql_warehouse" -> "sql_warehouse_id" +func aliasToVarName(prefix string) string { + if strings.HasSuffix(prefix, "_id") { + return prefix + } + return prefix + "_id" +} + +// GetSelectedPlugins returns plugins that match the given names. +func GetSelectedPlugins(m *manifest.Manifest, names []string) []manifest.Plugin { + nameSet := make(map[string]bool) + for _, n := range names { + nameSet[n] = true + } + + var selected []manifest.Plugin + for _, p := range m.GetPlugins() { + if nameSet[p.Name] { + selected = append(selected, p) + } + } + return selected +} diff --git a/libs/apps/generator/generator_test.go b/libs/apps/generator/generator_test.go new file mode 100644 index 0000000000..106c081a28 --- /dev/null +++ b/libs/apps/generator/generator_test.go @@ -0,0 +1,865 @@ +package generator_test + +import ( + "testing" + + "github.com/databricks/cli/libs/apps/generator" + "github.com/databricks/cli/libs/apps/manifest" + "github.com/stretchr/testify/assert" +) + +func TestGenerateBundleVariables(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "analytics", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "warehouse", Description: "SQL Warehouse for queries"}, + }, + }, + }, + } + + cfg := generator.Config{ + ProjectName: "test-app", + ResourceValues: map[string]string{"warehouse": "abc123"}, + } + + result := generator.GenerateBundleVariables(plugins, cfg) + assert.Contains(t, result, " warehouse_id:") + assert.Contains(t, result, " description: SQL Warehouse for queries") +} + +func TestGenerateBundleResources(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "analytics", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "warehouse", Permission: "CAN_USE"}, + }, + }, + }, + } + + cfg := generator.Config{ + ProjectName: "test-app", + ResourceValues: map[string]string{"warehouse": "abc123"}, + } + + result := generator.GenerateBundleResources(plugins, cfg) + assert.Contains(t, result, " - name: warehouse") + assert.Contains(t, result, " sql_warehouse:") + assert.Contains(t, result, " id: ${var.warehouse_id}") + assert.Contains(t, result, " permission: CAN_USE") +} + +func TestGenerateBundleResourcesDefaultPermission(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "analytics", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "warehouse"}, + }, + }, + }, + } + + cfg := generator.Config{ + ProjectName: "test-app", + ResourceValues: map[string]string{"warehouse": "abc123"}, + } + + result := generator.GenerateBundleResources(plugins, cfg) + assert.Contains(t, result, " permission: CAN_USE") +} + +func TestGenerateTargetVariables(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "analytics", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "warehouse"}, + }, + }, + }, + } + + cfg := generator.Config{ + ProjectName: "test-app", + ResourceValues: map[string]string{"warehouse.id": "abc123"}, + } + + result := generator.GenerateTargetVariables(plugins, cfg) + assert.Contains(t, result, " warehouse_id: abc123") +} + +func TestGenerateDotEnv(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "analytics", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + { + Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "warehouse", + Fields: map[string]manifest.ResourceField{ + "id": {Env: "DATABRICKS_WAREHOUSE_ID"}, + }, + }, + }, + }, + }, + } + + cfg := generator.Config{ + ProjectName: "test-app", + ResourceValues: map[string]string{"warehouse.id": "abc123"}, + } + + result := generator.GenerateDotEnv(plugins, cfg) + assert.Equal(t, "DATABRICKS_WAREHOUSE_ID=abc123", result) +} + +func TestGenerateDotEnvExample(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "analytics", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + { + Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "warehouse", + Fields: map[string]manifest.ResourceField{ + "id": {Env: "DATABRICKS_WAREHOUSE_ID"}, + }, + }, + }, + }, + }, + } + + result := generator.GenerateDotEnvExample(plugins) + assert.Equal(t, "DATABRICKS_WAREHOUSE_ID=your_warehouse_id", result) +} + +func TestGenerateEmptyPlugins(t *testing.T) { + var plugins []manifest.Plugin + cfg := generator.Config{ + ProjectName: "test-app", + } + + assert.Empty(t, generator.GenerateBundleVariables(plugins, cfg)) + assert.Empty(t, generator.GenerateBundleResources(plugins, cfg)) + assert.Empty(t, generator.GenerateTargetVariables(plugins, cfg)) + assert.Empty(t, generator.GenerateDotEnv(plugins, cfg)) + assert.Empty(t, generator.GenerateDotEnvExample(plugins)) +} + +func TestGenerateUnknownResourceType(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "unknown", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "unknown_type", Alias: "Unknown", ResourceKey: "foo"}, + }, + }, + }, + } + + cfg := generator.Config{ + ProjectName: "test-app", + } + + // Unknown resource types produce no resource block + result := generator.GenerateBundleResources(plugins, cfg) + assert.Empty(t, result) + + // Variables are still generated + result = generator.GenerateBundleVariables(plugins, cfg) + assert.Contains(t, result, " foo_id:") +} + +func TestGenerateEmptyResourceType(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "empty", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "", Alias: "Foo", ResourceKey: "foo"}, + }, + }, + }, + } + + cfg := generator.Config{ + ProjectName: "test-app", + } + + // Empty type generates no resource block + result := generator.GenerateBundleResources(plugins, cfg) + assert.Empty(t, result) +} + +func TestGenerateBundleResourcesDatabaseType(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "caching", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "sql-warehouse", Permission: "CAN_USE"}, + {Type: "database", Alias: "Database", ResourceKey: "database", Permission: "CAN_CONNECT_AND_CREATE"}, + }, + }, + }, + } + + cfg := generator.Config{ + ProjectName: "test-app", + ResourceValues: map[string]string{"sql-warehouse": "wh123", "database": "some-id"}, + } + + result := generator.GenerateBundleResources(plugins, cfg) + assert.Contains(t, result, "- name: sql-warehouse") + assert.Contains(t, result, "sql_warehouse:") + assert.Contains(t, result, "id: ${var.sql_warehouse_id}") + assert.Contains(t, result, "- name: database") + assert.Contains(t, result, "database:") + assert.Contains(t, result, "instance_name: ${var.database_instance_name}") + assert.Contains(t, result, "database_name: ${var.database_database_name}") + assert.Contains(t, result, "permission: CAN_CONNECT_AND_CREATE") +} + +func TestGenerateBundleResourcesDefaultPermissions(t *testing.T) { + tests := []struct { + resourceType string + expectedPermission string + }{ + {"sql_warehouse", "CAN_USE"}, + {"job", "CAN_MANAGE_RUN"}, + {"serving_endpoint", "CAN_QUERY"}, + {"secret", "READ"}, + {"experiment", "CAN_READ"}, + {"database", "CAN_CONNECT_AND_CREATE"}, + {"volume", "READ_VOLUME"}, + {"uc_function", "EXECUTE"}, + {"uc_connection", "USE_CONNECTION"}, + {"genie_space", "CAN_VIEW"}, + {"vector_search_index", "SELECT"}, + // TODO: uncomment when bundles support app as an app resource type. + // {"app", "CAN_USE"}, + } + + for _, tt := range tests { + t.Run(tt.resourceType, func(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "test", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: tt.resourceType, Alias: "Resource", ResourceKey: "res"}, + }, + }, + }, + } + cfg := generator.Config{ResourceValues: map[string]string{"res": "id1"}} + result := generator.GenerateBundleResources(plugins, cfg) + assert.Contains(t, result, "permission: "+tt.expectedPermission) + }) + } +} + +func TestGetSelectedPlugins(t *testing.T) { + m := &manifest.Manifest{ + Plugins: map[string]manifest.Plugin{ + "analytics": {Name: "analytics", DisplayName: "Analytics"}, + "server": {Name: "server", DisplayName: "Server"}, + "auth": {Name: "auth", DisplayName: "Auth"}, + }, + } + + selected := generator.GetSelectedPlugins(m, []string{"analytics", "auth"}) + assert.Len(t, selected, 2) + + names := make([]string, len(selected)) + for i, p := range selected { + names[i] = p.Name + } + assert.Contains(t, names, "analytics") + assert.Contains(t, names, "auth") +} + +func TestGenerateWithOptionalResources(t *testing.T) { + whFields := map[string]manifest.ResourceField{"id": {Env: "DATABRICKS_WAREHOUSE_ID"}} + secFields := map[string]manifest.ResourceField{"id": {Env: "SECONDARY_WAREHOUSE_ID"}} + + plugins := []manifest.Plugin{ + { + Name: "analytics", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "warehouse", Description: "Main warehouse", Fields: whFields}, + }, + Optional: []manifest.Resource{ + {Type: "sql_warehouse", Alias: "Secondary Warehouse", ResourceKey: "secondary_warehouse", Description: "Secondary warehouse", Fields: secFields}, + }, + }, + }, + } + + // Config with only required resource + cfgRequiredOnly := generator.Config{ + ProjectName: "test-app", + ResourceValues: map[string]string{"warehouse.id": "wh123"}, + } + + // Config with both required and optional resources + cfgWithOptional := generator.Config{ + ProjectName: "test-app", + ResourceValues: map[string]string{"warehouse.id": "wh123", "secondary_warehouse.id": "wh456"}, + } + + // Test bundle variables - required only + result := generator.GenerateBundleVariables(plugins, cfgRequiredOnly) + assert.Contains(t, result, " warehouse_id:") + assert.NotContains(t, result, "secondary_warehouse_id") + + // Test bundle variables - with optional + result = generator.GenerateBundleVariables(plugins, cfgWithOptional) + assert.Contains(t, result, " warehouse_id:") + assert.Contains(t, result, " secondary_warehouse_id:") + + // Test bundle resources - required only + result = generator.GenerateBundleResources(plugins, cfgRequiredOnly) + assert.Contains(t, result, "- name: warehouse") + assert.NotContains(t, result, "secondary_warehouse") + + // Test bundle resources - with optional + result = generator.GenerateBundleResources(plugins, cfgWithOptional) + assert.Contains(t, result, "- name: warehouse") + assert.Contains(t, result, "- name: secondary_warehouse") + + // Test target variables - required only + result = generator.GenerateTargetVariables(plugins, cfgRequiredOnly) + assert.Contains(t, result, " warehouse_id: wh123") + assert.NotContains(t, result, "secondary_warehouse_id") + + // Test target variables - with optional + result = generator.GenerateTargetVariables(plugins, cfgWithOptional) + assert.Contains(t, result, " warehouse_id: wh123") + assert.Contains(t, result, " secondary_warehouse_id: wh456") + + // Test .env - required only + result = generator.GenerateDotEnv(plugins, cfgRequiredOnly) + assert.NotContains(t, result, "SECONDARY_WAREHOUSE_ID") + + // Test .env - with optional + result = generator.GenerateDotEnv(plugins, cfgWithOptional) + assert.Contains(t, result, "SECONDARY_WAREHOUSE_ID=wh456") +} + +func TestGenerateResourceYAMLAllTypes(t *testing.T) { + tests := []struct { + name string + resource manifest.Resource + expectContains []string + expectNotContain []string + }{ + { + name: "sql_warehouse uses id field", + resource: manifest.Resource{Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "wh", Permission: "CAN_USE"}, + expectContains: []string{ + "- name: wh", + "sql_warehouse:", + "id: ${var.wh_id}", + "permission: CAN_USE", + }, + }, + { + name: "job uses id field", + resource: manifest.Resource{Type: "job", Alias: "Job", ResourceKey: "myjob", Permission: "CAN_MANAGE_RUN"}, + expectContains: []string{ + "- name: myjob", + "job:", + "id: ${var.myjob_id}", + "permission: CAN_MANAGE_RUN", + }, + }, + { + name: "serving_endpoint uses name field", + resource: manifest.Resource{Type: "serving_endpoint", Alias: "Model Endpoint", ResourceKey: "endpoint", Permission: "CAN_QUERY"}, + expectContains: []string{ + "- name: endpoint", + "serving_endpoint:", + "name: ${var.endpoint_id}", + "permission: CAN_QUERY", + }, + }, + { + name: "experiment uses experiment_id field", + resource: manifest.Resource{Type: "experiment", Alias: "Experiment", ResourceKey: "exp", Permission: "CAN_READ"}, + expectContains: []string{ + "- name: exp", + "experiment:", + "experiment_id: ${var.exp_id}", + "permission: CAN_READ", + }, + }, + { + name: "secret uses scope and key fields", + resource: manifest.Resource{Type: "secret", Alias: "Secret", ResourceKey: "creds", Permission: "READ"}, + expectContains: []string{ + "- name: creds", + "secret:", + "scope: ${var.creds_scope}", + "key: ${var.creds_key}", + "permission: READ", + }, + expectNotContain: []string{"id:"}, + }, + { + name: "database uses instance_name and database_name fields", + resource: manifest.Resource{Type: "database", Alias: "Database", ResourceKey: "cache", Permission: "CAN_CONNECT_AND_CREATE"}, + expectContains: []string{ + "- name: cache", + "database:", + "instance_name: ${var.cache_instance_name}", + "database_name: ${var.cache_database_name}", + "permission: CAN_CONNECT_AND_CREATE", + }, + expectNotContain: []string{"id:"}, + }, + { + name: "genie_space uses name and space_id fields", + resource: manifest.Resource{Type: "genie_space", Alias: "Genie Space", ResourceKey: "genie-space", Permission: "CAN_VIEW"}, + expectContains: []string{ + "- name: genie-space", + "genie_space:", + "name: ${var.genie_space_name}", + "space_id: ${var.genie_space_id}", + "permission: CAN_VIEW", + }, + }, + { + name: "volume maps to uc_securable", + resource: manifest.Resource{Type: "volume", Alias: "UC Volume", ResourceKey: "vol", Permission: "READ_VOLUME"}, + expectContains: []string{ + "- name: vol", + "uc_securable:", + "securable_full_name: ${var.vol_id}", + "securable_type: VOLUME", + "permission: READ_VOLUME", + }, + expectNotContain: []string{"volume:"}, + }, + { + name: "uc_function maps to uc_securable FUNCTION", + resource: manifest.Resource{Type: "uc_function", Alias: "UC Function", ResourceKey: "func", Permission: "EXECUTE"}, + expectContains: []string{ + "- name: func", + "uc_securable:", + "securable_full_name: ${var.func_id}", + "securable_type: FUNCTION", + "permission: EXECUTE", + }, + }, + { + name: "uc_connection maps to uc_securable CONNECTION", + resource: manifest.Resource{Type: "uc_connection", Alias: "UC Connection", ResourceKey: "conn", Permission: "USE_CONNECTION"}, + expectContains: []string{ + "- name: conn", + "uc_securable:", + "securable_full_name: ${var.conn_id}", + "securable_type: CONNECTION", + "permission: USE_CONNECTION", + }, + }, + { + name: "vector_search_index maps to uc_securable TABLE", + resource: manifest.Resource{Type: "vector_search_index", Alias: "Vector Search Index", ResourceKey: "vector-search-index", Permission: "SELECT"}, + expectContains: []string{ + "- name: vector-search-index", + "uc_securable:", + "securable_full_name: ${var.vector_search_index_id}", + "securable_type: TABLE", + "permission: SELECT", + }, + expectNotContain: []string{"vector_search_index:"}, + }, + // TODO: uncomment when bundles support app as an app resource type. + // { + // name: "app uses name field", + // resource: manifest.Resource{Type: "app", Alias: "Databricks App", ResourceKey: "app", Permission: "CAN_USE"}, + // expectContains: []string{ + // "- name: app", + // "app:", + // "name: ${var.app_id}", + // "permission: CAN_USE", + // }, + // }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugins := []manifest.Plugin{ + {Name: "test", Resources: manifest.Resources{Required: []manifest.Resource{tt.resource}}}, + } + cfg := generator.Config{ResourceValues: map[string]string{tt.resource.ResourceKey: "val"}} + result := generator.GenerateBundleResources(plugins, cfg) + for _, s := range tt.expectContains { + assert.Contains(t, result, s) + } + for _, s := range tt.expectNotContain { + assert.NotContains(t, result, s) + } + }) + } +} + +func TestGenerateMultiFieldVariables(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "test", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + { + Type: "database", Alias: "Database", ResourceKey: "database", Description: "App cache", + Fields: map[string]manifest.ResourceField{ + "instance_name": {Env: "DB_INSTANCE"}, + "database_name": {Env: "DB_NAME"}, + }, + }, + { + Type: "secret", Alias: "Secret", ResourceKey: "secret", Description: "Credentials", + Fields: map[string]manifest.ResourceField{ + "scope": {Env: "SECRET_SCOPE"}, + "key": {Env: "SECRET_KEY"}, + }, + }, + { + Type: "genie_space", Alias: "Genie Space", ResourceKey: "genie-space", Description: "AI assistant", + Fields: map[string]manifest.ResourceField{ + "id": {Env: "GENIE_SPACE_ID"}, + }, + }, + { + Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "sql-warehouse", Description: "Warehouse", + Fields: map[string]manifest.ResourceField{ + "id": {Env: "WH_ID"}, + }, + }, + }, + }, + }, + } + cfg := generator.Config{ResourceValues: map[string]string{ + "database.instance_name": "val", "database.database_name": "val", + "secret.scope": "val", "secret.key": "val", + "genie-space.id": "val", "sql-warehouse.id": "val", + }} + + vars := generator.GenerateBundleVariables(plugins, cfg) + // database produces two variables + assert.Contains(t, vars, "database_instance_name:") + assert.Contains(t, vars, "database_database_name:") + assert.NotContains(t, vars, "database_id:") + + // secret produces two variables + assert.Contains(t, vars, "secret_scope:") + assert.Contains(t, vars, "secret_key:") + assert.NotContains(t, vars, "secret_id:") + + // genie_space produces two variables: id from manifest, name from spec + assert.Contains(t, vars, "genie_space_id:") + assert.Contains(t, vars, "genie_space_name:") + + // sql_warehouse produces one variable + assert.Contains(t, vars, "sql_warehouse_id:") +} + +func TestGenerateTargetVariablesMultiField(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "test", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + { + Type: "database", Alias: "Database", ResourceKey: "database", + Fields: map[string]manifest.ResourceField{ + "instance_name": {Env: "DB_INSTANCE"}, + "database_name": {Env: "DB_NAME"}, + }, + }, + { + Type: "secret", Alias: "Secret", ResourceKey: "secret", + Fields: map[string]manifest.ResourceField{ + "scope": {Env: "SECRET_SCOPE"}, + "key": {Env: "SECRET_KEY"}, + }, + }, + }, + }, + }, + } + cfg := generator.Config{ResourceValues: map[string]string{ + "database.instance_name": "my-instance", + "database.database_name": "my-db", + "secret.scope": "my-scope", + "secret.key": "my-key", + }} + + result := generator.GenerateTargetVariables(plugins, cfg) + assert.Contains(t, result, "database_instance_name: my-instance") + assert.Contains(t, result, "database_database_name: my-db") + assert.Contains(t, result, "secret_scope: my-scope") + assert.Contains(t, result, "secret_key: my-key") +} + +func TestGenerateWithExplicitFields(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "caching", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + { + Type: "database", Alias: "Database", ResourceKey: "database", Permission: "CAN_CONNECT_AND_CREATE", + Fields: map[string]manifest.ResourceField{ + "instance_name": {Env: "DB_INSTANCE", Description: "Lakebase instance"}, + "database_name": {Env: "DB_NAME", Description: "Database name"}, + }, + }, + }, + }, + }, + } + cfg := generator.Config{ResourceValues: map[string]string{ + "database.instance_name": "my-inst", + "database.database_name": "my-db", + }} + + // Variables use Fields descriptions + vars := generator.GenerateBundleVariables(plugins, cfg) + assert.Contains(t, vars, "database_database_name:") + assert.Contains(t, vars, " description: Database name") + assert.Contains(t, vars, "database_instance_name:") + assert.Contains(t, vars, " description: Lakebase instance") + + // Target vars use composite keys + target := generator.GenerateTargetVariables(plugins, cfg) + assert.Contains(t, target, "database_instance_name: my-inst") + assert.Contains(t, target, "database_database_name: my-db") + + // .env uses field-level env vars + env := generator.GenerateDotEnv(plugins, cfg) + assert.Contains(t, env, "DB_NAME=my-db") + assert.Contains(t, env, "DB_INSTANCE=my-inst") + + // .env.example uses field-level placeholders + example := generator.GenerateDotEnvExample(plugins) + assert.Contains(t, example, "DB_NAME=your_database_database_name") + assert.Contains(t, example, "DB_INSTANCE=your_database_instance_name") +} + +func TestGenerateFieldsDotEnvSecret(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "auth", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + { + Type: "secret", Alias: "Secret", ResourceKey: "secret", Permission: "READ", + Fields: map[string]manifest.ResourceField{ + "scope": {Env: "SECRET_SCOPE", Description: "Scope name"}, + "key": {Env: "SECRET_KEY", Description: "Key name"}, + }, + }, + }, + }, + }, + } + cfg := generator.Config{ResourceValues: map[string]string{ + "secret.scope": "my-scope", + "secret.key": "my-key", + }} + + env := generator.GenerateDotEnv(plugins, cfg) + assert.Contains(t, env, "SECRET_KEY=my-key") + assert.Contains(t, env, "SECRET_SCOPE=my-scope") +} + +func TestGenerateOptionalMultiFieldResource(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "test", + Resources: manifest.Resources{ + Optional: []manifest.Resource{ + { + Type: "database", Alias: "Database", ResourceKey: "database", Permission: "CAN_CONNECT_AND_CREATE", + Fields: map[string]manifest.ResourceField{ + "instance_name": {Env: "DB_INSTANCE"}, + "database_name": {Env: "DB_NAME"}, + }, + }, + }, + }, + }, + } + + // No values → optional resource is excluded + cfgEmpty := generator.Config{ResourceValues: map[string]string{}} + assert.Empty(t, generator.GenerateBundleVariables(plugins, cfgEmpty)) + assert.Empty(t, generator.GenerateBundleResources(plugins, cfgEmpty)) + assert.Empty(t, generator.GenerateTargetVariables(plugins, cfgEmpty)) + assert.Empty(t, generator.GenerateDotEnv(plugins, cfgEmpty)) + + // With values → optional resource is included + cfgFilled := generator.Config{ResourceValues: map[string]string{ + "database.instance_name": "inst", + "database.database_name": "db", + }} + assert.Contains(t, generator.GenerateBundleVariables(plugins, cfgFilled), "database_instance_name:") + assert.Contains(t, generator.GenerateBundleResources(plugins, cfgFilled), "database:") + assert.Contains(t, generator.GenerateTargetVariables(plugins, cfgFilled), "database_instance_name: inst") + assert.Contains(t, generator.GenerateDotEnv(plugins, cfgFilled), "DB_INSTANCE=inst") +} + +func TestGenerateDotEnvExampleWithOptional(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "analytics", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + { + Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "warehouse", + Fields: map[string]manifest.ResourceField{"id": {Env: "DATABRICKS_WAREHOUSE_ID"}}, + }, + }, + Optional: []manifest.Resource{ + { + Type: "sql_warehouse", Alias: "Secondary Warehouse", ResourceKey: "secondary", + Fields: map[string]manifest.ResourceField{"id": {Env: "SECONDARY_WAREHOUSE_ID"}}, + }, + }, + }, + }, + } + + result := generator.GenerateDotEnvExample(plugins) + // Required resources are shown normally + assert.Contains(t, result, "DATABRICKS_WAREHOUSE_ID=your_warehouse_id") + // Optional resources are commented out + assert.Contains(t, result, "# SECONDARY_WAREHOUSE_ID=your_secondary_id") +} + +func TestGenerateDotEnvSkipsInvalidEnvNames(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "evil", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + { + Type: "sql_warehouse", Alias: "Warehouse", ResourceKey: "warehouse", + Fields: map[string]manifest.ResourceField{ + "id": {Env: "VALID_VAR"}, + }, + }, + { + Type: "job", Alias: "Job", ResourceKey: "job", + Fields: map[string]manifest.ResourceField{ + "id": {Env: "INJECTED\nEVIL_VAR"}, + }, + }, + { + Type: "secret", Alias: "Secret", ResourceKey: "secret", + Fields: map[string]manifest.ResourceField{ + "scope": {Env: "PATH"}, + "key": {Env: "123INVALID"}, + }, + }, + }, + }, + }, + } + cfg := generator.Config{ResourceValues: map[string]string{ + "warehouse.id": "wh1", + "job.id": "j1", + "secret.scope": "s", + "secret.key": "k", + }} + + dotenv := generator.GenerateDotEnv(plugins, cfg) + assert.Contains(t, dotenv, "VALID_VAR=wh1") + assert.Contains(t, dotenv, "PATH=s") + assert.NotContains(t, dotenv, "INJECTED") + assert.NotContains(t, dotenv, "EVIL_VAR") + assert.NotContains(t, dotenv, "123INVALID") + + example := generator.GenerateDotEnvExample(plugins) + assert.Contains(t, example, "VALID_VAR=your_warehouse_id") + assert.Contains(t, example, "PATH=your_secret_scope") + assert.NotContains(t, example, "INJECTED") + assert.NotContains(t, example, "123INVALID") +} + +func TestGenerateTargetVariablesQuotesSpecialChars(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "test", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + { + Type: "sql_warehouse", Alias: "Warehouse", ResourceKey: "wh", + Fields: map[string]manifest.ResourceField{ + "id": {Env: "WH_ID"}, + }, + }, + }, + }, + }, + } + + tests := []struct { + name string + value string + expected string + }{ + {"plain value", "abc123", " wh_id: abc123"}, + {"value with colon", "host:port", ` wh_id: "host:port"`}, + {"value with hash", "val#comment", ` wh_id: "val#comment"`}, + {"value with leading space", " leading", ` wh_id: " leading"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := generator.Config{ResourceValues: map[string]string{"wh.id": tt.value}} + result := generator.GenerateTargetVariables(plugins, cfg) + assert.Contains(t, result, tt.expected) + }) + } +} + +func TestGenerateDotEnvSanitizesNewlines(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "test", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + { + Type: "sql_warehouse", Alias: "Warehouse", ResourceKey: "wh", + Fields: map[string]manifest.ResourceField{ + "id": {Env: "WH_ID"}, + }, + }, + }, + }, + }, + } + cfg := generator.Config{ResourceValues: map[string]string{ + "wh.id": "safe\nEVIL_VAR=injected", + }} + + result := generator.GenerateDotEnv(plugins, cfg) + assert.Equal(t, "WH_ID=safeEVIL_VAR=injected", result) + assert.NotContains(t, result, "\n") +} diff --git a/libs/apps/manifest/manifest.go b/libs/apps/manifest/manifest.go new file mode 100644 index 0000000000..b0eccebc9d --- /dev/null +++ b/libs/apps/manifest/manifest.go @@ -0,0 +1,232 @@ +package manifest + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +const ManifestFileName = "appkit.plugins.json" + +// ResourceField describes a single field within a multi-field resource. +// Multi-field resources (e.g., database, secret) need separate env vars and values per field. +type ResourceField struct { + Env string `json:"env"` + Description string `json:"description"` +} + +// Resource defines a Databricks resource required or optional for a plugin. +type Resource struct { + Type string `json:"type"` // e.g., "sql_warehouse" + Alias string `json:"alias"` // display name, e.g., "SQL Warehouse" + ResourceKey string `json:"resourceKey"` // machine key for config/env, e.g., "sql-warehouse" + Description string `json:"description"` // e.g., "SQL Warehouse for executing analytics queries" + Permission string `json:"permission"` // e.g., "CAN_USE" + Fields map[string]ResourceField `json:"fields"` // field definitions with env var mappings +} + +// Key returns the resource key for machine use (config keys, variable naming). +func (r Resource) Key() string { + return r.ResourceKey +} + +// VarPrefix returns the variable name prefix derived from the resource key. +// Hyphens are replaced with underscores for YAML variable name compatibility. +func (r Resource) VarPrefix() string { + return strings.ReplaceAll(r.Key(), "-", "_") +} + +// HasFields returns true if the resource has explicit field definitions. +func (r Resource) HasFields() bool { + return len(r.Fields) > 0 +} + +// FieldNames returns the field names in sorted order for deterministic iteration. +func (r Resource) FieldNames() []string { + names := make([]string, 0, len(r.Fields)) + for k := range r.Fields { + names = append(names, k) + } + sort.Strings(names) + return names +} + +// Resources defines the required and optional resources for a plugin. +type Resources struct { + Required []Resource `json:"required"` + Optional []Resource `json:"optional"` +} + +// Plugin represents a plugin defined in the manifest. +type Plugin struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Package string `json:"package"` + RequiredByTemplate bool `json:"requiredByTemplate"` + Resources Resources `json:"resources"` +} + +// Manifest represents the appkit.plugins.json file structure. +type Manifest struct { + Schema string `json:"$schema"` + Version string `json:"version"` + Plugins map[string]Plugin `json:"plugins"` +} + +// Load reads and parses the appkit.plugins.json manifest from the template directory. +func Load(templateDir string) (*Manifest, error) { + path := filepath.Join(templateDir, ManifestFileName) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("manifest file not found: %s", path) + } + return nil, fmt.Errorf("read manifest: %w", err) + } + + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parse manifest: %w", err) + } + + return &m, nil +} + +// HasManifest checks if the template directory contains an appkit.plugins.json file. +func HasManifest(templateDir string) bool { + path := filepath.Join(templateDir, ManifestFileName) + _, err := os.Stat(path) + return err == nil +} + +// GetPlugins returns all plugins from the manifest sorted by name. +// The plugin name is taken from the map key if not specified in the plugin object. +func (m *Manifest) GetPlugins() []Plugin { + plugins := make([]Plugin, 0, len(m.Plugins)) + for name, p := range m.Plugins { + if p.Name == "" { + p.Name = name + } + plugins = append(plugins, p) + } + sort.Slice(plugins, func(i, j int) bool { + return plugins[i].Name < plugins[j].Name + }) + return plugins +} + +// GetSelectablePlugins returns plugins the user can choose during init. +// Excludes mandatory plugins (they are always included automatically). +func (m *Manifest) GetSelectablePlugins() []Plugin { + var selectable []Plugin + for _, p := range m.GetPlugins() { + if !p.RequiredByTemplate { + selectable = append(selectable, p) + } + } + return selectable +} + +// GetMandatoryPlugins returns plugins marked as requiredByTemplate. +func (m *Manifest) GetMandatoryPlugins() []Plugin { + var mandatory []Plugin + for _, p := range m.GetPlugins() { + if p.RequiredByTemplate { + mandatory = append(mandatory, p) + } + } + return mandatory +} + +// GetMandatoryPluginNames returns the names of all mandatory plugins. +func (m *Manifest) GetMandatoryPluginNames() []string { + var names []string + for _, p := range m.GetMandatoryPlugins() { + names = append(names, p.Name) + } + return names +} + +// GetPluginByName returns a plugin by its name, or nil if not found. +func (m *Manifest) GetPluginByName(name string) *Plugin { + if p, ok := m.Plugins[name]; ok { + return &p + } + return nil +} + +// GetPluginNames returns a list of all plugin names. +func (m *Manifest) GetPluginNames() []string { + names := make([]string, 0, len(m.Plugins)) + for name := range m.Plugins { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// ValidatePluginNames checks that all provided plugin names exist in the manifest. +func (m *Manifest) ValidatePluginNames(names []string) error { + for _, name := range names { + if _, ok := m.Plugins[name]; !ok { + return fmt.Errorf("unknown plugin: %q; available: %v", name, m.GetPluginNames()) + } + } + return nil +} + +// CollectResources returns all required resources for the given plugin names. +func (m *Manifest) CollectResources(pluginNames []string) []Resource { + seen := make(map[string]bool) + var resources []Resource + + for _, name := range pluginNames { + plugin := m.GetPluginByName(name) + if plugin == nil { + continue + } + for _, r := range plugin.Resources.Required { + // TODO: remove skip when bundles support app as an app resource type. + if r.Type == "app" { + continue + } + key := r.Type + ":" + r.Key() + if !seen[key] { + seen[key] = true + resources = append(resources, r) + } + } + } + + return resources +} + +// CollectOptionalResources returns all optional resources for the given plugin names. +func (m *Manifest) CollectOptionalResources(pluginNames []string) []Resource { + seen := make(map[string]bool) + var resources []Resource + + for _, name := range pluginNames { + plugin := m.GetPluginByName(name) + if plugin == nil { + continue + } + for _, r := range plugin.Resources.Optional { + // TODO: remove skip when bundles support app as an app resource type. + if r.Type == "app" { + continue + } + key := r.Type + ":" + r.Key() + if !seen[key] { + seen[key] = true + resources = append(resources, r) + } + } + } + + return resources +} diff --git a/libs/apps/manifest/manifest_test.go b/libs/apps/manifest/manifest_test.go new file mode 100644 index 0000000000..5a1c4f8212 --- /dev/null +++ b/libs/apps/manifest/manifest_test.go @@ -0,0 +1,340 @@ +package manifest_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/databricks/cli/libs/apps/manifest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoad(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, manifest.ManifestFileName) + + content := `{ + "$schema": "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + "version": "1.0", + "plugins": { + "analytics": { + "name": "analytics", + "displayName": "Analytics Plugin", + "description": "SQL query execution", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "sql_warehouse", + "alias": "SQL Warehouse", + "resourceKey": "sql-warehouse", + "description": "SQL Warehouse", + "permission": "CAN_USE", + "fields": { + "id": {"env": "DATABRICKS_WAREHOUSE_ID", "description": "SQL Warehouse ID"} + } + } + ], + "optional": [] + } + }, + "server": { + "name": "server", + "displayName": "Server Plugin", + "description": "HTTP server", + "package": "@databricks/appkit", + "requiredByTemplate": true, + "resources": { + "required": [], + "optional": [] + } + } + } + }` + + err := os.WriteFile(manifestPath, []byte(content), 0o644) + require.NoError(t, err) + + m, err := manifest.Load(dir) + require.NoError(t, err) + assert.Equal(t, "1.0", m.Version) + assert.Len(t, m.Plugins, 2) + assert.True(t, m.Plugins["server"].RequiredByTemplate) + assert.False(t, m.Plugins["analytics"].RequiredByTemplate) +} + +func TestLoadNotFound(t *testing.T) { + dir := t.TempDir() + _, err := manifest.Load(dir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "manifest file not found") +} + +func TestLoadInvalidJSON(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, manifest.ManifestFileName) + + err := os.WriteFile(manifestPath, []byte("invalid json"), 0o644) + require.NoError(t, err) + + _, err = manifest.Load(dir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "parse manifest") +} + +func TestHasManifest(t *testing.T) { + dir := t.TempDir() + assert.False(t, manifest.HasManifest(dir)) + + manifestPath := filepath.Join(dir, manifest.ManifestFileName) + err := os.WriteFile(manifestPath, []byte("{}"), 0o644) + require.NoError(t, err) + + assert.True(t, manifest.HasManifest(dir)) +} + +func TestGetPlugins(t *testing.T) { + m := &manifest.Manifest{ + Plugins: map[string]manifest.Plugin{ + "zebra": {Name: "zebra", DisplayName: "Zebra"}, + "alpha": {Name: "alpha", DisplayName: "Alpha"}, + }, + } + + plugins := m.GetPlugins() + require.Len(t, plugins, 2) + assert.Equal(t, "alpha", plugins[0].Name) + assert.Equal(t, "zebra", plugins[1].Name) +} + +func TestGetSelectablePlugins(t *testing.T) { + m := &manifest.Manifest{ + Plugins: map[string]manifest.Plugin{ + "server": { + Name: "server", + RequiredByTemplate: true, + }, + "analytics": { + Name: "analytics", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "sql-warehouse"}, + }, + }, + }, + "optional-plugin": { + Name: "optional-plugin", + }, + }, + } + + selectable := m.GetSelectablePlugins() + require.Len(t, selectable, 2) + assert.Equal(t, "analytics", selectable[0].Name) + assert.Equal(t, "optional-plugin", selectable[1].Name) +} + +func TestGetMandatoryPlugins(t *testing.T) { + m := &manifest.Manifest{ + Plugins: map[string]manifest.Plugin{ + "server": { + Name: "server", + RequiredByTemplate: true, + }, + "analytics": { + Name: "analytics", + }, + "core": { + Name: "core", + RequiredByTemplate: true, + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "sql-warehouse"}, + }, + }, + }, + }, + } + + mandatory := m.GetMandatoryPlugins() + require.Len(t, mandatory, 2) + assert.Equal(t, "core", mandatory[0].Name) + assert.Equal(t, "server", mandatory[1].Name) + + names := m.GetMandatoryPluginNames() + assert.Equal(t, []string{"core", "server"}, names) +} + +func TestGetMandatoryPluginsEmpty(t *testing.T) { + m := &manifest.Manifest{ + Plugins: map[string]manifest.Plugin{ + "analytics": {Name: "analytics"}, + }, + } + + mandatory := m.GetMandatoryPlugins() + assert.Empty(t, mandatory) + assert.Empty(t, m.GetMandatoryPluginNames()) +} + +func TestGetPluginByName(t *testing.T) { + m := &manifest.Manifest{ + Plugins: map[string]manifest.Plugin{ + "analytics": {Name: "analytics", DisplayName: "Analytics"}, + }, + } + + p := m.GetPluginByName("analytics") + require.NotNil(t, p) + assert.Equal(t, "Analytics", p.DisplayName) + + p = m.GetPluginByName("nonexistent") + assert.Nil(t, p) +} + +func TestGetPluginNames(t *testing.T) { + m := &manifest.Manifest{ + Plugins: map[string]manifest.Plugin{ + "zebra": {Name: "zebra"}, + "alpha": {Name: "alpha"}, + }, + } + + names := m.GetPluginNames() + require.Len(t, names, 2) + assert.Equal(t, "alpha", names[0]) + assert.Equal(t, "zebra", names[1]) +} + +func TestValidatePluginNames(t *testing.T) { + m := &manifest.Manifest{ + Plugins: map[string]manifest.Plugin{ + "analytics": {Name: "analytics"}, + "server": {Name: "server"}, + }, + } + + err := m.ValidatePluginNames([]string{"analytics"}) + assert.NoError(t, err) + + err = m.ValidatePluginNames([]string{"analytics", "server"}) + assert.NoError(t, err) + + err = m.ValidatePluginNames([]string{"nonexistent"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown plugin") +} + +func TestCollectResources(t *testing.T) { + m := &manifest.Manifest{ + Plugins: map[string]manifest.Plugin{ + "analytics": { + Name: "analytics", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "sql-warehouse"}, + }, + }, + }, + "genie": { + Name: "genie", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "sql-warehouse"}, + {Type: "genie_space", Alias: "Genie Space", ResourceKey: "genie-space"}, + }, + }, + }, + }, + } + + resources := m.CollectResources([]string{"analytics"}) + require.Len(t, resources, 1) + assert.Equal(t, "sql_warehouse", resources[0].Type) + + // Collect from both - warehouse should be deduplicated by resource_key + resources = m.CollectResources([]string{"analytics", "genie"}) + require.Len(t, resources, 2) +} + +func TestResourceFields(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, manifest.ManifestFileName) + + content := `{ + "version": "1.0", + "plugins": { + "caching": { + "name": "caching", + "displayName": "Caching", + "description": "DB caching", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "database", + "alias": "Database", + "resourceKey": "database", + "description": "Cache database", + "permission": "CAN_CONNECT_AND_CREATE", + "fields": { + "instance_name": {"env": "DB_INSTANCE", "description": "Lakebase instance"}, + "database_name": {"env": "DB_NAME", "description": "Database name"} + } + } + ], + "optional": [] + } + } + } + }` + + err := os.WriteFile(manifestPath, []byte(content), 0o644) + require.NoError(t, err) + + m, err := manifest.Load(dir) + require.NoError(t, err) + + p := m.GetPluginByName("caching") + require.NotNil(t, p) + require.Len(t, p.Resources.Required, 1) + + r := p.Resources.Required[0] + assert.True(t, r.HasFields()) + assert.Len(t, r.Fields, 2) + assert.Equal(t, "DB_INSTANCE", r.Fields["instance_name"].Env) + assert.Equal(t, "DB_NAME", r.Fields["database_name"].Env) + assert.Equal(t, []string{"database_name", "instance_name"}, r.FieldNames()) +} + +func TestResourceHasFieldsFalse(t *testing.T) { + r := manifest.Resource{Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "sql-warehouse"} + assert.False(t, r.HasFields()) + assert.Empty(t, r.FieldNames()) +} + +func TestResourceKey(t *testing.T) { + r := manifest.Resource{Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "sql-warehouse"} + assert.Equal(t, "sql-warehouse", r.Key()) + assert.Equal(t, "sql_warehouse", r.VarPrefix()) +} + +func TestCollectOptionalResources(t *testing.T) { + m := &manifest.Manifest{ + Plugins: map[string]manifest.Plugin{ + "analytics": { + Name: "analytics", + Resources: manifest.Resources{ + Optional: []manifest.Resource{ + {Type: "catalog", Alias: "Default Catalog", ResourceKey: "default-catalog"}, + }, + }, + }, + }, + } + + resources := m.CollectOptionalResources([]string{"analytics"}) + require.Len(t, resources, 1) + assert.Equal(t, "catalog", resources[0].Type) +} diff --git a/libs/apps/prompt/listers.go b/libs/apps/prompt/listers.go new file mode 100644 index 0000000000..6903dd66f7 --- /dev/null +++ b/libs/apps/prompt/listers.go @@ -0,0 +1,417 @@ +package prompt + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/log" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/client" + "github.com/databricks/databricks-sdk-go/listing" + "github.com/databricks/databricks-sdk-go/service/catalog" + "github.com/databricks/databricks-sdk-go/service/dashboards" + "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/sql" + "github.com/databricks/databricks-sdk-go/service/vectorsearch" + "github.com/databricks/databricks-sdk-go/service/workspace" +) + +// maxListResults caps the number of items returned by listers to avoid +// very long API traversals on large workspaces. +const maxListResults = 500 + +// ListItem is a generic item for resource pickers (id and display label). +type ListItem struct { + ID string + Label string +} + +// capResults truncates a slice to maxListResults. +func capResults(items []ListItem) []ListItem { + if len(items) > maxListResults { + return items[:maxListResults] + } + return items +} + +func workspaceClient(ctx context.Context) (*databricks.WorkspaceClient, error) { + w := cmdctx.WorkspaceClient(ctx) + if w == nil { + return nil, errors.New("no workspace client available") + } + return w, nil +} + +// ListSecretScopes returns secret scopes as selectable items. +func ListSecretScopes(ctx context.Context) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.Secrets.ListScopes(ctx) + scopes, err := listing.ToSlice(ctx, iter) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, len(scopes)) + for _, s := range scopes { + out = append(out, ListItem{ID: s.Name, Label: s.Name}) + } + return out, nil +} + +// ListSecretKeys returns secret keys within a scope as selectable items. +func ListSecretKeys(ctx context.Context, scope string) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.Secrets.ListSecrets(ctx, workspace.ListSecretsRequest{Scope: scope}) + keys, err := listing.ToSlice(ctx, iter) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, len(keys)) + for _, k := range keys { + out = append(out, ListItem{ID: k.Key, Label: k.Key}) + } + return out, nil +} + +// ListJobs returns jobs as selectable items. +func ListJobs(ctx context.Context) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.Jobs.List(ctx, jobs.ListJobsRequest{}) + jobList, err := listing.ToSlice(ctx, iter) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, len(jobList)) + for _, j := range jobList { + label := j.Settings.Name + id := strconv.FormatInt(j.JobId, 10) + if label == "" { + label = id + } + out = append(out, ListItem{ID: id, Label: label}) + } + return out, nil +} + +// ListSQLWarehousesItems returns SQL warehouses as ListItems (reuses same API as ListSQLWarehouses). +func ListSQLWarehousesItems(ctx context.Context) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.Warehouses.List(ctx, sql.ListWarehousesRequest{}) + whs, err := listing.ToSlice(ctx, iter) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, len(whs)) + for _, wh := range whs { + label := wh.Name + if wh.State != "" { + label = fmt.Sprintf("%s (%s)", wh.Name, wh.State) + } + out = append(out, ListItem{ID: wh.Id, Label: label}) + } + return out, nil +} + +// ListServingEndpoints returns serving endpoints as selectable items. +func ListServingEndpoints(ctx context.Context) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.ServingEndpoints.List(ctx) + endpoints, err := listing.ToSlice(ctx, iter) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, len(endpoints)) + for _, e := range endpoints { + name := e.Name + if name == "" { + name = e.Id + } + out = append(out, ListItem{ID: e.Id, Label: name}) + } + return out, nil +} + +// ListCatalogs returns UC catalogs as selectable items. +func ListCatalogs(ctx context.Context) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + catIter := w.Catalogs.List(ctx, catalog.ListCatalogsRequest{}) + cats, err := listing.ToSlice(ctx, catIter) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, min(len(cats), maxListResults)) + for _, c := range cats { + out = append(out, ListItem{ID: c.Name, Label: c.Name}) + } + return capResults(out), nil +} + +// ListSchemas returns UC schemas within a catalog as selectable items. +func ListSchemas(ctx context.Context, catalogName string) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + schemaIter := w.Schemas.List(ctx, catalog.ListSchemasRequest{CatalogName: catalogName}) + schemas, err := listing.ToSlice(ctx, schemaIter) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, min(len(schemas), maxListResults)) + for _, s := range schemas { + out = append(out, ListItem{ID: s.Name, Label: s.Name}) + } + return capResults(out), nil +} + +// ListVolumesInSchema returns UC volumes within a catalog.schema as selectable items. +func ListVolumesInSchema(ctx context.Context, catalogName, schemaName string) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + volIter := w.Volumes.List(ctx, catalog.ListVolumesRequest{ + CatalogName: catalogName, + SchemaName: schemaName, + }) + vols, err := listing.ToSlice(ctx, volIter) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, min(len(vols), maxListResults)) + for _, v := range vols { + fullName := fmt.Sprintf("%s.%s.%s", catalogName, schemaName, v.Name) + out = append(out, ListItem{ID: fullName, Label: v.Name}) + } + return capResults(out), nil +} + +// ListVectorSearchIndexes returns vector search indexes as selectable items (id = endpoint/index name). +func ListVectorSearchIndexes(ctx context.Context) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + var out []ListItem + epIter := w.VectorSearchEndpoints.ListEndpoints(ctx, vectorsearch.ListEndpointsRequest{}) + endpoints, err := listing.ToSlice(ctx, epIter) + if err != nil { + return nil, err + } + for _, ep := range endpoints { + indexIter := w.VectorSearchIndexes.ListIndexes(ctx, vectorsearch.ListIndexesRequest{EndpointName: ep.Name}) + indexes, err := listing.ToSlice(ctx, indexIter) + if err != nil { + log.Warnf(ctx, "Failed to list indexes for endpoint %q: %v", ep.Name, err) + continue + } + for _, idx := range indexes { + label := idx.Name + if label == "" { + label = ep.Name + "/ (unnamed)" + } + id := ep.Name + "/" + idx.Name + out = append(out, ListItem{ID: id, Label: fmt.Sprintf("%s / %s", ep.Name, label)}) + } + } + return out, nil +} + +// ListFunctionsInSchema returns UC functions within a catalog.schema as selectable items. +func ListFunctionsInSchema(ctx context.Context, catalogName, schemaName string) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + fnIter := w.Functions.List(ctx, catalog.ListFunctionsRequest{ + CatalogName: catalogName, + SchemaName: schemaName, + }) + fns, err := listing.ToSlice(ctx, fnIter) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, min(len(fns), maxListResults)) + for _, f := range fns { + fullName := f.FullName + if fullName == "" { + fullName = fmt.Sprintf("%s.%s.%s", catalogName, schemaName, f.Name) + } + out = append(out, ListItem{ID: fullName, Label: f.Name}) + } + return capResults(out), nil +} + +// ListConnections returns UC connections as selectable items. +func ListConnections(ctx context.Context) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.Connections.List(ctx, catalog.ListConnectionsRequest{}) + conns, err := listing.ToSlice(ctx, iter) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, len(conns)) + for _, c := range conns { + name := c.Name + if name == "" { + name = c.FullName + } + out = append(out, ListItem{ID: c.FullName, Label: name}) + } + return out, nil +} + +// ListDatabaseInstances returns Lakebase database instances as selectable items. +func ListDatabaseInstances(ctx context.Context) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.Database.ListDatabaseInstances(ctx, database.ListDatabaseInstancesRequest{}) + instances, err := listing.ToSlice(ctx, iter) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, len(instances)) + for _, inst := range instances { + out = append(out, ListItem{ID: inst.Name, Label: inst.Name}) + } + return out, nil +} + +// listDatabasesResponse is the response from the /databases endpoint. +type listDatabasesResponse struct { + Databases []struct { + Name string `json:"name"` + IsUsableByCustomer bool `json:"is_usable_by_customer"` + } `json:"databases"` +} + +// ListDatabases returns databases within a Lakebase instance as selectable items. +func ListDatabases(ctx context.Context, instanceName string) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + api, err := client.New(w.Config) + if err != nil { + return nil, err + } + // TODO: use the SDK to list databases once available + var resp listDatabasesResponse + path := fmt.Sprintf("/api/2.0/database/instances/%s/databases", url.PathEscape(instanceName)) + headers := map[string]string{"Accept": "application/json"} + err = api.Do(ctx, http.MethodGet, path, headers, nil, nil, &resp) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, len(resp.Databases)) + for _, db := range resp.Databases { + if !db.IsUsableByCustomer { + continue + } + out = append(out, ListItem{ID: db.Name, Label: db.Name}) + } + return out, nil +} + +// ListGenieSpaces returns Genie spaces as selectable items. +func ListGenieSpaces(ctx context.Context) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + var out []ListItem + req := dashboards.GenieListSpacesRequest{} + for { + resp, err := w.Genie.ListSpaces(ctx, req) + if err != nil { + return nil, err + } + for _, s := range resp.Spaces { + id := s.SpaceId + label := s.Title + if label == "" { + label = s.Description + } + if label == "" { + label = id + } + out = append(out, ListItem{ID: id, Label: label}) + } + if resp.NextPageToken == "" { + break + } + req.PageToken = resp.NextPageToken + } + return out, nil +} + +// ListExperiments returns MLflow experiments as selectable items. +func ListExperiments(ctx context.Context) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.Experiments.ListExperiments(ctx, ml.ListExperimentsRequest{}) + exps, err := listing.ToSlice(ctx, iter) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, len(exps)) + for _, e := range exps { + label := e.Name + if label == "" { + label = e.ExperimentId + } + out = append(out, ListItem{ID: e.ExperimentId, Label: label}) + } + return out, nil +} + +// TODO: uncomment when bundles support app as an app resource type. +// // ListAppsItems returns apps as ListItems (id = app name). +// func ListAppsItems(ctx context.Context) ([]ListItem, error) { +// w, err := workspaceClient(ctx) +// if err != nil { +// return nil, err +// } +// iter := w.Apps.List(ctx, apps.ListAppsRequest{}) +// appList, err := listing.ToSlice(ctx, iter) +// if err != nil { +// return nil, err +// } +// out := make([]ListItem, 0, len(appList)) +// for _, a := range appList { +// label := a.Name +// out = append(out, ListItem{ID: a.Name, Label: label}) +// } +// return out, nil +// } diff --git a/libs/apps/prompt/prompt.go b/libs/apps/prompt/prompt.go index 8040afc28a..95f5d2c04a 100644 --- a/libs/apps/prompt/prompt.go +++ b/libs/apps/prompt/prompt.go @@ -11,7 +11,7 @@ import ( "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" - "github.com/databricks/cli/libs/apps/features" + "github.com/databricks/cli/libs/apps/manifest" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/databricks-sdk-go/listing" @@ -162,54 +162,6 @@ func PromptForProjectName(ctx context.Context, outputDir string) (string, error) return name, nil } -// PromptForPluginDependencies prompts for dependencies required by detected plugins. -// Returns a map of dependency ID to value. -func PromptForPluginDependencies(ctx context.Context, deps []features.FeatureDependency) (map[string]string, error) { - theme := AppkitTheme() - result := make(map[string]string) - - for _, dep := range deps { - // Special handling for SQL warehouse - show picker instead of text input - if dep.ID == "sql_warehouse_id" { - warehouseID, err := PromptForWarehouse(ctx) - if err != nil { - return nil, err - } - result[dep.ID] = warehouseID - continue - } - - var value string - description := dep.Description - if !dep.Required { - description += " (optional)" - } - - input := huh.NewInput(). - Title(dep.Title). - Description(description). - Placeholder(dep.Placeholder). - Value(&value) - - if dep.Required { - input = input.Validate(func(s string) error { - if s == "" { - return errors.New("this field is required") - } - return nil - }) - } - - if err := input.WithTheme(theme).Run(); err != nil { - return nil, err - } - printAnswered(ctx, dep.Title, value) - result[dep.ID] = value - } - - return result, nil -} - // PromptForDeployAndRun prompts for post-creation deploy and run options. func PromptForDeployAndRun(ctx context.Context) (deploy bool, runMode RunMode, err error) { theme := AppkitTheme() @@ -262,216 +214,351 @@ func PromptForDeployAndRun(ctx context.Context) (deploy bool, runMode RunMode, e return deploy, RunMode(runModeStr), nil } -// PromptForProjectConfig shows an interactive form to gather project configuration. -// Flow: name -> features -> feature dependencies -> description -> deploy/run. -// If preSelectedFeatures is provided, the feature selection prompt is skipped. -func PromptForProjectConfig(ctx context.Context, preSelectedFeatures []string) (*CreateProjectConfig, error) { - config := &CreateProjectConfig{ - Dependencies: make(map[string]string), - Features: preSelectedFeatures, +// ListSQLWarehouses fetches all SQL warehouses the user has access to. +func ListSQLWarehouses(ctx context.Context) ([]sql.EndpointInfo, error) { + w := cmdctx.WorkspaceClient(ctx) + if w == nil { + return nil, errors.New("no workspace client available") } - theme := AppkitTheme() - PrintHeader(ctx) + iter := w.Warehouses.List(ctx, sql.ListWarehousesRequest{}) + return listing.ToSlice(ctx, iter) +} - // Step 1: Project name - err := huh.NewInput(). - Title("Project name"). - Description("lowercase letters, numbers, hyphens (max 26 chars)"). - Placeholder("my-app"). - Value(&config.ProjectName). - Validate(ValidateProjectName). +// PromptFromList shows a picker for items and returns the selected ID. +// If required is false and items are empty, returns ("", nil). If required is true and items are empty, returns an error. +func PromptFromList(ctx context.Context, title, emptyMessage string, items []ListItem, required bool) (string, error) { + id, _, err := promptFromListWithLabel(ctx, title, emptyMessage, items, required) + return id, err +} + +// promptFromListWithLabel shows a picker and returns both the selected ID and its display label. +func promptFromListWithLabel(ctx context.Context, title, emptyMessage string, items []ListItem, required bool) (string, string, error) { + if len(items) == 0 { + if required { + return "", "", errors.New(emptyMessage) + } + return "", "", nil + } + theme := AppkitTheme() + options := make([]huh.Option[string], 0, len(items)) + labels := make(map[string]string) + for _, it := range items { + options = append(options, huh.NewOption(it.Label, it.ID)) + labels[it.ID] = it.Label + } + var selected string + err := huh.NewSelect[string](). + Title(title). + Description(fmt.Sprintf("%d available — type to filter", len(items))). + Options(options...). + Value(&selected). + Filtering(true). + Height(8). WithTheme(theme). Run() if err != nil { - return nil, err + return "", "", err } - printAnswered(ctx, "Project name", config.ProjectName) - - // Step 2: Feature selection (skip if features already provided via flag) - if len(config.Features) == 0 && len(features.AvailableFeatures) > 0 { - options := make([]huh.Option[string], 0, len(features.AvailableFeatures)) - for _, f := range features.AvailableFeatures { - label := f.Name + " - " + f.Description - options = append(options, huh.NewOption(label, f.ID)) - } + printAnswered(ctx, title, labels[selected]) + return selected, labels[selected], nil +} - err = huh.NewMultiSelect[string](). - Title("Select features"). - Description("space to toggle, enter to confirm"). - Options(options...). - Value(&config.Features). - Height(8). - WithTheme(theme). - Run() - if err != nil { - return nil, err - } - if len(config.Features) == 0 { - printAnswered(ctx, "Features", "None") - } else { - printAnswered(ctx, "Features", fmt.Sprintf("%d selected", len(config.Features))) - } +// PromptForWarehouse shows a picker to select a SQL warehouse. +func PromptForWarehouse(ctx context.Context) (string, error) { + var items []ListItem + err := RunWithSpinnerCtx(ctx, "Fetching SQL warehouses...", func() error { + var fetchErr error + items, fetchErr = ListSQLWarehousesItems(ctx) + return fetchErr + }) + if err != nil { + return "", fmt.Errorf("failed to fetch SQL warehouses: %w", err) } + return PromptFromList(ctx, "Select SQL Warehouse", "no SQL warehouses found. Create one in your workspace first", items, true) +} - // Step 3: Prompt for feature dependencies - deps := features.CollectDependencies(config.Features) - for _, dep := range deps { - // Special handling for SQL warehouse - show picker instead of text input - if dep.ID == "sql_warehouse_id" { - warehouseID, err := PromptForWarehouse(ctx) - if err != nil { - return nil, err - } - config.Dependencies[dep.ID] = warehouseID - continue - } - - var value string - description := dep.Description - if !dep.Required { - description += " (optional)" - } - - input := huh.NewInput(). - Title(dep.Title). - Description(description). - Placeholder(dep.Placeholder). - Value(&value) - - if dep.Required { - input = input.Validate(func(s string) error { - if s == "" { - return errors.New("this field is required") - } - return nil - }) - } - - if err := input.WithTheme(theme).Run(); err != nil { - return nil, err - } - printAnswered(ctx, dep.Title, value) - config.Dependencies[dep.ID] = value +// singleValueResult wraps a single value into the resource values map. +// Uses the first field name from Fields for the composite key (resource_key.field), +// or falls back to the resource key if no Fields are defined. +func singleValueResult(r manifest.Resource, value string) map[string]string { + if value == "" { + return nil } + names := r.FieldNames() + if len(names) >= 1 { + return map[string]string{r.Key() + "." + names[0]: value} + } + return map[string]string{r.Key(): value} +} - // Step 4: Description - config.Description = DefaultAppDescription - err = huh.NewInput(). - Title("Description"). - Placeholder(DefaultAppDescription). - Value(&config.Description). - WithTheme(theme). - Run() +// promptForResourceFromLister runs a spinner, fetches items via fn, then shows PromptFromList. +func promptForResourceFromLister(ctx context.Context, r manifest.Resource, required bool, title, emptyMsg, spinnerMsg string, fn func(context.Context) ([]ListItem, error)) (map[string]string, error) { + var items []ListItem + err := RunWithSpinnerCtx(ctx, spinnerMsg, func() error { + var fetchErr error + items, fetchErr = fn(ctx) + return fetchErr + }) if err != nil { return nil, err } - if config.Description == "" { - config.Description = DefaultAppDescription + value, err := PromptFromList(ctx, title, emptyMsg, items, required) + if err != nil { + return nil, err } - printAnswered(ctx, "Description", config.Description) + return singleValueResult(r, value), nil +} - // Step 5: Deploy after creation? - err = huh.NewConfirm(). - Title("Deploy after creation?"). - Description("Run 'databricks apps deploy' after setup"). - Value(&config.Deploy). - WithTheme(theme). - Run() +// PromptForSecret shows a two-step picker for secret scope and key. +func PromptForSecret(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { + // Step 1: pick scope + var scopes []ListItem + err := RunWithSpinnerCtx(ctx, "Fetching secret scopes...", func() error { + var fetchErr error + scopes, fetchErr = ListSecretScopes(ctx) + return fetchErr + }) if err != nil { return nil, err } - if config.Deploy { - printAnswered(ctx, "Deploy after creation", "Yes") - } else { - printAnswered(ctx, "Deploy after creation", "No") + scope, err := PromptFromList(ctx, "Select Secret Scope", "no secret scopes found", scopes, required) + if err != nil { + return nil, err + } + if scope == "" { + return nil, nil } - // Step 6: Run the app? - runModeStr := string(RunModeNone) - err = huh.NewSelect[string](). - Title("Run the app after creation?"). - Description("Choose how to start the development server"). - Options( - huh.NewOption("No, I'll run it later", string(RunModeNone)), - huh.NewOption("Yes, run locally (npm run dev)", string(RunModeDev)), - huh.NewOption("Yes, run with remote bridge (dev-remote)", string(RunModeDevRemote)), - ). - Value(&runModeStr). - WithTheme(theme). - Run() + // Step 2: pick key within scope + var keys []ListItem + err = RunWithSpinnerCtx(ctx, "Fetching secret keys...", func() error { + var fetchErr error + keys, fetchErr = ListSecretKeys(ctx, scope) + return fetchErr + }) + if err != nil { + return nil, err + } + key, err := PromptFromList(ctx, "Select Secret Key", "no keys found in scope "+scope, keys, required) if err != nil { return nil, err } - config.RunMode = RunMode(runModeStr) + if key == "" { + return nil, nil + } - runModeLabels := map[string]string{ - string(RunModeNone): "No", - string(RunModeDev): "Yes (local)", - string(RunModeDevRemote): "Yes (remote)", + return map[string]string{ + r.Key() + ".scope": scope, + r.Key() + ".key": key, + }, nil +} + +// PromptForJob shows a picker for jobs. +func PromptForJob(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { + return promptForResourceFromLister(ctx, r, required, "Select Job", "no jobs found", "Fetching jobs...", ListJobs) +} + +// PromptForSQLWarehouseResource shows a picker for SQL warehouses (manifest.Resource version). +func PromptForSQLWarehouseResource(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { + return promptForResourceFromLister(ctx, r, required, "Select SQL Warehouse", "no SQL warehouses found. Create one in your workspace first", "Fetching SQL warehouses...", ListSQLWarehousesItems) +} + +const backID = "__back__" + +// promptUCCatalog shows a picker for UC catalogs (shared first step for volume/function pickers). +func promptUCCatalog(ctx context.Context, required bool) (string, error) { + var items []ListItem + err := RunWithSpinnerCtx(ctx, "Fetching catalogs...", func() error { + var fetchErr error + items, fetchErr = ListCatalogs(ctx) + return fetchErr + }) + if err != nil { + return "", err } - printAnswered(ctx, "Run after creation", runModeLabels[runModeStr]) + return PromptFromList(ctx, "Select Catalog", "no catalogs found", items, required) +} - return config, nil +// promptFromListWithBack shows a picker with a "← Go back" option prepended. +// Returns ("", nil) if the user selects "Go back". +func promptFromListWithBack(ctx context.Context, title string, items []ListItem) (string, error) { + withBack := make([]ListItem, 0, len(items)+1) + withBack = append(withBack, ListItem{ID: backID, Label: "← Go back"}) + withBack = append(withBack, items...) + value, err := PromptFromList(ctx, title, "", withBack, true) + if err != nil { + return "", err + } + if value == backID { + return "", nil + } + return value, nil } -// ListSQLWarehouses fetches all SQL warehouses the user has access to. -func ListSQLWarehouses(ctx context.Context) ([]sql.EndpointInfo, error) { - w := cmdctx.WorkspaceClient(ctx) - if w == nil { - return nil, errors.New("no workspace client available") +// promptUCResource runs a three-step catalog → schema → resource picker with back navigation. +// Empty results at any level show a message and navigate back automatically. +func promptUCResource(ctx context.Context, r manifest.Resource, required bool, resourceLabel, spinnerMsg string, listFn func(context.Context, string, string) ([]ListItem, error)) (map[string]string, error) { + for { + catalogName, err := promptUCCatalog(ctx, required) + if err != nil || catalogName == "" { + return nil, err + } + + for { + var schemas []ListItem + err = RunWithSpinnerCtx(ctx, "Fetching schemas...", func() error { + var fetchErr error + schemas, fetchErr = ListSchemas(ctx, catalogName) + return fetchErr + }) + if err != nil { + return nil, err + } + if len(schemas) == 0 { + cmdio.LogString(ctx, fmt.Sprintf("No schemas found in %s, try another catalog.", catalogName)) + break // back to catalog picker + } + + schemaName, err := promptFromListWithBack(ctx, "Select Schema", schemas) + if err != nil { + return nil, err + } + if schemaName == "" { + break // back to catalog picker + } + + var items []ListItem + err = RunWithSpinnerCtx(ctx, spinnerMsg, func() error { + var fetchErr error + items, fetchErr = listFn(ctx, catalogName, schemaName) + return fetchErr + }) + if err != nil { + return nil, err + } + if len(items) == 0 { + cmdio.LogString(ctx, fmt.Sprintf("No %ss found in %s.%s, try another schema.", resourceLabel, catalogName, schemaName)) + continue // back to schema picker + } + + value, err := promptFromListWithBack(ctx, "Select "+resourceLabel, items) + if err != nil { + return nil, err + } + if value == "" { + continue // back to schema picker + } + return singleValueResult(r, value), nil + } } +} - iter := w.Warehouses.List(ctx, sql.ListWarehousesRequest{}) - return listing.ToSlice(ctx, iter) +// PromptForServingEndpoint shows a picker for serving endpoints. +func PromptForServingEndpoint(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { + return promptForResourceFromLister(ctx, r, required, "Select Serving Endpoint", "no serving endpoints found", "Fetching serving endpoints...", ListServingEndpoints) } -// PromptForWarehouse shows a picker to select a SQL warehouse. -func PromptForWarehouse(ctx context.Context) (string, error) { - var warehouses []sql.EndpointInfo - err := RunWithSpinnerCtx(ctx, "Fetching SQL warehouses...", func() error { +// PromptForVolume shows a three-step picker for UC volumes: catalog -> schema -> volume. +func PromptForVolume(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { + return promptUCResource(ctx, r, required, "Volume", "Fetching volumes...", ListVolumesInSchema) +} + +// PromptForVectorSearchIndex shows a picker for vector search indexes. +func PromptForVectorSearchIndex(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { + return promptForResourceFromLister(ctx, r, required, "Select Vector Search Index", "no vector search indexes found", "Fetching vector search indexes...", ListVectorSearchIndexes) +} + +// PromptForUCFunction shows a three-step picker for UC functions: catalog -> schema -> function. +func PromptForUCFunction(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { + return promptUCResource(ctx, r, required, "UC Function", "Fetching functions...", ListFunctionsInSchema) +} + +// PromptForUCConnection shows a picker for UC connections. +func PromptForUCConnection(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { + return promptForResourceFromLister(ctx, r, required, "Select UC Connection", "no connections found", "Fetching connections...", ListConnections) +} + +// PromptForDatabase shows a two-step picker for database instance and database name. +func PromptForDatabase(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { + // Step 1: pick a Lakebase instance + var instances []ListItem + err := RunWithSpinnerCtx(ctx, "Fetching database instances...", func() error { var fetchErr error - warehouses, fetchErr = ListSQLWarehouses(ctx) + instances, fetchErr = ListDatabaseInstances(ctx) return fetchErr }) if err != nil { - return "", fmt.Errorf("failed to fetch SQL warehouses: %w", err) + return nil, err + } + instanceName, err := PromptFromList(ctx, "Select Database Instance", "no database instances found", instances, required) + if err != nil { + return nil, err + } + if instanceName == "" { + return nil, nil } - if len(warehouses) == 0 { - return "", errors.New("no SQL warehouses found. Create one in your workspace first") + // Step 2: pick a database within the instance + var databases []ListItem + err = RunWithSpinnerCtx(ctx, "Fetching databases...", func() error { + var fetchErr error + databases, fetchErr = ListDatabases(ctx, instanceName) + return fetchErr + }) + if err != nil { + return nil, err + } + dbName, err := PromptFromList(ctx, "Select Database", "no databases found in instance "+instanceName, databases, required) + if err != nil { + return nil, err + } + if dbName == "" { + return nil, nil } - theme := AppkitTheme() + return map[string]string{ + r.Key() + ".instance_name": instanceName, + r.Key() + ".database_name": dbName, + }, nil +} - // Build options with warehouse name and state - options := make([]huh.Option[string], 0, len(warehouses)) - warehouseNames := make(map[string]string) // id -> name for printing - for _, wh := range warehouses { - state := string(wh.State) - label := fmt.Sprintf("%s (%s)", wh.Name, state) - options = append(options, huh.NewOption(label, wh.Id)) - warehouseNames[wh.Id] = wh.Name +// 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) { + var items []ListItem + err := RunWithSpinnerCtx(ctx, "Fetching Genie spaces...", func() error { + var fetchErr error + items, fetchErr = ListGenieSpaces(ctx) + return fetchErr + }) + if err != nil { + return nil, err } - - var selected string - err = huh.NewSelect[string](). - Title("Select SQL Warehouse"). - Description(fmt.Sprintf("%d warehouses available — type to filter", len(warehouses))). - Options(options...). - Value(&selected). - Filtering(true). - Height(8). - WithTheme(theme). - Run() + id, name, err := promptFromListWithLabel(ctx, "Select Genie Space", "no Genie spaces found", items, required) if err != nil { - return "", err + return nil, err + } + if id == "" { + return nil, nil } + return map[string]string{ + r.Key() + ".id": id, + r.Key() + ".name": name, + }, nil +} - printAnswered(ctx, "SQL Warehouse", warehouseNames[selected]) - return selected, nil +// PromptForExperiment shows a picker for MLflow experiments. +func PromptForExperiment(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { + return promptForResourceFromLister(ctx, r, required, "Select Experiment", "no experiments found", "Fetching experiments...", ListExperiments) } +// TODO: uncomment when bundles support app as an app resource type. +// // PromptForAppResource shows a picker for apps (manifest.Resource version). +// func PromptForAppResource(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { +// return promptForResourceFromLister(ctx, r, required, "Select App", "no apps found. Create one first with 'databricks apps create '", "Fetching apps...", ListAppsItems) +// } + // RunWithSpinnerCtx runs a function while showing a spinner with the given title. // The spinner stops and the function returns early if the context is cancelled. // Panics in the action are recovered and returned as errors. diff --git a/libs/apps/prompt/resource_registry.go b/libs/apps/prompt/resource_registry.go new file mode 100644 index 0000000000..f3d6ec640c --- /dev/null +++ b/libs/apps/prompt/resource_registry.go @@ -0,0 +1,63 @@ +package prompt + +import ( + "context" + + "github.com/databricks/cli/libs/apps/manifest" +) + +// Resource type constants matching the TS enum (appkit plugin manifest). +const ( + ResourceTypeSecret = "secret" + ResourceTypeJob = "job" + ResourceTypeSQLWarehouse = "sql_warehouse" + ResourceTypeServingEndpoint = "serving_endpoint" + ResourceTypeVolume = "volume" + ResourceTypeVectorSearchIndex = "vector_search_index" + ResourceTypeUCFunction = "uc_function" + ResourceTypeUCConnection = "uc_connection" + ResourceTypeDatabase = "database" + ResourceTypeGenieSpace = "genie_space" + ResourceTypeExperiment = "experiment" + // TODO: uncomment when bundles support app as an app resource type. + // ResourceTypeApp = "app" +) + +// PromptResourceFunc prompts the user for a resource of a given type. +// Returns a map of value keys to values. For single-field resources the map has one entry +// keyed by "resource_key.field" (e.g., {"sql-warehouse.id": "abc123"}). For multi-field resources, +// keys use the format "resource_key.field_name" (e.g., {"database.instance_name": "x", "database.database_name": "y"}). +type PromptResourceFunc func(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) + +// GetPromptFunc returns the prompt function for the given resource type, or (nil, false) if not supported. +func GetPromptFunc(resourceType string) (PromptResourceFunc, bool) { + switch resourceType { + case ResourceTypeSecret: + return PromptForSecret, true + case ResourceTypeJob: + return PromptForJob, true + case ResourceTypeSQLWarehouse: + return PromptForSQLWarehouseResource, true + case ResourceTypeServingEndpoint: + return PromptForServingEndpoint, true + case ResourceTypeVolume: + return PromptForVolume, true + case ResourceTypeVectorSearchIndex: + return PromptForVectorSearchIndex, true + case ResourceTypeUCFunction: + return PromptForUCFunction, true + case ResourceTypeUCConnection: + return PromptForUCConnection, true + case ResourceTypeDatabase: + return PromptForDatabase, true + case ResourceTypeGenieSpace: + return PromptForGenieSpace, true + case ResourceTypeExperiment: + return PromptForExperiment, true + // TODO: uncomment when bundles support app as an app resource type. + // case ResourceTypeApp: + // return PromptForAppResource, true + default: + return nil, false + } +}