diff --git a/cmd/create.go b/cmd/create.go index 8ac9919..31d22ed 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -37,7 +37,7 @@ func (c CreateCmd) Create(ctx context.Context, ci CreateInput) error { return fmt.Errorf("failed to create directory: %w", err) } - pterm.Println(fmt.Sprintf("\nCreating a new %s %s\n", ci.Language, ci.Template)) + pterm.Printfln("\nCreating a new %s %s", ci.Language, ci.Template) spinner, _ := pterm.DefaultSpinner.Start("Copying template files...") @@ -45,16 +45,11 @@ func (c CreateCmd) Create(ctx context.Context, ci CreateInput) error { spinner.Fail("Failed to copy template files") return fmt.Errorf("failed to copy template files: %w", err) } - spinner.Success(fmt.Sprintf("✔ %s environment set up successfully", ci.Language)) - - nextSteps := fmt.Sprintf(`Next steps: - brew install onkernel/tap/kernel - cd %s - kernel login # or: export KERNEL_API_KEY= - kernel deploy index.ts - kernel invoke ts-basic get-page-title --payload '{"url": "https://www.google.com"}' -`, ci.Name) + nextSteps, err := create.InstallDependencies(ci.Name, appPath, ci.Language) + if err != nil { + return fmt.Errorf("failed to install dependencies: %w", err) + } pterm.Success.Println("🎉 Kernel app created successfully!") pterm.Println() pterm.FgYellow.Println(nextSteps) diff --git a/cmd/create_test.go b/cmd/create_test.go index 29c8358..d04e201 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -1,11 +1,15 @@ package cmd import ( + "bytes" "context" + "io" "os" "path/filepath" "testing" + "github.com/onkernel/cli/pkg/create" + "github.com/pterm/pterm" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -83,3 +87,295 @@ func TestCreateCommand(t *testing.T) { }) } } + +// TestAllTemplatesWithDependencies tests all available templates and verifies dependencies are installed +func TestAllTemplatesWithDependencies(t *testing.T) { + if testing.Short() { + t.Skip("Skipping dependency installation tests in short mode") + } + + tests := getTemplateInfo() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + appName := "test-app" + + orgDir, err := os.Getwd() + require.NoError(t, err) + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + t.Cleanup(func() { + os.Chdir(orgDir) + }) + + // Create the app + c := CreateCmd{} + err = c.Create(context.Background(), CreateInput{ + Name: appName, + Language: tt.language, + Template: tt.template, + }) + require.NoError(t, err, "failed to create app") + + appPath := filepath.Join(tmpDir, appName) + + // Verify app directory exists + assert.DirExists(t, appPath, "app directory should exist") + + // Language-specific validations + switch tt.language { + case create.LanguageTypeScript: + validateTypeScriptTemplate(t, appPath, true) + case create.LanguagePython: + validatePythonTemplate(t, appPath, true) + } + }) + } +} + +// TestAllTemplatesCreation tests that all templates can be created without installing dependencies +func TestAllTemplatesCreation(t *testing.T) { + tests := getTemplateInfo() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + appName := "test-app" + appPath := filepath.Join(tmpDir, appName) + + // Create app directory + err := os.MkdirAll(appPath, 0755) + require.NoError(t, err, "failed to create app directory") + + // Copy template files without installing dependencies + err = create.CopyTemplateFiles(appPath, tt.language, tt.template) + require.NoError(t, err, "failed to copy template files") + + // Verify app directory exists + assert.DirExists(t, appPath, "app directory should exist") + + // Language-specific validations (without dependency checks) + switch tt.language { + case create.LanguageTypeScript: + validateTypeScriptTemplate(t, appPath, false) + case create.LanguagePython: + validatePythonTemplate(t, appPath, false) + } + }) + } +} + +// validateTypeScriptTemplate verifies TypeScript template structure and optionally dependencies +func validateTypeScriptTemplate(t *testing.T, appPath string, checkDependencies bool) { + t.Helper() + + // Verify essential files exist + assert.FileExists(t, filepath.Join(appPath, "package.json"), "package.json should exist") + assert.FileExists(t, filepath.Join(appPath, "tsconfig.json"), "tsconfig.json should exist") + assert.FileExists(t, filepath.Join(appPath, "index.ts"), "index.ts should exist") + assert.FileExists(t, filepath.Join(appPath, ".gitignore"), ".gitignore should exist") + + // Verify _gitignore was renamed + assert.NoFileExists(t, filepath.Join(appPath, "_gitignore"), "_gitignore should not exist") + + if checkDependencies { + // Verify node_modules exists (dependencies were installed) + nodeModulesPath := filepath.Join(appPath, "node_modules") + if _, err := os.Stat(nodeModulesPath); err == nil { + // Only check contents if node_modules exists + entries, err := os.ReadDir(nodeModulesPath) + require.NoError(t, err, "should be able to read node_modules directory") + assert.NotEmpty(t, entries, "node_modules should contain installed packages") + } else { + t.Logf("Warning: node_modules not found at %s (npm install may have failed)", nodeModulesPath) + } + } +} + +// validatePythonTemplate verifies Python template structure and optionally dependencies +func validatePythonTemplate(t *testing.T, appPath string, checkDependencies bool) { + t.Helper() + + // Verify essential files exist + assert.FileExists(t, filepath.Join(appPath, "pyproject.toml"), "pyproject.toml should exist") + assert.FileExists(t, filepath.Join(appPath, "main.py"), "main.py should exist") + assert.FileExists(t, filepath.Join(appPath, ".gitignore"), ".gitignore should exist") + + // Verify _gitignore was renamed + assert.NoFileExists(t, filepath.Join(appPath, "_gitignore"), "_gitignore should not exist") + + if checkDependencies { + // Verify .venv exists (virtual environment was created) + venvPath := filepath.Join(appPath, ".venv") + if _, err := os.Stat(venvPath); err == nil { + // Only check contents if .venv exists + binPath := filepath.Join(venvPath, "bin") + assert.DirExists(t, binPath, ".venv/bin directory should exist") + + pythonPath := filepath.Join(binPath, "python") + assert.FileExists(t, pythonPath, ".venv/bin/python should exist") + } else { + t.Logf("Warning: .venv not found at %s (uv venv may have failed)", venvPath) + } + } +} + +// TestCreateCommand_DependencyInstallationFails tests that the app is still created +// even when dependency installation fails, with appropriate warning message +func TestCreateCommand_DependencyInstallationFails(t *testing.T) { + tmpDir := t.TempDir() + appName := "test-app" + + orgDir, err := os.Getwd() + require.NoError(t, err) + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + t.Cleanup(func() { + os.Chdir(orgDir) + }) + + var outputBuf bytes.Buffer + multiWriter := io.MultiWriter(&outputBuf, os.Stdout) + pterm.SetDefaultOutput(multiWriter) + + t.Cleanup(func() { + pterm.SetDefaultOutput(os.Stdout) + }) + + // Override the install command to use a command that will fail + originalInstallCommands := create.InstallCommands + create.InstallCommands = map[string]string{ + create.LanguageTypeScript: "exit 1", // Command that always fails + } + + // Restore original install commands after test + t.Cleanup(func() { + create.InstallCommands = originalInstallCommands + }) + + // Create the app - should succeed even though dependency installation fails + c := CreateCmd{} + err = c.Create(context.Background(), CreateInput{ + Name: appName, + Language: create.LanguageTypeScript, + Template: "sample-app", + }) + + output := outputBuf.String() + + assert.Contains(t, output, "cd test-app", "should print cd command") + assert.Contains(t, output, "pnpm install", "should print pnpm install command") +} + +// TestCreateCommand_RequiredToolMissing tests that the app is created +func TestCreateCommand_RequiredToolMissing(t *testing.T) { + tests := []struct { + name string + language string + template string + }{ + { + name: "typescript with missing pnpm", + language: create.LanguageTypeScript, + template: "sample-app", + }, + { + name: "python with missing uv", + language: create.LanguagePython, + template: "sample-app", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + appName := "test-app" + + orgDir, err := os.Getwd() + require.NoError(t, err) + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + t.Cleanup(func() { + os.Chdir(orgDir) + }) + + // Override the required tool to point to a non-existent command + originalRequiredTools := create.RequiredTools + create.RequiredTools = map[string]string{ + create.LanguageTypeScript: "nonexistent-pnpm-tool", + create.LanguagePython: "nonexistent-uv-tool", + } + + // Restore original required tools after test + t.Cleanup(func() { + create.RequiredTools = originalRequiredTools + }) + + // Create the app - should succeed even though required tool is missing + c := CreateCmd{} + err = c.Create(context.Background(), CreateInput{ + Name: appName, + Language: tt.language, + Template: tt.template, + }) + + // Should not return an error - the command should complete successfully + // but skip dependency installation + require.NoError(t, err, "app creation should succeed even when required tool is missing") + + // Verify the app directory and files were created + appPath := filepath.Join(tmpDir, appName) + assert.DirExists(t, appPath, "app directory should exist") + + // Language-specific file checks + switch tt.language { + case create.LanguageTypeScript: + assert.FileExists(t, filepath.Join(appPath, "package.json"), "package.json should exist") + assert.FileExists(t, filepath.Join(appPath, "index.ts"), "index.ts should exist") + assert.FileExists(t, filepath.Join(appPath, "tsconfig.json"), "tsconfig.json should exist") + + // node_modules should NOT exist since pnpm was not available + assert.NoDirExists(t, filepath.Join(appPath, "node_modules"), "node_modules should not exist when pnpm is missing") + case create.LanguagePython: + assert.FileExists(t, filepath.Join(appPath, "pyproject.toml"), "pyproject.toml should exist") + assert.FileExists(t, filepath.Join(appPath, "main.py"), "main.py should exist") + + // .venv should NOT exist since uv was not available + assert.NoDirExists(t, filepath.Join(appPath, ".venv"), ".venv should not exist when uv is missing") + } + }) + } +} + +func getTemplateInfo() []struct { + name string + language string + template string +} { + tests := make([]struct { + name string + language string + template string + }, 0) + + for templateKey, templateInfo := range create.Templates { + for _, lang := range templateInfo.Languages { + tests = append(tests, struct { + name string + language string + template string + }{ + name: lang + "/" + templateKey, + language: lang, + template: templateKey, + }) + } + } + + return tests +} diff --git a/pkg/create/dependencies.go b/pkg/create/dependencies.go new file mode 100644 index 0000000..6c31417 --- /dev/null +++ b/pkg/create/dependencies.go @@ -0,0 +1,100 @@ +package create + +import ( + "fmt" + "os/exec" + + "github.com/pterm/pterm" +) + +// InstallDependencies sets up project dependencies based on language +func InstallDependencies(appName string, appPath string, language string) (string, error) { + installCommand, ok := InstallCommands[language] + if !ok { + return "", fmt.Errorf("unsupported language: %s", language) + } + + requiredTool := RequiredTools[language] + if requiredTool != "" && !RequiredTools.CheckToolAvailable(language) { + return getNextStepsWithToolInstall(appName, language, requiredTool), nil + } + + spinner, _ := pterm.DefaultSpinner.Start(pterm.Sprintf("Setting up %s environment...", language)) + + cmd := exec.Command("sh", "-c", installCommand) + cmd.Dir = appPath + + if err := cmd.Run(); err != nil { + spinner.Stop() + pterm.Warning.Println("Failed to install dependencies. Please install them manually:") + switch language { + case LanguageTypeScript: + pterm.Printfln(" cd %s", appName) + pterm.Printfln(" pnpm install") + case LanguagePython: + pterm.Printfln(" cd %s", appName) + pterm.Println(" uv venv && source .venv/bin/activate && uv sync") + } + pterm.Println() + return getNextStepsStandard(appName), nil + } + + spinner.Success(pterm.Sprintf("✔ %s environment set up successfully", language)) + + return getNextStepsStandard(appName), nil +} + +// getNextStepsWithToolInstall returns next steps message including tool installation +func getNextStepsWithToolInstall(appName string, language string, requiredTool string) string { + pterm.Warning.Printfln(" %s is not installed or not in PATH", requiredTool) + + switch language { + case LanguageTypeScript: + return pterm.FgYellow.Sprintf(`Next steps: + # Install pnpm (choose one): + npm install -g pnpm + # or: brew install pnpm + # or: curl -fsSL https://get.pnpm.io/install.sh | sh - + + # Then install dependencies: + cd %s + pnpm install + + # Deploy your app: + brew install onkernel/tap/kernel + kernel login # or: export KERNEL_API_KEY= + kernel deploy index.ts + kernel invoke ts-basic get-page-title --payload '{"url": "https://www.google.com"}' +`, appName) + case LanguagePython: + return pterm.FgYellow.Sprintf(`Next steps: + # Install uv (choose one): + curl -LsSf https://astral.sh/uv/install.sh | sh + # or: brew install uv + # or: pipx install uv + + # Then set up your environment: + cd %s + uv venv && source .venv/bin/activate && uv sync + + # Deploy your app: + brew install onkernel/tap/kernel + kernel login # or: export KERNEL_API_KEY= + kernel deploy index.py + kernel invoke py-basic get-page-title --payload '{"url": "https://www.google.com"}' +`, appName) + default: + return "" + } +} + +// getNextStepsStandard returns standard next steps message +func getNextStepsStandard(appName string) string { + return pterm.FgYellow.Sprintf(`Next steps: + brew install onkernel/tap/kernel + cd %s + kernel login # or: export KERNEL_API_KEY= + kernel deploy index.ts + kernel invoke ts-basic get-page-title --payload '{"url": "https://www.google.com"}' +`, appName) +} diff --git a/pkg/create/types.go b/pkg/create/types.go index 71215e0..67939b7 100644 --- a/pkg/create/types.go +++ b/pkg/create/types.go @@ -1,5 +1,7 @@ package create +import "os/exec" + const ( DefaultAppName = "my-kernel-app" AppNamePrompt = "What is the name of your project?" @@ -14,6 +16,23 @@ const ( LanguageShorthandPython = "py" ) +type Tools map[string]string + +var RequiredTools = Tools{ + LanguageTypeScript: "pnpm", + LanguagePython: "uv", +} + +func (t Tools) CheckToolAvailable(tool string) bool { + _, err := exec.LookPath(t[tool]) + return err == nil +} + +var InstallCommands = map[string]string{ + LanguageTypeScript: "pnpm install", + LanguagePython: "uv venv", +} + // SupportedLanguages returns a list of all supported languages var SupportedLanguages = []string{ LanguageTypeScript,