diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bc84c54b55..406b8140fe 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,6 +4,8 @@ /acceptance/pipelines/ @jefferycheng1 @kanterov @lennartkats-db /cmd/pipelines/ @jefferycheng1 @kanterov @lennartkats-db /cmd/labs/ @alexott @nfx +/cmd/apps/ @databricks/eng-app-devex +/libs/apps/ @databricks/eng-app-devex /cmd/workspace/apps/ @databricks/eng-app-devex /libs/apps/ @databricks/eng-app-devex /acceptance/apps/ @databricks/eng-app-devex diff --git a/cmd/apps/deploy_bundle.go b/cmd/apps/deploy_bundle.go index c08bc6e9f5..592b5921f1 100644 --- a/cmd/apps/deploy_bundle.go +++ b/cmd/apps/deploy_bundle.go @@ -39,10 +39,12 @@ func BundleDeployOverrideWithWrapper(wrapError ErrorWrapper) func(*cobra.Command var ( force bool skipValidation bool + skipTests bool ) deployCmd.Flags().BoolVar(&force, "force", false, "Force-override Git branch validation") deployCmd.Flags().BoolVar(&skipValidation, "skip-validation", false, "Skip project validation (build, typecheck, lint)") + deployCmd.Flags().BoolVar(&skipTests, "skip-tests", true, "Skip running tests during validation") // Update the command usage to reflect that APP_NAME is optional when in bundle mode deployCmd.Use = "deploy [APP_NAME]" @@ -68,7 +70,7 @@ func BundleDeployOverrideWithWrapper(wrapError ErrorWrapper) func(*cobra.Command // Try to load bundle configuration b := root.TryConfigureBundle(cmd) if b != nil { - return runBundleDeploy(cmd, force, skipValidation) + return runBundleDeploy(cmd, force, skipValidation, skipTests) } } @@ -109,7 +111,7 @@ Examples: } // runBundleDeploy executes the enhanced deployment flow for bundle directories. -func runBundleDeploy(cmd *cobra.Command, force, skipValidation bool) error { +func runBundleDeploy(cmd *cobra.Command, force, skipValidation, skipTests bool) error { ctx := cmd.Context() // Get current working directory for validation @@ -122,7 +124,10 @@ func runBundleDeploy(cmd *cobra.Command, force, skipValidation bool) error { if !skipValidation { validator := validation.GetProjectValidator(workDir) if validator != nil { - result, err := validator.Validate(ctx, workDir) + opts := validation.ValidateOptions{ + SkipTests: skipTests, + } + result, err := validator.Validate(ctx, workDir, opts) if err != nil { return fmt.Errorf("validation error: %w", err) } diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 951fa8c731..12e22146b8 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -14,6 +14,7 @@ import ( "github.com/charmbracelet/huh" "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/libs/apps/features" + "github.com/databricks/cli/libs/apps/initializer" "github.com/databricks/cli/libs/apps/prompt" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" @@ -80,15 +81,19 @@ Environment variables: RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() return runCreate(ctx, createOptions{ - templatePath: templatePath, - branch: branch, - name: name, - warehouseID: warehouseID, - description: description, - outputDir: outputDir, - features: featuresFlag, - deploy: deploy, - run: run, + templatePath: templatePath, + branch: branch, + 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"), }) }, } @@ -107,15 +112,19 @@ Environment variables: } type createOptions struct { - templatePath string - branch string - name string - warehouseID string - description string - outputDir string - features []string - deploy bool - run string + templatePath string + branch 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 } // templateVars holds the variables for template substitution. @@ -170,7 +179,8 @@ func parseDeployAndRunFlags(deploy bool, run string) (bool, prompt.RunMode, erro // promptForFeaturesAndDeps prompts for features and their dependencies. // Used when the template uses the feature-fragment system. -func promptForFeaturesAndDeps(ctx context.Context, preSelectedFeatures []string) (*prompt.CreateProjectConfig, error) { +// 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) { config := &prompt.CreateProjectConfig{ Dependencies: make(map[string]string), Features: preSelectedFeatures, @@ -260,10 +270,12 @@ func promptForFeaturesAndDeps(ctx context.Context, preSelectedFeatures []string) } prompt.PrintAnswered(ctx, "Description", config.Description) - // Step 4: Deploy and run options - config.Deploy, config.RunMode, err = prompt.PromptForDeployAndRun(ctx) - if err != nil { - return nil, err + // Step 4: 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 { + return nil, err + } } return config, nil @@ -474,11 +486,17 @@ func runCreate(ctx context.Context, opts createOptions) error { // Step 3: Determine template type and gather configuration usesFeatureFragments := features.HasFeaturesDirectory(templateDir) + // 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 - if isInteractive && len(selectedFeatures) == 0 { - // Need to prompt for features (but we already have the name) - config, err := promptForFeaturesAndDeps(ctx, selectedFeatures) + // 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 } @@ -487,15 +505,41 @@ func runCreate(ctx context.Context, opts createOptions) error { if config.Description != "" { opts.description = config.Description } - shouldDeploy = config.Deploy - runMode = config.RunMode + // Use prompted values for deploy/run (only set if we prompted) + if !skipDeployRunPrompt { + shouldDeploy = config.Deploy + runMode = config.RunMode + } // Get warehouse from dependencies if provided if wh, ok := dependencies["sql_warehouse_id"]; ok && wh != "" { opts.warehouseID = wh } + } else if isInteractive && opts.featuresChanged && !flagsMode { + // Interactive mode with --features flag: validate features, prompt for deploy/run if no flags + flagValues := map[string]string{ + "warehouse-id": opts.warehouseID, + } + if len(selectedFeatures) > 0 { + if err := features.ValidateFeatureDependencies(selectedFeatures, flagValues); err != nil { + return err + } + } + dependencies = make(map[string]string) + if opts.warehouseID != "" { + dependencies["sql_warehouse_id"] = opts.warehouseID + } + + // Prompt for deploy/run if no flags were set + if !skipDeployRunPrompt { + var err error + shouldDeploy, runMode, err = prompt.PromptForDeployAndRun(ctx) + if err != nil { + return err + } + } } else { - // Non-interactive or features provided via flag + // Flags mode or non-interactive: validate features and use flag values flagValues := map[string]string{ "warehouse-id": opts.warehouseID, } @@ -508,6 +552,10 @@ func runCreate(ctx context.Context, opts createOptions) error { if opts.warehouseID != "" { dependencies["sql_warehouse_id"] = opts.warehouseID } + } + + // Apply flag values for deploy/run when in flags mode, flags were explicitly set, or non-interactive + if skipDeployRunPrompt || !isInteractive { var err error shouldDeploy, runMode, err = parseDeployAndRunFlags(opts.deploy, opts.run) if err != nil { @@ -562,11 +610,13 @@ func runCreate(ctx context.Context, opts createOptions) error { } } - // Prompt for description and post-creation actions - if isInteractive { - if opts.description == "" { - opts.description = prompt.DefaultAppDescription - } + // Set default description if not provided + if opts.description == "" { + opts.description = prompt.DefaultAppDescription + } + + // Only prompt for deploy/run if not in flags mode and no deploy/run flags were set + if isInteractive && !flagsMode && !opts.deployChanged && !opts.runChanged { var deployVal bool var runVal prompt.RunMode deployVal, runVal, err = prompt.PromptForDeployAndRun(ctx) @@ -576,6 +626,7 @@ func runCreate(ctx context.Context, opts createOptions) error { 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 { @@ -659,21 +710,34 @@ func runCreate(ctx context.Context, opts createOptions) error { return runErr } - // Run npm install - runErr = runNpmInstall(ctx, absOutputDir) - if runErr != nil { - return runErr + // Initialize project based on type (Node.js, Python, etc.) + var nextStepsCmd string + projectInitializer := initializer.GetProjectInitializer(absOutputDir) + if projectInitializer != nil { + result := projectInitializer.Initialize(ctx, absOutputDir) + if !result.Success { + if result.Error != nil { + return fmt.Errorf("%s: %w", result.Message, result.Error) + } + return errors.New(result.Message) + } + nextStepsCmd = projectInitializer.NextSteps() } - // Run npm run setup - runErr = runNpmSetup(ctx, absOutputDir) - if runErr != nil { - return runErr + // Validate dev-remote is only supported for appkit projects + if runMode == prompt.RunModeDevRemote { + if projectInitializer == nil || !projectInitializer.SupportsDevRemote() { + return errors.New("--run=dev-remote is only supported for Node.js projects with @databricks/appkit") + } } // Show next steps only if user didn't choose to deploy or run showNextSteps := !shouldDeploy && runMode == prompt.RunModeNone - prompt.PrintSuccess(ctx, opts.name, absOutputDir, fileCount, showNextSteps) + if showNextSteps { + prompt.PrintSuccess(ctx, opts.name, absOutputDir, fileCount, nextStepsCmd) + } else { + prompt.PrintSuccess(ctx, opts.name, absOutputDir, fileCount, "") + } // Execute post-creation actions (deploy and/or run) if shouldDeploy || runMode != prompt.RunModeNone { @@ -694,7 +758,7 @@ func runCreate(ctx context.Context, opts createOptions) error { if runMode != prompt.RunModeNone { cmdio.LogString(ctx, "") - if err := runPostCreateDev(ctx, runMode); err != nil { + if err := runPostCreateDev(ctx, runMode, projectInitializer, absOutputDir); err != nil { return err } } @@ -716,15 +780,15 @@ func runPostCreateDeploy(ctx context.Context) error { } // runPostCreateDev runs the dev or dev-remote command in the current directory. -func runPostCreateDev(ctx context.Context, mode prompt.RunMode) error { +func runPostCreateDev(ctx context.Context, mode prompt.RunMode, projectInit initializer.Initializer, workDir string) error { switch mode { case prompt.RunModeDev: - cmdio.LogString(ctx, "Starting development server (npm run dev)...") - cmd := exec.CommandContext(ctx, "npm", "run", "dev") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Stdin = os.Stdin - return cmd.Run() + if projectInit != nil { + return projectInit.RunDev(ctx, workDir) + } + // Fallback for unknown project types + cmdio.LogString(ctx, "⚠ Unknown project type, cannot start development server automatically") + return nil case prompt.RunModeDevRemote: cmdio.LogString(ctx, "Starting remote development server...") executable, err := os.Executable() @@ -741,39 +805,6 @@ func runPostCreateDev(ctx context.Context, mode prompt.RunMode) error { } } -// runNpmInstall runs npm install in the project directory. -func runNpmInstall(ctx context.Context, projectDir string) error { - // Check if npm is available - if _, err := exec.LookPath("npm"); err != nil { - cmdio.LogString(ctx, "⚠ npm not found. Please install Node.js and run 'npm install' manually.") - return nil - } - - return prompt.RunWithSpinnerCtx(ctx, "Installing dependencies...", func() error { - cmd := exec.CommandContext(ctx, "npm", "install") - cmd.Dir = projectDir - cmd.Stdout = nil // Suppress output - cmd.Stderr = nil - return cmd.Run() - }) -} - -// runNpmSetup runs npx appkit-setup in the project directory. -func runNpmSetup(ctx context.Context, projectDir string) error { - // Check if npx is available - if _, err := exec.LookPath("npx"); err != nil { - return nil - } - - return prompt.RunWithSpinnerCtx(ctx, "Running setup...", func() error { - cmd := exec.CommandContext(ctx, "npx", "appkit-setup", "--write") - cmd.Dir = projectDir - cmd.Stdout = nil // Suppress output - cmd.Stderr = nil - return cmd.Run() - }) -} - // renameFiles maps source file names to destination names (for files that can't use special chars). var renameFiles = map[string]string{ "_gitignore": ".gitignore", diff --git a/cmd/apps/validate.go b/cmd/apps/validate.go index 2f87540559..f6490d03ac 100644 --- a/cmd/apps/validate.go +++ b/cmd/apps/validate.go @@ -18,20 +18,24 @@ func newValidateCmd() *cobra.Command { Long: `Validate a Databricks App project by running build, typecheck, and lint checks. This command detects the project type and runs the appropriate validation: -- Node.js projects (package.json): runs npm install, build, typecheck, and lint +- Node.js projects (package.json): runs npm install, build, typecheck, lint, and tests Examples: # Validate the current directory databricks apps validate # Validate a specific directory - databricks apps validate --path ./my-app`, + databricks apps validate --path ./my-app + + # Run a quick validation without tests + databricks apps validate --skip-tests`, RunE: func(cmd *cobra.Command, args []string) error { return runValidate(cmd) }, } cmd.Flags().String("path", "", "Path to the project directory (defaults to current directory)") + cmd.Flags().Bool("skip-tests", false, "Skip running tests for faster validation") return cmd } @@ -49,6 +53,12 @@ func runValidate(cmd *cobra.Command) error { } } + // Get validation options + skipTests, _ := cmd.Flags().GetBool("skip-tests") + opts := validation.ValidateOptions{ + SkipTests: skipTests, + } + // Get validator for project type validator := validation.GetProjectValidator(projectPath) if validator == nil { @@ -56,7 +66,7 @@ func runValidate(cmd *cobra.Command) error { } // Run validation - result, err := validator.Validate(ctx, projectPath) + result, err := validator.Validate(ctx, projectPath, opts) if err != nil { return fmt.Errorf("validation error: %w", err) } diff --git a/go.mod b/go.mod index 32e25e6798..6b13a33a85 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,8 @@ require ( // Dependencies for experimental MCP commands require github.com/google/jsonschema-go v0.4.2 // MIT +require gopkg.in/yaml.v3 v3.0.1 + require ( cloud.google.com/go/auth v0.16.5 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect @@ -98,5 +100,4 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 // indirect google.golang.org/grpc v1.75.1 // indirect google.golang.org/protobuf v1.36.9 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/libs/apps/initializer/initializer.go b/libs/apps/initializer/initializer.go new file mode 100644 index 0000000000..6f063aa9e2 --- /dev/null +++ b/libs/apps/initializer/initializer.go @@ -0,0 +1,120 @@ +package initializer + +import ( + "context" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// InitResult contains the outcome of an initialization operation. +type InitResult struct { + Success bool + Message string + Error error +} + +// Initializer defines the interface for project initialization strategies. +type Initializer interface { + // Initialize runs the setup steps for the project type. + Initialize(ctx context.Context, workDir string) *InitResult + + // NextSteps returns the next steps message for this project type. + NextSteps() string + + // RunDev starts the local development server. + RunDev(ctx context.Context, workDir string) error + + // SupportsDevRemote returns true if dev-remote mode is supported. + SupportsDevRemote() bool +} + +// GetProjectInitializer returns the appropriate initializer based on project type. +// Detection order: package.json (Node.js), pyproject.toml (Python/uv), requirements.txt (Python/pip). +// Returns nil if no initializer is applicable. +func GetProjectInitializer(workDir string) Initializer { + // Check for Node.js project (package.json exists) + if fileExists(filepath.Join(workDir, "package.json")) { + return &InitializerNodeJs{workDir: workDir} + } + + // Check for Python project with pyproject.toml (use uv) + if fileExists(filepath.Join(workDir, "pyproject.toml")) { + return &InitializerPythonUv{} + } + + // Check for Python project with requirements.txt (use pip + venv) + if fileExists(filepath.Join(workDir, "requirements.txt")) { + return &InitializerPythonPip{} + } + + return nil +} + +// fileExists checks if a file exists at the given path. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// appYaml represents the structure of app.yaml for parsing the command. +type appYaml struct { + Command []string `yaml:"command"` +} + +// getAppCommand reads the command from app.yaml if it exists. +// Returns the command as a slice of strings, or nil if not found. +func getAppCommand(workDir string) []string { + appYamlPath := filepath.Join(workDir, "app.yaml") + data, err := os.ReadFile(appYamlPath) + if err != nil { + return nil + } + + var config appYaml + if err := yaml.Unmarshal(data, &config); err != nil { + return nil + } + + return config.Command +} + +// detectPythonCommand determines the command to run a Python app. +// Priority: app.yaml command > detect streamlit > default to python app.py +func detectPythonCommand(workDir string) []string { + // First, check app.yaml + if cmd := getAppCommand(workDir); len(cmd) > 0 { + return cmd + } + + // Check if streamlit is in requirements.txt or pyproject.toml + if hasStreamlit(workDir) { + return []string{"streamlit", "run", "app.py"} + } + + // Default to python app.py + return []string{"python", "app.py"} +} + +// hasStreamlit checks if streamlit is a dependency. +func hasStreamlit(workDir string) bool { + // Check requirements.txt + reqPath := filepath.Join(workDir, "requirements.txt") + if data, err := os.ReadFile(reqPath); err == nil { + if strings.Contains(strings.ToLower(string(data)), "streamlit") { + return true + } + } + + // Check pyproject.toml (simple check) + pyprojectPath := filepath.Join(workDir, "pyproject.toml") + if data, err := os.ReadFile(pyprojectPath); err == nil { + if strings.Contains(strings.ToLower(string(data)), "streamlit") { + return true + } + } + + return false +} diff --git a/libs/apps/initializer/initializer_test.go b/libs/apps/initializer/initializer_test.go new file mode 100644 index 0000000000..d39076b2d4 --- /dev/null +++ b/libs/apps/initializer/initializer_test.go @@ -0,0 +1,169 @@ +package initializer + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetProjectInitializer(t *testing.T) { + tests := []struct { + name string + files map[string]string + wantType string + }{ + { + name: "nodejs project with package.json", + files: map[string]string{"package.json": `{"name": "test"}`}, + wantType: "*initializer.InitializerNodeJs", + }, + { + name: "python project with pyproject.toml", + files: map[string]string{"pyproject.toml": "[project]\nname = \"test\""}, + wantType: "*initializer.InitializerPythonUv", + }, + { + name: "python project with requirements.txt", + files: map[string]string{"requirements.txt": "flask==2.0.0"}, + wantType: "*initializer.InitializerPythonPip", + }, + { + name: "no recognizable project type", + files: map[string]string{"README.md": "# Test"}, + wantType: "", + }, + { + name: "nodejs takes precedence over python", + files: map[string]string{ + "package.json": `{"name": "test"}`, + "requirements.txt": "flask==2.0.0", + }, + wantType: "*initializer.InitializerNodeJs", + }, + { + name: "pyproject.toml takes precedence over requirements.txt", + files: map[string]string{ + "pyproject.toml": "[project]\nname = \"test\"", + "requirements.txt": "flask==2.0.0", + }, + wantType: "*initializer.InitializerPythonUv", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temp directory + tmpDir, err := os.MkdirTemp("", "initializer-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create test files + for name, content := range tt.files { + err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o644) + require.NoError(t, err) + } + + // Get initializer + init := GetProjectInitializer(tmpDir) + + if tt.wantType == "" { + assert.Nil(t, init) + } else { + require.NotNil(t, init) + assert.Equal(t, tt.wantType, getTypeName(init)) + } + }) + } +} + +func TestNextSteps(t *testing.T) { + nodejs := &InitializerNodeJs{} + assert.Equal(t, "npm run dev", nodejs.NextSteps()) + + pythonUv := &InitializerPythonUv{} + assert.Contains(t, pythonUv.NextSteps(), "uv run") + + pythonPip := &InitializerPythonPip{} + assert.Contains(t, pythonPip.NextSteps(), ".venv") +} + +func TestSupportsDevRemote(t *testing.T) { + // Node.js without appkit + nodejs := &InitializerNodeJs{workDir: ""} + assert.False(t, nodejs.SupportsDevRemote()) + + // Python initializers never support dev-remote + pythonUv := &InitializerPythonUv{} + assert.False(t, pythonUv.SupportsDevRemote()) + + pythonPip := &InitializerPythonPip{} + assert.False(t, pythonPip.SupportsDevRemote()) +} + +func TestDetectPythonCommand(t *testing.T) { + tests := []struct { + name string + files map[string]string + wantCmd []string + }{ + { + name: "command from app.yaml", + files: map[string]string{ + "app.yaml": "command: [\"streamlit\", \"run\", \"app.py\"]", + "requirements.txt": "flask==2.0.0", + }, + wantCmd: []string{"streamlit", "run", "app.py"}, + }, + { + name: "detect streamlit from requirements.txt", + files: map[string]string{ + "requirements.txt": "streamlit==1.0.0\npandas", + }, + wantCmd: []string{"streamlit", "run", "app.py"}, + }, + { + name: "default to python app.py", + files: map[string]string{ + "requirements.txt": "flask==2.0.0", + }, + wantCmd: []string{"python", "app.py"}, + }, + { + name: "empty directory defaults to python", + files: map[string]string{}, + wantCmd: []string{"python", "app.py"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "python-cmd-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + for name, content := range tt.files { + err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o644) + require.NoError(t, err) + } + + cmd := detectPythonCommand(tmpDir) + assert.Equal(t, tt.wantCmd, cmd) + }) + } +} + +func getTypeName(i Initializer) string { + switch i.(type) { + case *InitializerNodeJs: + return "*initializer.InitializerNodeJs" + case *InitializerPythonUv: + return "*initializer.InitializerPythonUv" + case *InitializerPythonPip: + return "*initializer.InitializerPythonPip" + default: + return "" + } +} diff --git a/libs/apps/initializer/nodejs.go b/libs/apps/initializer/nodejs.go new file mode 100644 index 0000000000..2a21aa602a --- /dev/null +++ b/libs/apps/initializer/nodejs.go @@ -0,0 +1,129 @@ +package initializer + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + + "github.com/databricks/cli/libs/apps/prompt" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/log" +) + +// InitializerNodeJs implements initialization for Node.js-based projects. +type InitializerNodeJs struct { + workDir string +} + +func (i *InitializerNodeJs) Initialize(ctx context.Context, workDir string) *InitResult { + i.workDir = workDir + + // Step 1: Run npm install + if err := i.runNpmInstall(ctx, workDir); err != nil { + return &InitResult{ + Success: false, + Message: "Failed to install dependencies", + Error: err, + } + } + + // Step 2: Run appkit setup (only if appkit is present) + if i.hasAppkit(workDir) { + if err := i.runAppkitSetup(ctx, workDir); err != nil { + return &InitResult{ + Success: false, + Message: "Failed to run appkit setup", + Error: err, + } + } + } + + return &InitResult{ + Success: true, + Message: "Node.js project initialized successfully", + } +} + +func (i *InitializerNodeJs) NextSteps() string { + return "npm run dev" +} + +func (i *InitializerNodeJs) RunDev(ctx context.Context, workDir string) error { + cmdio.LogString(ctx, "Starting development server (npm run dev)...") + cmd := exec.CommandContext(ctx, "npm", "run", "dev") + cmd.Dir = workDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + return cmd.Run() +} + +func (i *InitializerNodeJs) SupportsDevRemote() bool { + if i.workDir == "" { + return false + } + return i.hasAppkit(i.workDir) +} + +// runNpmInstall runs npm install in the project directory. +func (i *InitializerNodeJs) runNpmInstall(ctx context.Context, workDir string) error { + // Check if npm is available + if _, err := exec.LookPath("npm"); err != nil { + cmdio.LogString(ctx, "⚠ npm not found. Please install Node.js and run 'npm install' manually.") + return nil + } + + return prompt.RunWithSpinnerCtx(ctx, "Installing dependencies...", func() error { + cmd := exec.CommandContext(ctx, "npm", "install") + cmd.Dir = workDir + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() + }) +} + +// runAppkitSetup runs npx appkit-setup in the project directory. +func (i *InitializerNodeJs) runAppkitSetup(ctx context.Context, workDir string) error { + // Check if npx is available + if _, err := exec.LookPath("npx"); err != nil { + log.Debugf(ctx, "npx not found, skipping appkit setup") + return nil + } + + return prompt.RunWithSpinnerCtx(ctx, "Running setup...", func() error { + cmd := exec.CommandContext(ctx, "npx", "appkit-setup", "--write") + cmd.Dir = workDir + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() + }) +} + +// hasAppkit checks if the project has @databricks/appkit in its dependencies. +func (i *InitializerNodeJs) hasAppkit(workDir string) bool { + packageJSONPath := filepath.Join(workDir, "package.json") + data, err := os.ReadFile(packageJSONPath) + if err != nil { + return false + } + + var pkg struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return false + } + + // Check both dependencies and devDependencies + if _, ok := pkg.Dependencies["@databricks/appkit"]; ok { + return true + } + if _, ok := pkg.DevDependencies["@databricks/appkit"]; ok { + return true + } + + return false +} diff --git a/libs/apps/initializer/nodejs_test.go b/libs/apps/initializer/nodejs_test.go new file mode 100644 index 0000000000..eb9095453f --- /dev/null +++ b/libs/apps/initializer/nodejs_test.go @@ -0,0 +1,67 @@ +package initializer + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHasAppkit(t *testing.T) { + tests := []struct { + name string + packageJSON string + want bool + }{ + { + name: "appkit in dependencies", + packageJSON: `{"dependencies": {"@databricks/appkit": "^1.0.0"}}`, + want: true, + }, + { + name: "appkit in devDependencies", + packageJSON: `{"devDependencies": {"@databricks/appkit": "^1.0.0"}}`, + want: true, + }, + { + name: "no appkit", + packageJSON: `{"dependencies": {"react": "^18.0.0"}}`, + want: false, + }, + { + name: "empty package.json", + packageJSON: `{}`, + want: false, + }, + { + name: "invalid json", + packageJSON: `not json`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "nodejs-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + err = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(tt.packageJSON), 0o644) + require.NoError(t, err) + + init := &InitializerNodeJs{} + assert.Equal(t, tt.want, init.hasAppkit(tmpDir)) + }) + } +} + +func TestHasAppkitNoPackageJSON(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "nodejs-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + init := &InitializerNodeJs{} + assert.False(t, init.hasAppkit(tmpDir)) +} diff --git a/libs/apps/initializer/python_pip.go b/libs/apps/initializer/python_pip.go new file mode 100644 index 0000000000..a7dd23912b --- /dev/null +++ b/libs/apps/initializer/python_pip.go @@ -0,0 +1,140 @@ +package initializer + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/databricks/cli/libs/apps/prompt" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/log" +) + +// InitializerPythonPip implements initialization for Python projects using pip and venv. +type InitializerPythonPip struct{} + +func (i *InitializerPythonPip) Initialize(ctx context.Context, workDir string) *InitResult { + // Step 1: Create virtual environment + if err := i.createVenv(ctx, workDir); err != nil { + return &InitResult{ + Success: false, + Message: "Failed to create virtual environment", + Error: err, + } + } + + // Step 2: Install dependencies + if err := i.installDependencies(ctx, workDir); err != nil { + return &InitResult{ + Success: false, + Message: "Failed to install dependencies", + Error: err, + } + } + + return &InitResult{ + Success: true, + Message: "Python project initialized successfully", + } +} + +func (i *InitializerPythonPip) NextSteps() string { + if runtime.GOOS == "windows" { + return ".venv\\Scripts\\activate && python app.py" + } + return "source .venv/bin/activate && python app.py" +} + +func (i *InitializerPythonPip) RunDev(ctx context.Context, workDir string) error { + cmd := detectPythonCommand(workDir) + cmdStr := strings.Join(cmd, " ") + + cmdio.LogString(ctx, "Starting development server ("+cmdStr+")...") + + // Get the path to the venv bin directory + var venvBin string + if runtime.GOOS == "windows" { + venvBin = filepath.Join(workDir, ".venv", "Scripts") + } else { + venvBin = filepath.Join(workDir, ".venv", "bin") + } + + // Use the full path to the executable in the venv + execPath := filepath.Join(venvBin, cmd[0]) + execCmd := exec.CommandContext(ctx, execPath, cmd[1:]...) + execCmd.Dir = workDir + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + execCmd.Stdin = os.Stdin + // Also set PATH for any child processes the command might spawn + execCmd.Env = append(os.Environ(), "PATH="+venvBin+string(os.PathListSeparator)+os.Getenv("PATH")) + + return execCmd.Run() +} + +func (i *InitializerPythonPip) SupportsDevRemote() bool { + return false +} + +// createVenv creates a virtual environment in the project directory. +func (i *InitializerPythonPip) createVenv(ctx context.Context, workDir string) error { + venvPath := filepath.Join(workDir, ".venv") + + // Skip if venv already exists + if _, err := os.Stat(venvPath); err == nil { + log.Debugf(ctx, "Virtual environment already exists at %s", venvPath) + return nil + } + + // Check if python3 is available + pythonCmd := "python3" + if _, err := exec.LookPath(pythonCmd); err != nil { + pythonCmd = "python" + if _, err := exec.LookPath(pythonCmd); err != nil { + cmdio.LogString(ctx, "⚠ Python not found. Please install Python and create a virtual environment manually.") + return nil + } + } + + return prompt.RunWithSpinnerCtx(ctx, "Creating virtual environment...", func() error { + cmd := exec.CommandContext(ctx, pythonCmd, "-m", "venv", ".venv") + cmd.Dir = workDir + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() + }) +} + +// installDependencies installs dependencies from requirements.txt. +func (i *InitializerPythonPip) installDependencies(ctx context.Context, workDir string) error { + requirementsPath := filepath.Join(workDir, "requirements.txt") + if _, err := os.Stat(requirementsPath); os.IsNotExist(err) { + log.Debugf(ctx, "No requirements.txt found, skipping dependency installation") + return nil + } + + // Get the pip path inside the venv + var pipPath string + if runtime.GOOS == "windows" { + pipPath = filepath.Join(workDir, ".venv", "Scripts", "pip") + } else { + pipPath = filepath.Join(workDir, ".venv", "bin", "pip") + } + + // Check if pip exists in venv + if _, err := os.Stat(pipPath); os.IsNotExist(err) { + cmdio.LogString(ctx, "⚠ pip not found in virtual environment. Please install dependencies manually.") + return nil + } + + return prompt.RunWithSpinnerCtx(ctx, "Installing dependencies...", func() error { + cmd := exec.CommandContext(ctx, pipPath, "install", "-r", "requirements.txt") + cmd.Dir = workDir + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() + }) +} diff --git a/libs/apps/initializer/python_uv.go b/libs/apps/initializer/python_uv.go new file mode 100644 index 0000000000..eba37ee74c --- /dev/null +++ b/libs/apps/initializer/python_uv.go @@ -0,0 +1,75 @@ +package initializer + +import ( + "context" + "os" + "os/exec" + "strings" + + "github.com/databricks/cli/libs/apps/prompt" + "github.com/databricks/cli/libs/cmdio" +) + +// InitializerPythonUv implements initialization for Python projects using uv. +type InitializerPythonUv struct{} + +func (i *InitializerPythonUv) Initialize(ctx context.Context, workDir string) *InitResult { + // Check if uv is available + if _, err := exec.LookPath("uv"); err != nil { + cmdio.LogString(ctx, "⚠ uv not found. Please install uv (https://docs.astral.sh/uv/) and run 'uv sync' manually.") + return &InitResult{ + Success: true, + Message: "Python project created (uv not installed, skipping dependency installation)", + } + } + + // Run uv sync to create venv and install dependencies + if err := i.runUvSync(ctx, workDir); err != nil { + return &InitResult{ + Success: false, + Message: "Failed to sync dependencies with uv", + Error: err, + } + } + + return &InitResult{ + Success: true, + Message: "Python project initialized successfully with uv", + } +} + +func (i *InitializerPythonUv) NextSteps() string { + return "uv run python app.py" +} + +func (i *InitializerPythonUv) RunDev(ctx context.Context, workDir string) error { + appCmd := detectPythonCommand(workDir) + cmdStr := "uv run " + strings.Join(appCmd, " ") + + cmdio.LogString(ctx, "Starting development server ("+cmdStr+")...") + + // Build the uv run command with the app command + args := append([]string{"run"}, appCmd...) + cmd := exec.CommandContext(ctx, "uv", args...) + cmd.Dir = workDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + return cmd.Run() +} + +func (i *InitializerPythonUv) SupportsDevRemote() bool { + return false +} + +// runUvSync runs uv sync to create the virtual environment and install dependencies. +func (i *InitializerPythonUv) runUvSync(ctx context.Context, workDir string) error { + return prompt.RunWithSpinnerCtx(ctx, "Installing dependencies with uv...", func() error { + cmd := exec.CommandContext(ctx, "uv", "sync") + cmd.Dir = workDir + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() + }) +} diff --git a/libs/apps/prompt/prompt.go b/libs/apps/prompt/prompt.go index 9082316439..8040afc28a 100644 --- a/libs/apps/prompt/prompt.go +++ b/libs/apps/prompt/prompt.go @@ -476,8 +476,8 @@ func PromptForWarehouse(ctx context.Context) (string, error) { // The spinner stops and the function returns early if the context is cancelled. // Panics in the action are recovered and returned as errors. func RunWithSpinnerCtx(ctx context.Context, title string, action func() error) error { - spinner := cmdio.Spinner(ctx) - spinner <- title + spinner := cmdio.NewSpinner(ctx) + spinner.Update(title) done := make(chan error, 1) go func() { @@ -491,12 +491,10 @@ func RunWithSpinnerCtx(ctx context.Context, title string, action func() error) e select { case err := <-done: - close(spinner) - cmdio.Wait(ctx) + spinner.Close() return err case <-ctx.Done(): - close(spinner) - cmdio.Wait(ctx) + spinner.Close() // Wait for action goroutine to complete to avoid orphaned goroutines. // For exec.CommandContext, the process is killed when context is cancelled. <-done @@ -572,8 +570,8 @@ func PromptForAppSelection(ctx context.Context, title string) (string, error) { } // PrintSuccess prints a success message after project creation. -// If showNextSteps is true, also prints the "Next steps" section. -func PrintSuccess(ctx context.Context, projectName, outputDir string, fileCount int, showNextSteps bool) { +// If nextStepsCmd is non-empty, also prints the "Next steps" section with the given command. +func PrintSuccess(ctx context.Context, projectName, outputDir string, fileCount int, nextStepsCmd string) { successStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#FFAB00")). // Databricks yellow Bold(true) @@ -590,12 +588,12 @@ func PrintSuccess(ctx context.Context, projectName, outputDir string, fileCount cmdio.LogString(ctx, dimStyle.Render(" Location: "+outputDir)) cmdio.LogString(ctx, dimStyle.Render(" Files: "+strconv.Itoa(fileCount))) - if showNextSteps { + if nextStepsCmd != "" { cmdio.LogString(ctx, "") cmdio.LogString(ctx, dimStyle.Render(" Next steps:")) cmdio.LogString(ctx, "") cmdio.LogString(ctx, codeStyle.Render(" cd "+projectName)) - cmdio.LogString(ctx, codeStyle.Render(" npm run dev")) + cmdio.LogString(ctx, codeStyle.Render(" "+nextStepsCmd)) } cmdio.LogString(ctx, "") } diff --git a/libs/apps/validation/nodejs.go b/libs/apps/validation/nodejs.go index 29bc0a2567..0584d52b9a 100644 --- a/libs/apps/validation/nodejs.go +++ b/libs/apps/validation/nodejs.go @@ -20,10 +20,10 @@ type validationStep struct { command string errorPrefix string displayName string - skipIf func(workDir string) bool // Optional: skip step if this returns true + skipIf func(workDir string, opts ValidateOptions) bool // Optional: skip step if this returns true } -func (v *ValidationNodeJs) Validate(ctx context.Context, workDir string) (*ValidateResult, error) { +func (v *ValidationNodeJs) Validate(ctx context.Context, workDir string, opts ValidateOptions) (*ValidateResult, error) { log.Infof(ctx, "Starting Node.js validation: build + typecheck") startTime := time.Now() @@ -36,7 +36,7 @@ func (v *ValidationNodeJs) Validate(ctx context.Context, workDir string) (*Valid command: "npm install", errorPrefix: "Failed to install dependencies", displayName: "Installing dependencies", - skipIf: hasNodeModules, + skipIf: func(workDir string, _ ValidateOptions) bool { return hasNodeModules(workDir) }, }, { name: "generate", @@ -62,11 +62,18 @@ func (v *ValidationNodeJs) Validate(ctx context.Context, workDir string) (*Valid errorPrefix: "Failed to run npm build", displayName: "Building", }, + { + name: "tests", + command: "npm run test --if-present", + errorPrefix: "Failed to run tests", + displayName: "Running tests", + skipIf: func(_ string, opts ValidateOptions) bool { return opts.SkipTests }, + }, } for _, step := range steps { // Check if step should be skipped - if step.skipIf != nil && step.skipIf(workDir) { + if step.skipIf != nil && step.skipIf(workDir, opts) { log.Debugf(ctx, "skipping %s (condition met)", step.name) cmdio.LogString(ctx, "⏭️ Skipped "+step.displayName) continue @@ -78,13 +85,12 @@ func (v *ValidationNodeJs) Validate(ctx context.Context, workDir string) (*Valid stepStart := time.Now() var stepErr *ValidationDetail - spinner := cmdio.Spinner(ctx) - spinner <- step.displayName + "..." + spinner := cmdio.NewSpinner(ctx) + spinner.Update(step.displayName + "...") stepErr = runValidationCommand(ctx, workDir, step.command) - close(spinner) - cmdio.Wait(ctx) // Wait for spinner to fully clean up and restore terminal + spinner.Close() stepDuration := time.Since(stepStart) if stepErr != nil { diff --git a/libs/apps/validation/validation.go b/libs/apps/validation/validation.go index 804b725e02..4cc45f5a1e 100644 --- a/libs/apps/validation/validation.go +++ b/libs/apps/validation/validation.go @@ -51,9 +51,14 @@ func (vr *ValidateResult) String() string { return result } +// ValidateOptions configures validation behavior. +type ValidateOptions struct { + SkipTests bool // Skip running tests for faster validation +} + // Validation defines the interface for project validation strategies. type Validation interface { - Validate(ctx context.Context, workDir string) (*ValidateResult, error) + Validate(ctx context.Context, workDir string, opts ValidateOptions) (*ValidateResult, error) } // GetProjectValidator returns the appropriate validator based on project type.