From 11fb319e339892fd513a1860b9c5566384e71b97 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Mon, 9 Feb 2026 10:49:21 +0100 Subject: [PATCH 1/4] feat: use plugin manifest --- cmd/apps/init.go | 613 ++++++++++++-------------- cmd/apps/init_test.go | 16 +- libs/apps/features/features.go | 329 -------------- libs/apps/features/features_test.go | 453 ------------------- libs/apps/generator/generator.go | 205 +++++++++ libs/apps/generator/generator_test.go | 280 ++++++++++++ libs/apps/manifest/manifest.go | 168 +++++++ libs/apps/manifest/manifest_test.go | 229 ++++++++++ libs/apps/prompt/listers.go | 336 ++++++++++++++ libs/apps/prompt/prompt.go | 332 +++++--------- libs/apps/prompt/resource_registry.go | 58 +++ 11 files changed, 1669 insertions(+), 1350 deletions(-) delete mode 100644 libs/apps/features/features.go delete mode 100644 libs/apps/features/features_test.go create mode 100644 libs/apps/generator/generator.go create mode 100644 libs/apps/generator/generator_test.go create mode 100644 libs/apps/manifest/manifest.go create mode 100644 libs/apps/manifest/manifest_test.go create mode 100644 libs/apps/prompt/listers.go create mode 100644 libs/apps/prompt/resource_registry.go diff --git a/cmd/apps/init.go b/cmd/apps/init.go index e815241ce9..7b9269c0e5 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" @@ -55,7 +56,7 @@ func newInitCmd() *cobra.Command { warehouseID string description string outputDir string - featuresFlag []string + pluginsFlag []string deploy bool run string ) @@ -98,8 +99,8 @@ Examples: # With a GitHub URL databricks apps init --template https://github.com/user/repo --name my-app -Feature dependencies: - Some features require additional flags: +Plugin dependencies: + Some plugins require additional flags (as defined in appkit.plugins.json): - analytics: requires --warehouse-id (SQL Warehouse ID) Environment variables: @@ -115,20 +116,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"), + warehouseID: warehouseID, + 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"), }) }, } @@ -140,7 +141,9 @@ Environment variables: cmd.Flags().StringVar(&warehouseID, "warehouse-id", "", "SQL warehouse ID") 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,20 +151,20 @@ 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") + warehouseID string + 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 } // templateVars holds the variables for template substitution. @@ -171,8 +174,8 @@ type templateVars struct { AppDescription string Profile string WorkspaceHost string - PluginImport string - PluginUsage string + PluginImports string + PluginUsages string // Feature resource fragments (aggregated from selected features) BundleVariables string BundleResources string @@ -182,16 +185,6 @@ type templateVars struct { DotEnvExample string } -// featureFragments holds aggregated content from feature resource files. -type featureFragments struct { - BundleVariables string - BundleResources string - TargetVariables string - AppEnv string - DotEnv string - DotEnvExample string -} - // parseDeployAndRunFlags parses the deploy and run flag values into typed values. func parseDeployAndRunFlags(deploy bool, run string) (bool, prompt.RunMode, error) { var runMode prompt.RunMode @@ -214,22 +207,22 @@ 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)) } err := huh.NewMultiSelect[string](). @@ -244,54 +237,35 @@ func promptForFeaturesAndDeps(ctx context.Context, preSelectedFeatures []string, return nil, err } if len(config.Features) == 0 { - prompt.PrintAnswered(ctx, "Features", "None") + 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(config.Features))) } } - // 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 - } - - 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 - }) + // Step 2: Prompt for required plugin resource dependencies + resources := m.CollectResources(config.Features) + for _, r := range resources { + value, err := promptForResource(ctx, r, theme, true) + if err != nil { + return nil, err } + config.Dependencies[r.Alias] = value + } - 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 { + value, err := promptForResource(ctx, r, theme, false) + if err != nil { return nil, err } - prompt.PrintAnswered(ctx, dep.Title, value) - config.Dependencies[dep.ID] = value + if value != "" { + config.Dependencies[r.Alias] = value + } } - // Step 3: Description + // Step 4: Description config.Description = prompt.DefaultAppDescription err := huh.NewInput(). Title("Description"). @@ -307,7 +281,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 +292,60 @@ 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) - 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) +// promptForResource prompts the user for a resource value. +// If required is true, the user must provide a value. Otherwise, they can skip. +func promptForResource(ctx context.Context, r manifest.Resource, theme *huh.Theme, required bool) (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 app env: %w", err) + return "", 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) - } - 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) + if !configure { + prompt.PrintAnswered(ctx, r.Alias, "skipped") + return "", nil } - dotEnvExampleList = append(dotEnvExampleList, content) } + return fn(ctx, r, required) } - // 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 -} + // Generic text input for unregistered resource types + var value string + description := r.Description + if !required { + description += " (optional, press enter to skip)" + } -// 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 - } + 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") + } + return nil + }) + } + + if err := input.WithTheme(theme).Run(); err != nil { return "", err } - return substituteVars(string(content), vars), nil + + if value == "" && !required { + prompt.PrintAnswered(ctx, r.Alias, "skipped") + } else { + prompt.PrintAnswered(ctx, r.Alias, value) + } + return value, nil } // cloneRepo clones a git repository to a temporary directory. @@ -453,15 +403,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,158 +496,95 @@ 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 + 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", + name, len(p.Resources.Required), len(p.Resources.Optional)) + } // 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 - - if isInteractive && !opts.featuresChanged && !flagsMode { - // Interactive mode without --features flag: prompt for features, dependencies, description - config, err := promptForFeaturesAndDeps(ctx, selectedFeatures, skipDeployRunPrompt) - if 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 - } + // Skip deploy/run prompts if in flags mode or if deploy/run flags were explicitly set + skipDeployRunPrompt := flagsMode || opts.deployChanged || opts.runChanged - // 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 - } + 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 + } + // Use prompted values for deploy/run (only set if we prompted) + if !skipDeployRunPrompt { + shouldDeploy = config.Deploy + runMode = config.RunMode + } - // 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 + // Get warehouse from resourceValues if provided + if wh, ok := resourceValues["warehouse"]; ok && wh != "" { + opts.warehouseID = wh + } + } else if isInteractive && opts.pluginsChanged && !flagsMode { + // Interactive mode with --plugins flag: validate plugins, prompt for deploy/run if no flags + if len(selectedPlugins) > 0 { + if err := m.ValidatePluginNames(selectedPlugins); err != nil { + return err } } + resourceValues = make(map[string]string) + if opts.warehouseID != "" { + resourceValues["warehouse"] = 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 if no flags were set + if !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 - } } 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) - } - - 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 { + // Flags mode or non-interactive: validate plugins and use flag values + if len(selectedPlugins) > 0 { + if err := m.ValidatePluginNames(selectedPlugins); 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 - } - - // 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) - } - } - } } - - // Set default description if not provided - if opts.description == "" { - opts.description = prompt.DefaultAppDescription + resourceValues = make(map[string]string) + if opts.warehouseID != "" { + resourceValues["warehouse"] = opts.warehouseID } - // 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 + // Validate required resources are provided + resources := m.CollectResources(selectedPlugins) + for _, r := range resources { + if _, ok := resourceValues[r.Alias]; !ok { + return fmt.Errorf("missing required resource %q for selected plugins (use --%s flag)", r.Alias, r.Alias+"-id") } } } + // 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 + } + } + // Track whether we started creating the project for cleanup on failure var projectCreated bool var runErr error @@ -721,31 +608,48 @@ 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: %v", 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, + SQLWarehouseID: opts.warehouseID, + 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 +669,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 +772,46 @@ func runPostCreateDev(ctx context.Context, mode prompt.RunMode, projectInit init } } +// 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 +} + +// applyPlugins applies plugin-specific post-processing to the project. +func applyPlugins(projectDir string, pluginNames []string) error { + selectedSet := make(map[string]bool) + for _, name := range pluginNames { + selectedSet[name] = 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 +} + // renameFiles maps source file names to destination names (for files that can't use special chars). var renameFiles = map[string]string{ "_gitignore": ".gitignore", @@ -1013,6 +957,8 @@ 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) @@ -1021,19 +967,24 @@ func substituteVars(s string, vars templateVars) string { 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 } @@ -1055,10 +1006,10 @@ func executeTemplate(path string, content []byte, vars templateVars) ([]byte, er "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, diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index c21447cc16..2c3c30feff 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -71,8 +71,8 @@ func TestSubstituteVars(t *testing.T) { AppDescription: "My awesome app", Profile: "default", WorkspaceHost: "https://dbc-123.cloud.databricks.com", - PluginImport: "analytics", - PluginUsage: "analytics()", + PluginImports: "analytics", + PluginUsages: "analytics()", } tests := []struct { @@ -107,12 +107,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()]", }, { @@ -143,8 +143,8 @@ func TestSubstituteVarsNoPlugins(t *testing.T) { AppDescription: "My app", Profile: "", WorkspaceHost: "", - PluginImport: "", // No plugins - PluginUsage: "", + PluginImports: "", // No plugins + PluginUsages: "", } tests := []struct { @@ -154,12 +154,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]", }, } 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..ddabd77e76 --- /dev/null +++ b/libs/apps/generator/generator.go @@ -0,0 +1,205 @@ +package generator + +import ( + "fmt" + "strings" + + "github.com/databricks/cli/libs/apps/manifest" +) + +// Config holds configuration values collected from user prompts. +type Config struct { + ProjectName string + WorkspaceHost string + Profile string + // ResourceValues maps resource alias to its value (e.g., "warehouse" -> "abc123") + ResourceValues map[string]string +} + +// 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 { + // Required resources + for _, r := range p.Resources.Required { + varName := aliasToVarName(r.Alias) + lines = append(lines, fmt.Sprintf(" %s:", varName)) + if r.Description != "" { + lines = append(lines, " description: "+r.Description) + } + } + // Optional resources (only if value provided) + for _, r := range p.Resources.Optional { + if _, hasValue := cfg.ResourceValues[r.Alias]; hasValue { + varName := aliasToVarName(r.Alias) + lines = append(lines, fmt.Sprintf(" %s:", varName)) + if r.Description != "" { + lines = append(lines, " description: "+r.Description) + } + } + } + } + + return strings.Join(lines, "\n") +} + +// 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 _, hasValue := cfg.ResourceValues[r.Alias]; hasValue { + 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 { + // Required resources + for _, r := range p.Resources.Required { + varName := aliasToVarName(r.Alias) + value := cfg.ResourceValues[r.Alias] + if value != "" { + lines = append(lines, fmt.Sprintf(" %s: %s", varName, value)) + } + } + // Optional resources (only if value provided) + for _, r := range p.Resources.Optional { + value := cfg.ResourceValues[r.Alias] + if value != "" { + varName := aliasToVarName(r.Alias) + lines = append(lines, fmt.Sprintf(" %s: %s", varName, value)) + } + } + } + + return strings.Join(lines, "\n") +} + +// 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 { + // Required resources + for _, r := range p.Resources.Required { + if r.Env != "" { + value := cfg.ResourceValues[r.Alias] + lines = append(lines, fmt.Sprintf("%s=%s", r.Env, value)) + } + } + // Optional resources (only if value provided) + for _, r := range p.Resources.Optional { + value := cfg.ResourceValues[r.Alias] + if r.Env != "" && value != "" { + lines = append(lines, fmt.Sprintf("%s=%s", r.Env, value)) + } + } + } + + 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 { + // Required resources + for _, r := range p.Resources.Required { + if r.Env != "" { + placeholder := "your_" + strings.ToLower(r.Alias) + lines = append(lines, fmt.Sprintf("%s=%s", r.Env, placeholder)) + } + } + // Optional resources (commented out) + for _, r := range p.Resources.Optional { + if r.Env != "" { + placeholder := "your_" + strings.ToLower(r.Alias) + lines = append(lines, fmt.Sprintf("# %s=%s", r.Env, placeholder)) + } + } + } + + return strings.Join(lines, "\n") +} + +// generateResourceYAML generates YAML for a single resource based on its type. +// indent specifies the number of spaces to indent each line. +func generateResourceYAML(r manifest.Resource, indent int) string { + switch r.Type { + case "sql_warehouse": + return generateSQLWarehouseResource(r, indent) + default: + // Unknown resource type - skip + return "" + } +} + +// generateSQLWarehouseResource generates the app resource for a SQL warehouse. +func generateSQLWarehouseResource(r manifest.Resource, indent int) string { + varName := aliasToVarName(r.Alias) + permission := r.Permission + if permission == "" { + permission = "CAN_USE" + } + + pad := strings.Repeat(" ", indent) + return fmt.Sprintf(`%s- name: %s +%s sql_warehouse: +%s id: ${var.%s} +%s permission: %s`, pad, r.Alias, pad, pad, varName, pad, permission) +} + +// aliasToVarName converts a resource alias to a variable name. +// e.g., "warehouse" -> "warehouse_id" +func aliasToVarName(alias string) string { + if strings.HasSuffix(alias, "_id") { + return alias + } + return alias + "_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..1187ea23be --- /dev/null +++ b/libs/apps/generator/generator_test.go @@ -0,0 +1,280 @@ +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: "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: "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: "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: "warehouse"}, + }, + }, + }, + } + + cfg := generator.Config{ + ProjectName: "test-app", + ResourceValues: map[string]string{"warehouse": "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: "warehouse", Env: "DATABRICKS_WAREHOUSE_ID"}, + }, + }, + }, + } + + cfg := generator.Config{ + ProjectName: "test-app", + ResourceValues: map[string]string{"warehouse": "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: "warehouse", Env: "DATABRICKS_WAREHOUSE_ID"}, + }, + }, + }, + } + + result := generator.GenerateDotEnvExample(plugins) + assert.Equal(t, "DATABRICKS_WAREHOUSE_ID=your_warehouse", 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: "foo"}, + }, + }, + }, + } + + cfg := generator.Config{ + ProjectName: "test-app", + } + + // Unknown resource types are skipped for bundle resources + result := generator.GenerateBundleResources(plugins, cfg) + assert.Empty(t, result) + + // But variables are still generated + result = generator.GenerateBundleVariables(plugins, cfg) + assert.Contains(t, result, " foo_id:") +} + +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) { + plugins := []manifest.Plugin{ + { + Name: "analytics", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "sql_warehouse", Alias: "warehouse", Description: "Main warehouse"}, + }, + Optional: []manifest.Resource{ + {Type: "sql_warehouse", Alias: "secondary_warehouse", Description: "Secondary warehouse", Env: "SECONDARY_WAREHOUSE_ID"}, + }, + }, + }, + } + + // Config with only required resource + cfgRequiredOnly := generator.Config{ + ProjectName: "test-app", + ResourceValues: map[string]string{"warehouse": "wh123"}, + } + + // Config with both required and optional resources + cfgWithOptional := generator.Config{ + ProjectName: "test-app", + ResourceValues: map[string]string{"warehouse": "wh123", "secondary_warehouse": "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 TestGenerateDotEnvExampleWithOptional(t *testing.T) { + plugins := []manifest.Plugin{ + { + Name: "analytics", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "sql_warehouse", Alias: "warehouse", Env: "DATABRICKS_WAREHOUSE_ID"}, + }, + Optional: []manifest.Resource{ + {Type: "sql_warehouse", Alias: "secondary", Env: "SECONDARY_WAREHOUSE_ID"}, + }, + }, + }, + } + + result := generator.GenerateDotEnvExample(plugins) + // Required resources are shown normally + assert.Contains(t, result, "DATABRICKS_WAREHOUSE_ID=your_warehouse") + // Optional resources are commented out + assert.Contains(t, result, "# SECONDARY_WAREHOUSE_ID=your_secondary") +} diff --git a/libs/apps/manifest/manifest.go b/libs/apps/manifest/manifest.go new file mode 100644 index 0000000000..8a6eb95a05 --- /dev/null +++ b/libs/apps/manifest/manifest.go @@ -0,0 +1,168 @@ +package manifest + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +const ManifestFileName = "appkit.plugins.json" + +// 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"` // e.g., "warehouse" + Description string `json:"description"` // e.g., "SQL Warehouse for executing analytics queries" + Permission string `json:"permission"` // e.g., "CAN_USE" + Env string `json:"env"` // e.g., "DATABRICKS_WAREHOUSE_ID" +} + +// 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"` + 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 that have resources (can be selected/configured). +// Plugins without resources (like a base server) are considered always-on. +func (m *Manifest) GetSelectablePlugins() []Plugin { + var selectable []Plugin + for _, p := range m.GetPlugins() { + if len(p.Resources.Required) > 0 || len(p.Resources.Optional) > 0 { + selectable = append(selectable, p) + } + } + return selectable +} + +// 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 { + key := r.Type + ":" + r.Alias + 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 { + key := r.Type + ":" + r.Alias + 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..4e073daef9 --- /dev/null +++ b/libs/apps/manifest/manifest_test.go @@ -0,0 +1,229 @@ +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": "warehouse", + "description": "SQL Warehouse", + "permission": "CAN_USE", + "env": "DATABRICKS_WAREHOUSE_ID" + } + ], + "optional": [] + } + }, + "server": { + "name": "server", + "displayName": "Server Plugin", + "description": "HTTP server", + "package": "@databricks/appkit", + "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) +} + +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", + Resources: manifest.Resources{ + Required: []manifest.Resource{}, + Optional: []manifest.Resource{}, + }, + }, + "analytics": { + Name: "analytics", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "sql_warehouse", Alias: "warehouse"}, + }, + Optional: []manifest.Resource{}, + }, + }, + }, + } + + selectable := m.GetSelectablePlugins() + require.Len(t, selectable, 1) + assert.Equal(t, "analytics", selectable[0].Name) +} + +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: "warehouse", Env: "DATABRICKS_WAREHOUSE_ID"}, + }, + }, + }, + "genie": { + Name: "genie", + Resources: manifest.Resources{ + Required: []manifest.Resource{ + {Type: "sql_warehouse", Alias: "warehouse", Env: "DATABRICKS_WAREHOUSE_ID"}, + {Type: "genie_space", Alias: "genie", Env: "GENIE_SPACE_ID"}, + }, + }, + }, + }, + } + + 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 + resources = m.CollectResources([]string{"analytics", "genie"}) + require.Len(t, resources, 2) +} + +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", Env: "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..0df2cc6fb8 --- /dev/null +++ b/libs/apps/prompt/listers.go @@ -0,0 +1,336 @@ +package prompt + +import ( + "context" + "errors" + "fmt" + "strconv" + + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/listing" + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/databricks/databricks-sdk-go/service/catalog" + "github.com/databricks/databricks-sdk-go/service/dashboards" + "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" +) + +// ListItem is a generic item for resource pickers (id and display label). +type ListItem struct { + ID string + Label string +} + +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 +} + +// ListSecrets returns secret scopes as selectable items (id = scope name). +func ListSecrets(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 +} + +// 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 +} + +// ListVolumes returns UC volumes as selectable items (id = full name catalog.schema.volume). +func ListVolumes(ctx context.Context) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + var out []ListItem + catIter := w.Catalogs.List(ctx, catalog.ListCatalogsRequest{}) + catalogs, err := listing.ToSlice(ctx, catIter) + if err != nil { + return nil, err + } + const maxSchemas = 50 + for _, cat := range catalogs { + schemaIter := w.Schemas.List(ctx, catalog.ListSchemasRequest{CatalogName: cat.Name}) + schemas, err := listing.ToSlice(ctx, schemaIter) + if err != nil { + continue + } + for _, sch := range schemas { + volIter := w.Volumes.List(ctx, catalog.ListVolumesRequest{ + CatalogName: cat.Name, + SchemaName: sch.Name, + }) + vols, err := listing.ToSlice(ctx, volIter) + if err != nil { + continue + } + for _, v := range vols { + fullName := fmt.Sprintf("%s.%s.%s", cat.Name, sch.Name, v.Name) + out = append(out, ListItem{ID: fullName, Label: fullName}) + } + } + if len(out) >= maxSchemas*10 { + break + } + } + return 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 { + 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 +} + +// ListFunctions returns UC functions as selectable items (id = full name). +func ListFunctions(ctx context.Context) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + var out []ListItem + catIter := w.Catalogs.List(ctx, catalog.ListCatalogsRequest{}) + catalogs, err := listing.ToSlice(ctx, catIter) + if err != nil { + return nil, err + } + for _, cat := range catalogs { + schemaIter := w.Schemas.List(ctx, catalog.ListSchemasRequest{CatalogName: cat.Name}) + schemas, err := listing.ToSlice(ctx, schemaIter) + if err != nil { + continue + } + for _, sch := range schemas { + fnIter := w.Functions.List(ctx, catalog.ListFunctionsRequest{ + CatalogName: cat.Name, + SchemaName: sch.Name, + }) + fns, err := listing.ToSlice(ctx, fnIter) + if err != nil { + continue + } + for _, f := range fns { + fullName := f.FullName + if fullName == "" { + fullName = fmt.Sprintf("%s.%s.%s", cat.Name, sch.Name, f.Name) + } + out = append(out, ListItem{ID: fullName, Label: fullName}) + } + } + } + return 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 +} + +// ListDatabases returns UC catalogs as selectable items (id = catalog name). +func ListDatabases(ctx context.Context) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.Catalogs.List(ctx, catalog.ListCatalogsRequest{}) + catalogs, err := listing.ToSlice(ctx, iter) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, len(catalogs)) + for _, c := range catalogs { + out = append(out, ListItem{ID: c.Name, Label: c.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 + } + resp, err := w.Genie.ListSpaces(ctx, dashboards.GenieListSpacesRequest{}) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, len(resp.Spaces)) + 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}) + } + 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 +} + +// 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 + if a.Description != "" { + label = a.Name + " — " + a.Description + } + 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..3200b83f95 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,214 +214,136 @@ 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) - // 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). - WithTheme(theme). - Run() - if err != nil { - return nil, 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)) - } + iter := w.Warehouses.List(ctx, sql.ListWarehousesRequest{}) + return listing.ToSlice(ctx, iter) +} - 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))) +// 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) { + if len(items) == 0 { + if required { + return "", errors.New(emptyMessage) } + return "", nil } - - // 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 + 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 } - - // Step 4: Description - config.Description = DefaultAppDescription - err = huh.NewInput(). - Title("Description"). - Placeholder(DefaultAppDescription). - Value(&config.Description). + 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 - } - if config.Description == "" { - config.Description = DefaultAppDescription + return "", err } - printAnswered(ctx, "Description", config.Description) + printAnswered(ctx, title, labels[selected]) + return selected, 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() +// 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 nil, err - } - if config.Deploy { - printAnswered(ctx, "Deploy after creation", "Yes") - } else { - printAnswered(ctx, "Deploy after creation", "No") + 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 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() +// promptForResourceFromLister runs a spinner, fetches items via fn, then shows PromptFromList. +func promptForResourceFromLister(ctx context.Context, _ manifest.Resource, required bool, title, emptyMsg, spinnerMsg string, fn func(context.Context) ([]ListItem, error)) (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 + return "", err } - config.RunMode = RunMode(runModeStr) + return PromptFromList(ctx, title, emptyMsg, items, required) +} - runModeLabels := map[string]string{ - string(RunModeNone): "No", - string(RunModeDev): "Yes (local)", - string(RunModeDevRemote): "Yes (remote)", - } - printAnswered(ctx, "Run after creation", runModeLabels[runModeStr]) +// PromptForSecret shows a picker for secret scopes. +func PromptForSecret(ctx context.Context, r manifest.Resource, required bool) (string, error) { + return promptForResourceFromLister(ctx, r, required, "Select Secret Scope", "no secret scopes found", "Fetching secret scopes...", ListSecrets) +} - return config, nil +// PromptForJob shows a picker for jobs. +func PromptForJob(ctx context.Context, r manifest.Resource, required bool) (string, error) { + return promptForResourceFromLister(ctx, r, required, "Select Job", "no jobs found", "Fetching jobs...", ListJobs) } -// 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") - } +// PromptForSQLWarehouseResource shows a picker for SQL warehouses (manifest.Resource version). +func PromptForSQLWarehouseResource(ctx context.Context, r manifest.Resource, required bool) (string, error) { + return promptForResourceFromLister(ctx, r, required, "Select SQL Warehouse", "no SQL warehouses found. Create one in your workspace first", "Fetching SQL warehouses...", ListSQLWarehousesItems) +} - 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) (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 { - var fetchErr error - warehouses, fetchErr = ListSQLWarehouses(ctx) - return fetchErr - }) - if err != nil { - return "", fmt.Errorf("failed to fetch SQL warehouses: %w", err) - } +// PromptForVolume shows a picker for UC volumes. +func PromptForVolume(ctx context.Context, r manifest.Resource, required bool) (string, error) { + return promptForResourceFromLister(ctx, r, required, "Select Volume", "no volumes found", "Fetching volumes...", ListVolumes) +} - if len(warehouses) == 0 { - return "", errors.New("no SQL warehouses found. Create one in your workspace first") - } +// PromptForVectorSearchIndex shows a picker for vector search indexes. +func PromptForVectorSearchIndex(ctx context.Context, r manifest.Resource, required bool) (string, error) { + return promptForResourceFromLister(ctx, r, required, "Select Vector Search Index", "no vector search indexes found", "Fetching vector search indexes...", ListVectorSearchIndexes) +} - theme := AppkitTheme() +// PromptForUCFunction shows a picker for UC functions. +func PromptForUCFunction(ctx context.Context, r manifest.Resource, required bool) (string, error) { + return promptForResourceFromLister(ctx, r, required, "Select UC Function", "no functions found", "Fetching functions...", ListFunctions) +} - // 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 - } +// PromptForUCConnection shows a picker for UC connections. +func PromptForUCConnection(ctx context.Context, r manifest.Resource, required bool) (string, error) { + return promptForResourceFromLister(ctx, r, required, "Select UC Connection", "no connections found", "Fetching connections...", ListConnections) +} - 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() - if err != nil { - return "", err - } +// PromptForDatabase shows a picker for UC catalogs (databases). +func PromptForDatabase(ctx context.Context, r manifest.Resource, required bool) (string, error) { + return promptForResourceFromLister(ctx, r, required, "Select Database (Catalog)", "no catalogs found", "Fetching catalogs...", ListDatabases) +} - printAnswered(ctx, "SQL Warehouse", warehouseNames[selected]) - return selected, nil +// PromptForGenieSpace shows a picker for Genie spaces. +func PromptForGenieSpace(ctx context.Context, r manifest.Resource, required bool) (string, error) { + return promptForResourceFromLister(ctx, r, required, "Select Genie Space", "no Genie spaces found", "Fetching Genie spaces...", ListGenieSpaces) +} + +// PromptForExperiment shows a picker for MLflow experiments. +func PromptForExperiment(ctx context.Context, r manifest.Resource, required bool) (string, error) { + return promptForResourceFromLister(ctx, r, required, "Select Experiment", "no experiments found", "Fetching experiments...", ListExperiments) +} + +// PromptForAppResource shows a picker for apps (manifest.Resource version). +func PromptForAppResource(ctx context.Context, r manifest.Resource, required bool) (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. diff --git a/libs/apps/prompt/resource_registry.go b/libs/apps/prompt/resource_registry.go new file mode 100644 index 0000000000..a7c4272bed --- /dev/null +++ b/libs/apps/prompt/resource_registry.go @@ -0,0 +1,58 @@ +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" + ResourceTypeApp = "app" +) + +// PromptResourceFunc prompts the user for a resource of a given type and returns the selected ID. +type PromptResourceFunc func(ctx context.Context, r manifest.Resource, required bool) (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 + case ResourceTypeApp: + return PromptForAppResource, true + default: + return nil, false + } +} From 611ac0423b99b625fff79cca4c8d1c3ff34533b0 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 10 Feb 2026 18:35:24 +0100 Subject: [PATCH 2/4] chore: fixup --- cmd/apps/init.go | 55 ++- libs/apps/generator/generator.go | 312 ++++++++++++---- libs/apps/generator/generator_test.go | 509 +++++++++++++++++++++++++- libs/apps/manifest/manifest.go | 57 ++- libs/apps/manifest/manifest_test.go | 81 +++- libs/apps/prompt/listers.go | 151 +++++--- libs/apps/prompt/prompt.go | 141 +++++-- libs/apps/prompt/resource_registry.go | 15 +- 8 files changed, 1133 insertions(+), 188 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 7b9269c0e5..883bc7437f 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -246,22 +246,24 @@ func promptForPluginsAndDeps(ctx context.Context, m *manifest.Manifest, preSelec // Step 2: Prompt for required plugin resource dependencies resources := m.CollectResources(config.Features) for _, r := range resources { - value, err := promptForResource(ctx, r, theme, true) + values, err := promptForResource(ctx, r, theme, true) if err != nil { return nil, err } - config.Dependencies[r.Alias] = value + for k, v := range values { + config.Dependencies[k] = v + } } // Step 3: Prompt for optional plugin resource dependencies optionalResources := m.CollectOptionalResources(config.Features) for _, r := range optionalResources { - value, err := promptForResource(ctx, r, theme, false) + values, err := promptForResource(ctx, r, theme, false) if err != nil { return nil, err } - if value != "" { - config.Dependencies[r.Alias] = value + for k, v := range values { + config.Dependencies[k] = v } } @@ -293,8 +295,9 @@ func promptForPluginsAndDeps(ctx context.Context, m *manifest.Manifest, preSelec } // promptForResource prompts the user for a resource value. -// If required is true, the user must provide a value. Otherwise, they can skip. -func promptForResource(ctx context.Context, r manifest.Resource, theme *huh.Theme, required bool) (string, error) { +// 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 @@ -305,11 +308,11 @@ func promptForResource(ctx context.Context, r manifest.Resource, theme *huh.Them WithTheme(theme). Run() if err != nil { - return "", err + return nil, err } if !configure { prompt.PrintAnswered(ctx, r.Alias, "skipped") - return "", nil + return nil, nil } } return fn(ctx, r, required) @@ -337,15 +340,21 @@ func promptForResource(ctx context.Context, r manifest.Resource, theme *huh.Them } if err := input.WithTheme(theme).Run(); err != nil { - return "", err + return nil, err } if value == "" && !required { prompt.PrintAnswered(ctx, r.Alias, "skipped") - } else { - prompt.PrintAnswered(ctx, r.Alias, value) + return nil, nil } - return value, 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 map[string]string{r.Key(): value}, nil } // cloneRepo clones a git repository to a temporary directory. @@ -532,7 +541,7 @@ func runCreate(ctx context.Context, opts createOptions) error { } // Get warehouse from resourceValues if provided - if wh, ok := resourceValues["warehouse"]; ok && wh != "" { + if wh, ok := resourceValues["sql-warehouse.id"]; ok && wh != "" { opts.warehouseID = wh } } else if isInteractive && opts.pluginsChanged && !flagsMode { @@ -544,7 +553,7 @@ func runCreate(ctx context.Context, opts createOptions) error { } resourceValues = make(map[string]string) if opts.warehouseID != "" { - resourceValues["warehouse"] = opts.warehouseID + resourceValues["sql-warehouse.id"] = opts.warehouseID } // Prompt for deploy/run if no flags were set @@ -564,14 +573,22 @@ func runCreate(ctx context.Context, opts createOptions) error { } resourceValues = make(map[string]string) if opts.warehouseID != "" { - resourceValues["warehouse"] = opts.warehouseID + resourceValues["sql-warehouse.id"] = opts.warehouseID } - // Validate required resources are provided + // Validate required resources are provided. + // All resource value keys use "resource_key.field_name" format. resources := m.CollectResources(selectedPlugins) for _, r := range resources { - if _, ok := resourceValues[r.Alias]; !ok { - return fmt.Errorf("missing required resource %q for selected plugins (use --%s flag)", r.Alias, r.Alias+"-id") + found := false + for k := range resourceValues { + if strings.HasPrefix(k, r.Key()+".") { + found = true + break + } + } + if !found { + return fmt.Errorf("missing required resource %q for selected plugins (use --%s-id flag)", r.Alias, r.Key()) } } } diff --git a/libs/apps/generator/generator.go b/libs/apps/generator/generator.go index ddabd77e76..cabc63a0d3 100644 --- a/libs/apps/generator/generator.go +++ b/libs/apps/generator/generator.go @@ -12,10 +12,21 @@ type Config struct { ProjectName string WorkspaceHost string Profile string - // ResourceValues maps resource alias to its value (e.g., "warehouse" -> "abc123") + // 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. @@ -23,22 +34,12 @@ func GenerateBundleVariables(plugins []manifest.Plugin, cfg Config) string { var lines []string for _, p := range plugins { - // Required resources for _, r := range p.Resources.Required { - varName := aliasToVarName(r.Alias) - lines = append(lines, fmt.Sprintf(" %s:", varName)) - if r.Description != "" { - lines = append(lines, " description: "+r.Description) - } + lines = append(lines, generateVariableLines(r)...) } - // Optional resources (only if value provided) for _, r := range p.Resources.Optional { - if _, hasValue := cfg.ResourceValues[r.Alias]; hasValue { - varName := aliasToVarName(r.Alias) - lines = append(lines, fmt.Sprintf(" %s:", varName)) - if r.Description != "" { - lines = append(lines, " description: "+r.Description) - } + if hasResourceValues(r, cfg) { + lines = append(lines, generateVariableLines(r)...) } } } @@ -46,6 +47,26 @@ func GenerateBundleVariables(plugins []manifest.Plugin, cfg Config) string { 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. @@ -62,7 +83,7 @@ func GenerateBundleResources(plugins []manifest.Plugin, cfg Config) string { } // Optional resources (only if value provided) for _, r := range p.Resources.Optional { - if _, hasValue := cfg.ResourceValues[r.Alias]; hasValue { + if hasResourceValues(r, cfg) { resource := generateResourceYAML(r, 8) if resource != "" { blocks = append(blocks, resource) @@ -81,20 +102,12 @@ func GenerateTargetVariables(plugins []manifest.Plugin, cfg Config) string { var lines []string for _, p := range plugins { - // Required resources for _, r := range p.Resources.Required { - varName := aliasToVarName(r.Alias) - value := cfg.ResourceValues[r.Alias] - if value != "" { - lines = append(lines, fmt.Sprintf(" %s: %s", varName, value)) - } + lines = append(lines, generateTargetVarLines(r, cfg)...) } - // Optional resources (only if value provided) for _, r := range p.Resources.Optional { - value := cfg.ResourceValues[r.Alias] - if value != "" { - varName := aliasToVarName(r.Alias) - lines = append(lines, fmt.Sprintf(" %s: %s", varName, value)) + if hasResourceValues(r, cfg) { + lines = append(lines, generateTargetVarLines(r, cfg)...) } } } @@ -102,24 +115,62 @@ func GenerateTargetVariables(plugins []manifest.Plugin, cfg Config) string { 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, value)) + } + } + return lines +} + +// dotEnvActualLines returns .env lines with actual values from cfg. +func dotEnvActualLines(r manifest.Resource, cfg Config) []string { + var lines []string + for _, fieldName := range r.FieldNames() { + field := r.Fields[fieldName] + if field.Env == "" { + continue + } + value := cfg.ResourceValues[r.Key()+"."+fieldName] + lines = append(lines, fmt.Sprintf("%s=%s", field.Env, value)) + } + return lines +} + +// dotEnvExampleLines returns .env.example lines with placeholders. +func dotEnvExampleLines(r manifest.Resource, commented bool) []string { + var lines []string + for _, fieldName := range r.FieldNames() { + field := r.Fields[fieldName] + if 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 { - // Required resources for _, r := range p.Resources.Required { - if r.Env != "" { - value := cfg.ResourceValues[r.Alias] - lines = append(lines, fmt.Sprintf("%s=%s", r.Env, value)) - } + lines = append(lines, dotEnvActualLines(r, cfg)...) } - // Optional resources (only if value provided) for _, r := range p.Resources.Optional { - value := cfg.ResourceValues[r.Alias] - if r.Env != "" && value != "" { - lines = append(lines, fmt.Sprintf("%s=%s", r.Env, value)) + if hasResourceValues(r, cfg) { + lines = append(lines, dotEnvActualLines(r, cfg)...) } } } @@ -133,59 +184,190 @@ func GenerateDotEnvExample(plugins []manifest.Plugin) string { var lines []string for _, p := range plugins { - // Required resources for _, r := range p.Resources.Required { - if r.Env != "" { - placeholder := "your_" + strings.ToLower(r.Alias) - lines = append(lines, fmt.Sprintf("%s=%s", r.Env, placeholder)) - } + lines = append(lines, dotEnvExampleLines(r, false)...) } - // Optional resources (commented out) for _, r := range p.Resources.Optional { - if r.Env != "" { - placeholder := "your_" + strings.ToLower(r.Alias) - lines = append(lines, fmt.Sprintf("# %s=%s", r.Env, placeholder)) - } + lines = append(lines, dotEnvExampleLines(r, true)...) } } return strings.Join(lines, "\n") } -// generateResourceYAML generates YAML for a single resource based on its type. -// indent specifies the number of spaces to indent each line. +// defaultPermissions maps resource type to its default permission when none is specified. +var defaultPermissions = map[string]string{ + "sql_warehouse": "CAN_USE", + "job": "CAN_MANAGE_RUN", + "serving_endpoint": "CAN_QUERY", + "secret": "READ", + "experiment": "CAN_READ", + "database": "CAN_CONNECT_AND_CREATE", + "genie_space": "CAN_VIEW", + "volume": "READ_VOLUME", + "uc_function": "EXECUTE", + "uc_connection": "USE_CONNECTION", + "vector_search_index": "CAN_USE", + // TODO: uncomment when bundles support app as an app resource type. + // "app": "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 +} + +// singleVarName returns the variable name for a single-field resource. +// Uses the first field from Fields, or falls back to varPrefix_id. +func singleVarName(r manifest.Resource) string { + names := r.FieldNames() + if len(names) > 0 { + return varNameForField(r, names[0]) + } + return aliasToVarName(r.VarPrefix()) +} + +// variableNamesForResource returns the variable names that a resource type needs. +// Variable names are derived from VarPrefix (resource_key with hyphens as underscores). +// Value keys use Key() (resource_key) with field names. +func variableNamesForResource(r manifest.Resource) []varInfo { + var vars []varInfo + 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, + }) + } + if len(vars) > 0 { + return vars + } + // Fallback for resources without explicit Fields. + return []varInfo{ + {name: aliasToVarName(r.VarPrefix()), description: r.Description, valueKey: r.Key()}, + } +} + +// generateResourceYAML generates YAML for a single app resource based on its type. +// Each resource type has its own field structure per the Databricks Apps schema. +// Variable references are derived from the resource's Fields via singleVarName/varNameForField. func generateResourceYAML(r manifest.Resource, indent int) string { - switch r.Type { - case "sql_warehouse": - return generateSQLWarehouseResource(r, indent) - default: - // Unknown resource type - skip + if r.Type == "" { return "" } -} -// generateSQLWarehouseResource generates the app resource for a SQL warehouse. -func generateSQLWarehouseResource(r manifest.Resource, indent int) string { - varName := aliasToVarName(r.Alias) permission := r.Permission if permission == "" { - permission = "CAN_USE" + if def, ok := defaultPermissions[r.Type]; ok { + permission = def + } else { + permission = "CAN_USE" + } } pad := strings.Repeat(" ", indent) - return fmt.Sprintf(`%s- name: %s + + key := r.Key() + + switch r.Type { + case "sql_warehouse": + return fmt.Sprintf(`%s- name: %s %s sql_warehouse: %s id: ${var.%s} -%s permission: %s`, pad, r.Alias, pad, pad, varName, pad, permission) +%s permission: %s`, pad, key, pad, pad, singleVarName(r), pad, permission) + + case "job": + return fmt.Sprintf(`%s- name: %s +%s job: +%s id: ${var.%s} +%s permission: %s`, pad, key, pad, pad, singleVarName(r), pad, permission) + + case "serving_endpoint": + return fmt.Sprintf(`%s- name: %s +%s serving_endpoint: +%s name: ${var.%s} +%s permission: %s`, pad, key, pad, pad, singleVarName(r), pad, permission) + + case "experiment": + return fmt.Sprintf(`%s- name: %s +%s experiment: +%s experiment_id: ${var.%s} +%s permission: %s`, pad, key, pad, pad, singleVarName(r), pad, permission) + + case "secret": + return fmt.Sprintf(`%s- name: %s +%s secret: +%s scope: ${var.%s} +%s key: ${var.%s} +%s permission: %s`, pad, key, pad, pad, varNameForField(r, "scope"), pad, varNameForField(r, "key"), pad, permission) + + case "database": + return fmt.Sprintf(`%s- name: %s +%s database: +%s instance_name: ${var.%s} +%s database_name: ${var.%s} +%s permission: %s`, pad, key, pad, pad, varNameForField(r, "instance_name"), pad, varNameForField(r, "database_name"), pad, permission) + + case "genie_space": + return fmt.Sprintf(`%s- name: %s +%s genie_space: +%s name: %s +%s space_id: ${var.%s} +%s permission: %s`, pad, key, pad, pad, r.Alias, pad, varNameForField(r, "space_id"), pad, permission) + + case "volume", "uc_function", "uc_connection": + securableType := ucSecurableType(r.Type) + return fmt.Sprintf(`%s- name: %s +%s uc_securable: +%s securable_full_name: ${var.%s} +%s securable_type: %s +%s permission: %s`, pad, key, pad, pad, singleVarName(r), pad, securableType, pad, permission) + + case "vector_search_index": + return fmt.Sprintf(`%s- name: %s +%s vector_search_index: +%s id: ${var.%s} +%s permission: %s`, pad, key, pad, pad, singleVarName(r), pad, permission) + + // TODO: uncomment when bundles support app as an app resource type. + // case "app": + // return fmt.Sprintf(`%s- name: %s + // %s app: + // %s name: ${var.%s} + // %s permission: %s`, pad, key, pad, pad, singleVarName(r), pad, permission) + + default: + return "" + } +} + +// ucSecurableType maps a manifest resource type to the uc_securable securable_type value. +func ucSecurableType(resourceType string) string { + switch resourceType { + case "volume": + return "VOLUME" + case "uc_function": + return "FUNCTION" + case "uc_connection": + return "CONNECTION" + default: + return "" + } } -// aliasToVarName converts a resource alias to a variable name. -// e.g., "warehouse" -> "warehouse_id" -func aliasToVarName(alias string) string { - if strings.HasSuffix(alias, "_id") { - return alias +// 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 alias + "_id" + return prefix + "_id" } // GetSelectedPlugins returns plugins that match the given names. diff --git a/libs/apps/generator/generator_test.go b/libs/apps/generator/generator_test.go index 1187ea23be..f45fb1a9ae 100644 --- a/libs/apps/generator/generator_test.go +++ b/libs/apps/generator/generator_test.go @@ -14,7 +14,7 @@ func TestGenerateBundleVariables(t *testing.T) { Name: "analytics", Resources: manifest.Resources{ Required: []manifest.Resource{ - {Type: "sql_warehouse", Alias: "warehouse", Description: "SQL Warehouse for queries"}, + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "warehouse", Description: "SQL Warehouse for queries"}, }, }, }, @@ -36,7 +36,7 @@ func TestGenerateBundleResources(t *testing.T) { Name: "analytics", Resources: manifest.Resources{ Required: []manifest.Resource{ - {Type: "sql_warehouse", Alias: "warehouse", Permission: "CAN_USE"}, + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "warehouse", Permission: "CAN_USE"}, }, }, }, @@ -60,7 +60,7 @@ func TestGenerateBundleResourcesDefaultPermission(t *testing.T) { Name: "analytics", Resources: manifest.Resources{ Required: []manifest.Resource{ - {Type: "sql_warehouse", Alias: "warehouse"}, + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "warehouse"}, }, }, }, @@ -81,7 +81,7 @@ func TestGenerateTargetVariables(t *testing.T) { Name: "analytics", Resources: manifest.Resources{ Required: []manifest.Resource{ - {Type: "sql_warehouse", Alias: "warehouse"}, + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "warehouse"}, }, }, }, @@ -102,7 +102,12 @@ func TestGenerateDotEnv(t *testing.T) { Name: "analytics", Resources: manifest.Resources{ Required: []manifest.Resource{ - {Type: "sql_warehouse", Alias: "warehouse", Env: "DATABRICKS_WAREHOUSE_ID"}, + { + Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "warehouse", + Fields: map[string]manifest.ResourceField{ + "id": {Env: "DATABRICKS_WAREHOUSE_ID"}, + }, + }, }, }, }, @@ -110,7 +115,7 @@ func TestGenerateDotEnv(t *testing.T) { cfg := generator.Config{ ProjectName: "test-app", - ResourceValues: map[string]string{"warehouse": "abc123"}, + ResourceValues: map[string]string{"warehouse.id": "abc123"}, } result := generator.GenerateDotEnv(plugins, cfg) @@ -123,14 +128,19 @@ func TestGenerateDotEnvExample(t *testing.T) { Name: "analytics", Resources: manifest.Resources{ Required: []manifest.Resource{ - {Type: "sql_warehouse", Alias: "warehouse", Env: "DATABRICKS_WAREHOUSE_ID"}, + { + 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", result) + assert.Equal(t, "DATABRICKS_WAREHOUSE_ID=your_warehouse_id", result) } func TestGenerateEmptyPlugins(t *testing.T) { @@ -152,7 +162,7 @@ func TestGenerateUnknownResourceType(t *testing.T) { Name: "unknown", Resources: manifest.Resources{ Required: []manifest.Resource{ - {Type: "unknown_type", Alias: "foo"}, + {Type: "unknown_type", Alias: "Unknown", ResourceKey: "foo"}, }, }, }, @@ -162,15 +172,104 @@ func TestGenerateUnknownResourceType(t *testing.T) { ProjectName: "test-app", } - // Unknown resource types are skipped for bundle resources + // Unknown resource types produce no resource block result := generator.GenerateBundleResources(plugins, cfg) assert.Empty(t, result) - // But variables are still generated + // 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", "CAN_USE"}, + // 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{ @@ -192,15 +291,18 @@ func TestGetSelectedPlugins(t *testing.T) { } 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: "warehouse", Description: "Main warehouse"}, + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "warehouse", Description: "Main warehouse", Fields: whFields}, }, Optional: []manifest.Resource{ - {Type: "sql_warehouse", Alias: "secondary_warehouse", Description: "Secondary warehouse", Env: "SECONDARY_WAREHOUSE_ID"}, + {Type: "sql_warehouse", Alias: "Secondary Warehouse", ResourceKey: "secondary_warehouse", Description: "Secondary warehouse", Fields: secFields}, }, }, }, @@ -209,13 +311,13 @@ func TestGenerateWithOptionalResources(t *testing.T) { // Config with only required resource cfgRequiredOnly := generator.Config{ ProjectName: "test-app", - ResourceValues: map[string]string{"warehouse": "wh123"}, + 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": "wh123", "secondary_warehouse": "wh456"}, + ResourceValues: map[string]string{"warehouse.id": "wh123", "secondary_warehouse.id": "wh456"}, } // Test bundle variables - required only @@ -257,16 +359,385 @@ func TestGenerateWithOptionalResources(t *testing.T) { 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: Genie Space", + "space_id: ${var.genie_space_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 uses id field", + resource: manifest.Resource{Type: "vector_search_index", Alias: "Vector Search Index", ResourceKey: "vector-search-index", Permission: "CAN_USE"}, + expectContains: []string{ + "- name: vector-search-index", + "vector_search_index:", + "id: ${var.vector_search_index_id}", + "permission: CAN_USE", + }, + }, + // 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{ + "space_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.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 one variable (space_id) using VarPrefix + assert.Contains(t, vars, "genie_space_space_id:") + assert.NotContains(t, vars, "genie_space_id:") + + // 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: "warehouse", Env: "DATABRICKS_WAREHOUSE_ID"}, + { + 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", Env: "SECONDARY_WAREHOUSE_ID"}, + { + Type: "sql_warehouse", Alias: "Secondary Warehouse", ResourceKey: "secondary", + Fields: map[string]manifest.ResourceField{"id": {Env: "SECONDARY_WAREHOUSE_ID"}}, + }, }, }, }, @@ -274,7 +745,7 @@ func TestGenerateDotEnvExampleWithOptional(t *testing.T) { result := generator.GenerateDotEnvExample(plugins) // Required resources are shown normally - assert.Contains(t, result, "DATABRICKS_WAREHOUSE_ID=your_warehouse") + assert.Contains(t, result, "DATABRICKS_WAREHOUSE_ID=your_warehouse_id") // Optional resources are commented out - assert.Contains(t, result, "# SECONDARY_WAREHOUSE_ID=your_secondary") + assert.Contains(t, result, "# SECONDARY_WAREHOUSE_ID=your_secondary_id") } diff --git a/libs/apps/manifest/manifest.go b/libs/apps/manifest/manifest.go index 8a6eb95a05..425401dade 100644 --- a/libs/apps/manifest/manifest.go +++ b/libs/apps/manifest/manifest.go @@ -6,17 +6,52 @@ import ( "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"` // e.g., "warehouse" - Description string `json:"description"` // e.g., "SQL Warehouse for executing analytics queries" - Permission string `json:"permission"` // e.g., "CAN_USE" - Env string `json:"env"` // e.g., "DATABRICKS_WAREHOUSE_ID" + Type string `json:"type"` // e.g., "sql_warehouse" + Alias string `json:"alias"` // display name, e.g., "SQL Warehouse" + ResourceKey string `json:"resource_key"` // 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. @@ -134,7 +169,11 @@ func (m *Manifest) CollectResources(pluginNames []string) []Resource { continue } for _, r := range plugin.Resources.Required { - key := r.Type + ":" + r.Alias + // 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) @@ -156,7 +195,11 @@ func (m *Manifest) CollectOptionalResources(pluginNames []string) []Resource { continue } for _, r := range plugin.Resources.Optional { - key := r.Type + ":" + r.Alias + // 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) diff --git a/libs/apps/manifest/manifest_test.go b/libs/apps/manifest/manifest_test.go index 4e073daef9..d9a25f0b71 100644 --- a/libs/apps/manifest/manifest_test.go +++ b/libs/apps/manifest/manifest_test.go @@ -27,10 +27,13 @@ func TestLoad(t *testing.T) { "required": [ { "type": "sql_warehouse", - "alias": "warehouse", + "alias": "SQL Warehouse", + "resource_key": "sql-warehouse", "description": "SQL Warehouse", "permission": "CAN_USE", - "env": "DATABRICKS_WAREHOUSE_ID" + "fields": { + "id": {"env": "DATABRICKS_WAREHOUSE_ID", "description": "SQL Warehouse ID"} + } } ], "optional": [] @@ -116,7 +119,7 @@ func TestGetSelectablePlugins(t *testing.T) { Name: "analytics", Resources: manifest.Resources{ Required: []manifest.Resource{ - {Type: "sql_warehouse", Alias: "warehouse"}, + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "sql-warehouse"}, }, Optional: []manifest.Resource{}, }, @@ -184,7 +187,7 @@ func TestCollectResources(t *testing.T) { Name: "analytics", Resources: manifest.Resources{ Required: []manifest.Resource{ - {Type: "sql_warehouse", Alias: "warehouse", Env: "DATABRICKS_WAREHOUSE_ID"}, + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "sql-warehouse"}, }, }, }, @@ -192,8 +195,8 @@ func TestCollectResources(t *testing.T) { Name: "genie", Resources: manifest.Resources{ Required: []manifest.Resource{ - {Type: "sql_warehouse", Alias: "warehouse", Env: "DATABRICKS_WAREHOUSE_ID"}, - {Type: "genie_space", Alias: "genie", Env: "GENIE_SPACE_ID"}, + {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "sql-warehouse"}, + {Type: "genie_space", Alias: "Genie Space", ResourceKey: "genie-space"}, }, }, }, @@ -204,11 +207,73 @@ func TestCollectResources(t *testing.T) { require.Len(t, resources, 1) assert.Equal(t, "sql_warehouse", resources[0].Type) - // Collect from both - warehouse should be deduplicated + // 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", + "resource_key": "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{ @@ -216,7 +281,7 @@ func TestCollectOptionalResources(t *testing.T) { Name: "analytics", Resources: manifest.Resources{ Optional: []manifest.Resource{ - {Type: "catalog", Alias: "default_catalog", Env: "DEFAULT_CATALOG"}, + {Type: "catalog", Alias: "Default Catalog", ResourceKey: "default-catalog"}, }, }, }, diff --git a/libs/apps/prompt/listers.go b/libs/apps/prompt/listers.go index 0df2cc6fb8..a1171ca4fe 100644 --- a/libs/apps/prompt/listers.go +++ b/libs/apps/prompt/listers.go @@ -4,18 +4,21 @@ import ( "context" "errors" "fmt" + "net/http" "strconv" "github.com/databricks/cli/libs/cmdctx" "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/apps" "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" ) // ListItem is a generic item for resource pickers (id and display label). @@ -32,8 +35,8 @@ func workspaceClient(ctx context.Context) (*databricks.WorkspaceClient, error) { return w, nil } -// ListSecrets returns secret scopes as selectable items (id = scope name). -func ListSecrets(ctx context.Context) ([]ListItem, error) { +// ListSecretScopes returns secret scopes as selectable items. +func ListSecretScopes(ctx context.Context) ([]ListItem, error) { w, err := workspaceClient(ctx) if err != nil { return nil, err @@ -50,6 +53,24 @@ func ListSecrets(ctx context.Context) ([]ListItem, error) { 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) @@ -119,6 +140,7 @@ func ListServingEndpoints(ctx context.Context) ([]ListItem, error) { // ListVolumes returns UC volumes as selectable items (id = full name catalog.schema.volume). func ListVolumes(ctx context.Context) ([]ListItem, error) { + // TODO: this might be better to just use the path. w, err := workspaceClient(ctx) if err != nil { return nil, err @@ -159,6 +181,7 @@ func ListVolumes(ctx context.Context) ([]ListItem, error) { // ListVectorSearchIndexes returns vector search indexes as selectable items (id = endpoint/index name). func ListVectorSearchIndexes(ctx context.Context) ([]ListItem, error) { + // TODO: review this one too w, err := workspaceClient(ctx) if err != nil { return nil, err @@ -189,6 +212,7 @@ func ListVectorSearchIndexes(ctx context.Context) ([]ListItem, error) { // ListFunctions returns UC functions as selectable items (id = full name). func ListFunctions(ctx context.Context) ([]ListItem, error) { + // TODO: review this one too w, err := workspaceClient(ctx) if err != nil { return nil, err @@ -248,45 +272,88 @@ func ListConnections(ctx context.Context) ([]ListItem, error) { return out, nil } -// ListDatabases returns UC catalogs as selectable items (id = catalog name). -func ListDatabases(ctx context.Context) ([]ListItem, error) { +// 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.Catalogs.List(ctx, catalog.ListCatalogsRequest{}) - catalogs, err := listing.ToSlice(ctx, iter) + iter := w.Database.ListDatabaseInstances(ctx, database.ListDatabaseInstancesRequest{}) + instances, err := listing.ToSlice(ctx, iter) if err != nil { return nil, err } - out := make([]ListItem, 0, len(catalogs)) - for _, c := range catalogs { - out = append(out, ListItem{ID: c.Name, Label: c.Name}) + out := make([]ListItem, 0, len(instances)) + for _, inst := range instances { + out = append(out, ListItem{ID: inst.Name, Label: inst.Name}) } return out, nil } -// ListGenieSpaces returns Genie spaces as selectable items. -func ListGenieSpaces(ctx context.Context) ([]ListItem, error) { +// 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 } - resp, err := w.Genie.ListSpaces(ctx, dashboards.GenieListSpacesRequest{}) + api, err := client.New(w.Config) if err != nil { return nil, err } - out := make([]ListItem, 0, len(resp.Spaces)) - for _, s := range resp.Spaces { - id := s.SpaceId - label := s.Title - if label == "" { - label = s.Description + // TODO: use the SDK to list databases once available + var resp listDatabasesResponse + path := fmt.Sprintf("/api/2.0/database/instances/%s/databases", 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 } - if label == "" { - label = id + 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 } - out = append(out, ListItem{ID: id, Label: label}) + 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 } @@ -313,24 +380,22 @@ func ListExperiments(ctx context.Context) ([]ListItem, error) { return out, nil } -// 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 - if a.Description != "" { - label = a.Name + " — " + a.Description - } - out = append(out, ListItem{ID: a.Name, 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 3200b83f95..64786d7d15 100644 --- a/libs/apps/prompt/prompt.go +++ b/libs/apps/prompt/prompt.go @@ -272,8 +272,22 @@ func PromptForWarehouse(ctx context.Context) (string, error) { return PromptFromList(ctx, "Select SQL Warehouse", "no SQL warehouses found. Create one in your workspace first", items, true) } +// 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} +} + // promptForResourceFromLister runs a spinner, fetches items via fn, then shows PromptFromList. -func promptForResourceFromLister(ctx context.Context, _ manifest.Resource, required bool, title, emptyMsg, spinnerMsg string, fn func(context.Context) ([]ListItem, error)) (string, error) { +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 @@ -281,70 +295,153 @@ func promptForResourceFromLister(ctx context.Context, _ manifest.Resource, requi return fetchErr }) if err != nil { - return "", err + return nil, err } - return PromptFromList(ctx, title, emptyMsg, items, required) + value, err := PromptFromList(ctx, title, emptyMsg, items, required) + if err != nil { + return nil, err + } + return singleValueResult(r, value), nil } -// PromptForSecret shows a picker for secret scopes. -func PromptForSecret(ctx context.Context, r manifest.Resource, required bool) (string, error) { - return promptForResourceFromLister(ctx, r, required, "Select Secret Scope", "no secret scopes found", "Fetching secret scopes...", ListSecrets) +// 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 + } + 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 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 + } + if key == "" { + return nil, nil + } + + 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) (string, error) { +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) (string, error) { +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) } // PromptForServingEndpoint shows a picker for serving endpoints. -func PromptForServingEndpoint(ctx context.Context, r manifest.Resource, required bool) (string, error) { +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) } // PromptForVolume shows a picker for UC volumes. -func PromptForVolume(ctx context.Context, r manifest.Resource, required bool) (string, error) { +func PromptForVolume(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { return promptForResourceFromLister(ctx, r, required, "Select Volume", "no volumes found", "Fetching volumes...", ListVolumes) } // PromptForVectorSearchIndex shows a picker for vector search indexes. -func PromptForVectorSearchIndex(ctx context.Context, r manifest.Resource, required bool) (string, error) { +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 picker for UC functions. -func PromptForUCFunction(ctx context.Context, r manifest.Resource, required bool) (string, error) { +func PromptForUCFunction(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { return promptForResourceFromLister(ctx, r, required, "Select UC Function", "no functions found", "Fetching functions...", ListFunctions) } // PromptForUCConnection shows a picker for UC connections. -func PromptForUCConnection(ctx context.Context, r manifest.Resource, required bool) (string, error) { +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 picker for UC catalogs (databases). -func PromptForDatabase(ctx context.Context, r manifest.Resource, required bool) (string, error) { - return promptForResourceFromLister(ctx, r, required, "Select Database (Catalog)", "no catalogs found", "Fetching catalogs...", ListDatabases) +// 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 + instances, fetchErr = ListDatabaseInstances(ctx) + return fetchErr + }) + if err != nil { + 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 + } + + // 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 + } + + return map[string]string{ + r.Key() + ".instance_name": instanceName, + r.Key() + ".database_name": dbName, + }, nil } // PromptForGenieSpace shows a picker for Genie spaces. -func PromptForGenieSpace(ctx context.Context, r manifest.Resource, required bool) (string, error) { +func PromptForGenieSpace(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { return promptForResourceFromLister(ctx, r, required, "Select Genie Space", "no Genie spaces found", "Fetching Genie spaces...", ListGenieSpaces) } // PromptForExperiment shows a picker for MLflow experiments. -func PromptForExperiment(ctx context.Context, r manifest.Resource, required bool) (string, error) { +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) } -// PromptForAppResource shows a picker for apps (manifest.Resource version). -func PromptForAppResource(ctx context.Context, r manifest.Resource, required bool) (string, error) { - return promptForResourceFromLister(ctx, r, required, "Select App", "no apps found. Create one first with 'databricks apps create '", "Fetching apps...", ListAppsItems) -} +// 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. diff --git a/libs/apps/prompt/resource_registry.go b/libs/apps/prompt/resource_registry.go index a7c4272bed..f3d6ec640c 100644 --- a/libs/apps/prompt/resource_registry.go +++ b/libs/apps/prompt/resource_registry.go @@ -19,11 +19,15 @@ const ( ResourceTypeDatabase = "database" ResourceTypeGenieSpace = "genie_space" ResourceTypeExperiment = "experiment" - ResourceTypeApp = "app" + // TODO: uncomment when bundles support app as an app resource type. + // ResourceTypeApp = "app" ) -// PromptResourceFunc prompts the user for a resource of a given type and returns the selected ID. -type PromptResourceFunc func(ctx context.Context, r manifest.Resource, required bool) (string, error) +// 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) { @@ -50,8 +54,9 @@ func GetPromptFunc(resourceType string) (PromptResourceFunc, bool) { return PromptForGenieSpace, true case ResourceTypeExperiment: return PromptForExperiment, true - case ResourceTypeApp: - return PromptForAppResource, true + // TODO: uncomment when bundles support app as an app resource type. + // case ResourceTypeApp: + // return PromptForAppResource, true default: return nil, false } From 2d51e4148fc6c36e2dfc969d49661b953b639e4b Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 10 Feb 2026 18:59:54 +0100 Subject: [PATCH 3/4] chore: extra flags --- cmd/apps/init.go | 170 ++++++++++++++++++++++++++++++++++-------- cmd/apps/init_test.go | 113 ++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+), 33 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 883bc7437f..364b02a468 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -49,16 +49,28 @@ func normalizeVersion(version string) string { func newInitCmd() *cobra.Command { var ( - templatePath string - branch string - version string - name string - warehouseID string - description string - outputDir string - pluginsFlag []string - deploy bool - run string + templatePath string + branch string + version string + name string + warehouseID string + description string + outputDir string + pluginsFlag []string + deploy bool + run string + jobID string + modelEndpointID string + volumeID string + vectorSearchIndexID string + functionID string + connectionID string + genieSpaceID string + experimentID string + databaseInstance string + databaseName string + secretScope string + secretKey string ) cmd := &cobra.Command{ @@ -90,6 +102,14 @@ Examples: # With analytics feature (requires --warehouse-id) databricks apps init --name my-app --features=analytics --warehouse-id=abc123 + # With database resource (both flags required together) + databricks apps init --name my-app --features=analytics \ + --warehouse-id=abc123 --database-instance=myinst --database-name=mydb + + # With secret resource (both flags required together) + databricks apps init --name my-app --features=analytics \ + --warehouse-id=abc123 --secret-scope=myscope --secret-key=mykey + # Create, deploy, and run with dev-remote databricks apps init --name my-app --deploy --run=dev-remote @@ -99,9 +119,20 @@ Examples: # With a GitHub URL databricks apps init --template https://github.com/user/repo --name my-app -Plugin dependencies: - Some plugins require additional flags (as defined in appkit.plugins.json): - - analytics: requires --warehouse-id (SQL Warehouse ID) +Resource flags (as defined in appkit.plugins.json): + --warehouse-id SQL Warehouse ID + --job-id Job ID + --model-endpoint-id Serving endpoint ID + --volume-id Unity Catalog volume full name + --vector-search-index-id Vector search index ID + --function-id Unity Catalog function full name + --connection-id Unity Catalog connection name + --genie-space-id Genie Space ID + --experiment-id MLflow experiment ID + --database-instance Lakebase instance name (requires --database-name) + --database-name Lakebase database name (requires --database-instance) + --secret-scope Secret scope name (requires --secret-key) + --secret-key Secret key name (requires --secret-scope) Environment variables: DATABRICKS_APPKIT_TEMPLATE_PATH Override the default template source`, @@ -116,20 +147,32 @@ 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, - 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"), + templatePath: templatePath, + branch: branch, + version: version, + name: name, + nameProvided: cmd.Flags().Changed("name"), + warehouseID: warehouseID, + 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"), + jobID: jobID, + modelEndpointID: modelEndpointID, + volumeID: volumeID, + vectorSearchIndexID: vectorSearchIndexID, + functionID: functionID, + connectionID: connectionID, + genieSpaceID: genieSpaceID, + experimentID: experimentID, + databaseInstance: databaseInstance, + databaseName: databaseName, + secretScope: secretScope, + secretKey: secretKey, }) }, } @@ -139,6 +182,18 @@ Environment variables: 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().StringVar(&jobID, "job-id", "", "Job ID") + cmd.Flags().StringVar(&modelEndpointID, "model-endpoint-id", "", "Serving endpoint ID") + cmd.Flags().StringVar(&volumeID, "volume-id", "", "Unity Catalog volume full name (catalog.schema.volume)") + cmd.Flags().StringVar(&vectorSearchIndexID, "vector-search-index-id", "", "Vector search index ID") + cmd.Flags().StringVar(&functionID, "function-id", "", "Unity Catalog function full name") + cmd.Flags().StringVar(&connectionID, "connection-id", "", "Unity Catalog connection name") + cmd.Flags().StringVar(&genieSpaceID, "genie-space-id", "", "Genie Space ID") + cmd.Flags().StringVar(&experimentID, "experiment-id", "", "MLflow experiment ID") + cmd.Flags().StringVar(&databaseInstance, "database-instance", "", "Lakebase database instance name (requires --database-name)") + cmd.Flags().StringVar(&databaseName, "database-name", "", "Lakebase database name (requires --database-instance)") + cmd.Flags().StringVar(&secretScope, "secret-scope", "", "Secret scope name (requires --secret-key)") + cmd.Flags().StringVar(&secretKey, "secret-key", "", "Secret key name (requires --secret-scope)") cmd.Flags().StringVar(&description, "description", "", "App description") cmd.Flags().StringVar(&outputDir, "output-dir", "", "Directory to write the project to") cmd.Flags().StringSliceVar(&pluginsFlag, "features", nil, "Features/plugins to enable (comma-separated, as defined in template manifest)") @@ -165,6 +220,53 @@ type createOptions struct { run string runChanged bool // true if --run flag was explicitly set pluginsChanged bool // true if --plugins flag was explicitly set + + // Resource flags + jobID string + modelEndpointID string + volumeID string + vectorSearchIndexID string + functionID string + connectionID string + genieSpaceID string + experimentID string + databaseInstance string + databaseName string + secretScope string + secretKey string +} + +// populateResourceValues writes all non-empty resource flag values into the map. +func (o *createOptions) populateResourceValues(rv map[string]string) { + set := func(key, val string) { + if val != "" { + rv[key] = val + } + } + set("sql-warehouse.id", o.warehouseID) + set("job.id", o.jobID) + set("model-endpoint.id", o.modelEndpointID) + set("volume.id", o.volumeID) + set("vector-search-index.id", o.vectorSearchIndexID) + set("function.id", o.functionID) + set("connection.id", o.connectionID) + set("genie-space.space_id", o.genieSpaceID) + set("experiment.id", o.experimentID) + set("database.instance_name", o.databaseInstance) + set("database.database_name", o.databaseName) + set("secret.scope", o.secretScope) + set("secret.key", o.secretKey) +} + +// validateMultiFieldFlags checks that multi-field resource flags are provided together. +func (o *createOptions) validateMultiFieldFlags() error { + if (o.databaseInstance != "") != (o.databaseName != "") { + return errors.New("--database-instance and --database-name must be provided together") + } + if (o.secretScope != "") != (o.secretKey != "") { + return errors.New("--secret-scope and --secret-key must be provided together") + } + return nil } // templateVars holds the variables for template substitution. @@ -551,10 +653,11 @@ func runCreate(ctx context.Context, opts createOptions) error { return err } } - resourceValues = make(map[string]string) - if opts.warehouseID != "" { - resourceValues["sql-warehouse.id"] = opts.warehouseID + if err := opts.validateMultiFieldFlags(); err != nil { + return err } + resourceValues = make(map[string]string) + opts.populateResourceValues(resourceValues) // Prompt for deploy/run if no flags were set if !skipDeployRunPrompt { @@ -571,10 +674,11 @@ func runCreate(ctx context.Context, opts createOptions) error { return err } } - resourceValues = make(map[string]string) - if opts.warehouseID != "" { - resourceValues["sql-warehouse.id"] = opts.warehouseID + if err := opts.validateMultiFieldFlags(); err != nil { + return err } + resourceValues = make(map[string]string) + opts.populateResourceValues(resourceValues) // Validate required resources are provided. // All resource value keys use "resource_key.field_name" format. diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index 2c3c30feff..49198d54f7 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -283,3 +283,116 @@ func TestParseDeployAndRunFlags(t *testing.T) { }) } } + +func TestValidateMultiFieldFlags(t *testing.T) { + tests := []struct { + name string + opts createOptions + wantErr string + }{ + { + name: "both database fields provided", + opts: createOptions{databaseInstance: "inst", databaseName: "db"}, + }, + { + name: "neither database field provided", + opts: createOptions{}, + }, + { + name: "only database instance name", + opts: createOptions{databaseInstance: "inst"}, + wantErr: "--database-instance and --database-name must be provided together", + }, + { + name: "only database database name", + opts: createOptions{databaseName: "db"}, + wantErr: "--database-instance and --database-name must be provided together", + }, + { + name: "both secret fields provided", + opts: createOptions{secretScope: "scope", secretKey: "key"}, + }, + { + name: "neither secret field provided", + opts: createOptions{}, + }, + { + name: "only secret scope", + opts: createOptions{secretScope: "scope"}, + wantErr: "--secret-scope and --secret-key must be provided together", + }, + { + name: "only secret key", + opts: createOptions{secretKey: "key"}, + wantErr: "--secret-scope and --secret-key must be provided together", + }, + { + name: "all multi-field flags provided", + opts: createOptions{ + databaseInstance: "inst", + databaseName: "db", + secretScope: "scope", + secretKey: "key", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.opts.validateMultiFieldFlags() + if tt.wantErr != "" { + require.Error(t, err) + assert.Equal(t, tt.wantErr, err.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestPopulateResourceValues(t *testing.T) { + opts := createOptions{ + warehouseID: "wh-123", + jobID: "job-456", + modelEndpointID: "ep-789", + volumeID: "cat.schema.vol", + vectorSearchIndexID: "idx-1", + functionID: "cat.schema.fn", + connectionID: "conn-1", + genieSpaceID: "gs-1", + experimentID: "exp-1", + databaseInstance: "inst", + databaseName: "mydb", + secretScope: "scope", + secretKey: "key", + } + + rv := make(map[string]string) + opts.populateResourceValues(rv) + + assert.Equal(t, "wh-123", rv["sql-warehouse.id"]) + assert.Equal(t, "job-456", rv["job.id"]) + assert.Equal(t, "ep-789", rv["model-endpoint.id"]) + assert.Equal(t, "cat.schema.vol", rv["volume.id"]) + assert.Equal(t, "idx-1", rv["vector-search-index.id"]) + assert.Equal(t, "cat.schema.fn", rv["function.id"]) + assert.Equal(t, "conn-1", rv["connection.id"]) + assert.Equal(t, "gs-1", rv["genie-space.space_id"]) + assert.Equal(t, "exp-1", rv["experiment.id"]) + assert.Equal(t, "inst", rv["database.instance_name"]) + assert.Equal(t, "mydb", rv["database.database_name"]) + assert.Equal(t, "scope", rv["secret.scope"]) + assert.Equal(t, "key", rv["secret.key"]) +} + +func TestPopulateResourceValuesSkipsEmpty(t *testing.T) { + opts := createOptions{ + warehouseID: "wh-123", + } + + rv := make(map[string]string) + opts.populateResourceValues(rv) + + assert.Equal(t, "wh-123", rv["sql-warehouse.id"]) + assert.Len(t, rv, 1) +} From 1677df6616c83f2a3c09ef14307d3dbc7c261f63 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 11 Feb 2026 17:09:05 +0100 Subject: [PATCH 4/4] chore: fixup --- cmd/apps/init.go | 38 +++++++++++++---- cmd/apps/init_test.go | 15 +++++++ libs/apps/manifest/manifest.go | 49 +++++++++++++++------- libs/apps/manifest/manifest_test.go | 64 +++++++++++++++++++++++++---- libs/apps/prompt/listers.go | 1 - 5 files changed, 136 insertions(+), 31 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 364b02a468..cdf66cd2bb 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -327,24 +327,29 @@ func promptForPluginsAndDeps(ctx context.Context, m *manifest.Manifest, preSelec 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 { + if len(selected) == 0 { prompt.PrintAnswered(ctx, "Plugins", "None") } else { - prompt.PrintAnswered(ctx, "Plugins", fmt.Sprintf("%d selected", len(config.Features))) + prompt.PrintAnswered(ctx, "Plugins", fmt.Sprintf("%d selected", len(selected))) } + config.Features = selected } + // Always include mandatory plugins. + config.Features = appendUnique(config.Features, m.GetMandatoryPluginNames()...) + // Step 2: Prompt for required plugin resource dependencies resources := m.CollectResources(config.Features) for _, r := range resources { @@ -615,8 +620,8 @@ func runCreate(ctx context.Context, opts createOptions) error { 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", - name, len(p.Resources.Required), len(p.Resources.Optional)) + log.Debugf(ctx, " Plugin %q: %d required resources, %d optional resources, requiredByTemplate=%v", + name, len(p.Resources.Required), len(p.Resources.Optional), p.RequiredByTemplate) } // When --name is provided, user is in "flags mode" - use defaults instead of prompting @@ -679,9 +684,13 @@ func runCreate(ctx context.Context, opts createOptions) error { } resourceValues = make(map[string]string) opts.populateResourceValues(resourceValues) + } - // Validate required resources are provided. - // All resource value keys use "resource_key.field_name" format. + // 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 @@ -893,6 +902,21 @@ 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 { diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index 49198d54f7..2fbc23116a 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -396,3 +396,18 @@ func TestPopulateResourceValuesSkipsEmpty(t *testing.T) { assert.Equal(t, "wh-123", rv["sql-warehouse.id"]) assert.Len(t, rv, 1) } + +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/manifest/manifest.go b/libs/apps/manifest/manifest.go index 425401dade..b0eccebc9d 100644 --- a/libs/apps/manifest/manifest.go +++ b/libs/apps/manifest/manifest.go @@ -20,12 +20,12 @@ type ResourceField struct { // 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:"resource_key"` // 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 + 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). @@ -62,11 +62,12 @@ type Resources struct { // 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"` - Resources Resources `json:"resources"` + 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. @@ -118,18 +119,38 @@ func (m *Manifest) GetPlugins() []Plugin { return plugins } -// GetSelectablePlugins returns plugins that have resources (can be selected/configured). -// Plugins without resources (like a base server) are considered always-on. +// 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 len(p.Resources.Required) > 0 || len(p.Resources.Optional) > 0 { + 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 { diff --git a/libs/apps/manifest/manifest_test.go b/libs/apps/manifest/manifest_test.go index d9a25f0b71..5a1c4f8212 100644 --- a/libs/apps/manifest/manifest_test.go +++ b/libs/apps/manifest/manifest_test.go @@ -28,7 +28,7 @@ func TestLoad(t *testing.T) { { "type": "sql_warehouse", "alias": "SQL Warehouse", - "resource_key": "sql-warehouse", + "resourceKey": "sql-warehouse", "description": "SQL Warehouse", "permission": "CAN_USE", "fields": { @@ -44,6 +44,7 @@ func TestLoad(t *testing.T) { "displayName": "Server Plugin", "description": "HTTP server", "package": "@databricks/appkit", + "requiredByTemplate": true, "resources": { "required": [], "optional": [] @@ -59,6 +60,8 @@ func TestLoad(t *testing.T) { 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) { @@ -109,11 +112,8 @@ func TestGetSelectablePlugins(t *testing.T) { m := &manifest.Manifest{ Plugins: map[string]manifest.Plugin{ "server": { - Name: "server", - Resources: manifest.Resources{ - Required: []manifest.Resource{}, - Optional: []manifest.Resource{}, - }, + Name: "server", + RequiredByTemplate: true, }, "analytics": { Name: "analytics", @@ -121,15 +121,61 @@ func TestGetSelectablePlugins(t *testing.T) { Required: []manifest.Resource{ {Type: "sql_warehouse", Alias: "SQL Warehouse", ResourceKey: "sql-warehouse"}, }, - Optional: []manifest.Resource{}, }, }, + "optional-plugin": { + Name: "optional-plugin", + }, }, } selectable := m.GetSelectablePlugins() - require.Len(t, selectable, 1) + 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) { @@ -229,7 +275,7 @@ func TestResourceFields(t *testing.T) { { "type": "database", "alias": "Database", - "resource_key": "database", + "resourceKey": "database", "description": "Cache database", "permission": "CAN_CONNECT_AND_CREATE", "fields": { diff --git a/libs/apps/prompt/listers.go b/libs/apps/prompt/listers.go index a1171ca4fe..3ee309dc22 100644 --- a/libs/apps/prompt/listers.go +++ b/libs/apps/prompt/listers.go @@ -181,7 +181,6 @@ func ListVolumes(ctx context.Context) ([]ListItem, error) { // ListVectorSearchIndexes returns vector search indexes as selectable items (id = endpoint/index name). func ListVectorSearchIndexes(ctx context.Context) ([]ListItem, error) { - // TODO: review this one too w, err := workspaceClient(ctx) if err != nil { return nil, err