diff --git a/.gitignore b/.gitignore index 1da05da8..7922457b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ result .gocache/ release-notes.md release-notes.json + +CLAUDE.*.md diff --git a/cmd/stackpack.go b/cmd/stackpack.go index 6a588baf..12010a40 100644 --- a/cmd/stackpack.go +++ b/cmd/stackpack.go @@ -1,6 +1,8 @@ package cmd import ( + "os" + "github.com/spf13/cobra" "github.com/stackvista/stackstate-cli/cmd/stackpack" "github.com/stackvista/stackstate-cli/internal/di" @@ -22,5 +24,10 @@ func StackPackCommand(cli *di.Deps) *cobra.Command { cmd.AddCommand(stackpack.StackpackConfirmManualStepsCommand(cli)) cmd.AddCommand(stackpack.StackpackDescribeCommand(cli)) + // Only add scaffold command if experimental feature is enabled + if os.Getenv("STS_EXPERIMENTAL_STACKPACK_SCAFFOLD") != "" { + cmd.AddCommand(stackpack.StackpackScaffoldCommand(cli)) + } + return cmd } diff --git a/cmd/stackpack/stackpack_scaffold.go b/cmd/stackpack/stackpack_scaffold.go new file mode 100644 index 00000000..f1be0c51 --- /dev/null +++ b/cmd/stackpack/stackpack_scaffold.go @@ -0,0 +1,198 @@ +package stackpack + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/spf13/cobra" + stscobra "github.com/stackvista/stackstate-cli/internal/cobra" + "github.com/stackvista/stackstate-cli/internal/common" + "github.com/stackvista/stackstate-cli/internal/di" + "github.com/stackvista/stackstate-cli/pkg/scaffold" +) + +const ( + defaultTemplateGitHubRepo = "StackVista/stackpack-templates" // Default GitHub repository for templates + defaultTemplateGitHubRef = "main" // Default branch for GitHub templates + defaultTemplateGitHubPath = "templates" // Default path in GitHub repo for templates + defaultTemplateName = "generic" // Default template name to use +) + +type ScaffoldArgs struct { + // Local template source + TemplateLocalDir string + + // GitHub template source + TemplateGitHubRepo string // Format: "owner/repo" + TemplateGitHubRef string + TemplateGitHubPath string + + // Common flags + DestinationDir string + Name string + DisplayName string + TemplateName string + Force bool +} + +func StackpackScaffoldCommand(cli *di.Deps) *cobra.Command { + args := &ScaffoldArgs{} + cmd := &cobra.Command{ + Use: "scaffold", + Short: "Create a stackpack skeleton from a template", + Long: `Create a stackpack skeleton from a template. + +This command scaffolds a new stackpack project structure from a template source. +The template can be from a local directory or a GitHub repository. +The template can be customized with the stackpack name and other variables.`, + Example: `# Create a stackpack using defaults (uses default GitHub repo and template) +sts stackpack scaffold --name my-stackpack + +# Create a stackpack from a local template (looks for ./templates/stackpack/ subdirectory) +sts stackpack scaffold --template-local-dir ./templates --name my-awesome-stackpack --template-name stackpack + +# Overwrite existing files without prompting +sts stackpack scaffold --name my-awesome-stackpack --force + +# Create a stackpack from a specific GitHub repository +sts stackpack scaffold --template-github-repo stackvista/my-templates --name my-awesome-stackpack --template-name generic`, + RunE: cli.CmdRunE(RunStackpackScaffoldCommand(args)), + } + + // Template source flags (mutually exclusive, defaults to GitHub repo if none specified) + cmd.Flags().StringVar(&args.TemplateLocalDir, "template-local-dir", "", "Path to local directory containing template subdirectories") + cmd.Flags().StringVar(&args.TemplateGitHubRepo, "template-github-repo", "", fmt.Sprintf("GitHub repository in format 'owner/repo' (default: %s)", defaultTemplateGitHubRepo)) + cmd.Flags().StringVar(&args.TemplateGitHubRef, "template-github-ref", "main", fmt.Sprintf("Git reference (branch, tag, or commit SHA) (default: %s)", defaultTemplateGitHubRef)) + cmd.Flags().StringVar(&args.TemplateGitHubPath, "template-github-path", "", fmt.Sprintf("Path within the repository containing template subdirectories (default: %s)", defaultTemplateGitHubPath)) + + // Common flags + cmd.Flags().StringVar(&args.DestinationDir, "destination-dir", "", "Target directory where scaffolded files will be created. If not specified, uses current working directory") + cmd.Flags().StringVar(&args.Name, "name", "", "Name of the stackpack (required). Must start with [a-z] and contain only lowercase letters, digits, and hyphens") + cmd.Flags().StringVar(&args.DisplayName, "display-name", "", "Name that's displayed on both the StackPack listing page and on the title of the StackPack page. If not provided, the value of --name will be used") + cmd.Flags().StringVar(&args.TemplateName, "template-name", defaultTemplateName, fmt.Sprintf("Name of the template subdirectory to use (default: %s)", defaultTemplateName)) + cmd.Flags().BoolVar(&args.Force, "force", false, "Overwrite existing files without prompting") + + // Mark required flags + cmd.MarkFlagRequired("name") //nolint:errcheck + + // Template sources are mutually exclusive but not required (will use default GitHub repo if none specified) + stscobra.MarkMutexFlags(cmd, []string{"template-local-dir", "template-github-repo"}, "template-source", false) + + return cmd +} + +func RunStackpackScaffoldCommand(args *ScaffoldArgs) func(cli *di.Deps, cmd *cobra.Command) common.CLIError { + return func(cli *di.Deps, cmd *cobra.Command) common.CLIError { + // Create template source based on which source was specified + var source scaffold.TemplateSource + var err error + + if args.DestinationDir == "" { + args.DestinationDir, err = os.Getwd() + if err != nil { + return common.NewRuntimeError(fmt.Errorf("failed to get current working directory: %w", err)) + } + } + + // Validate stackpack name + if err := validateStackpackName(args.Name); err != nil { + return common.NewCLIArgParseError(err) + } + + if args.TemplateLocalDir != "" { + source = scaffold.NewLocalDirSource(args.TemplateLocalDir, args.TemplateName) + } else { + // Use GitHub repository (either specified or default) + githubRepo := defaultIfEmptyString(args.TemplateGitHubRepo, defaultTemplateGitHubRepo) + githubRef := defaultIfEmptyString(args.TemplateGitHubRef, defaultTemplateGitHubRef) + githubPath := defaultIfEmptyString(args.TemplateGitHubPath, defaultTemplateGitHubPath) + + // Parse owner/repo format + owner, repo, err := parseGitHubRepo(githubRepo) + if err != nil { + return common.NewCLIArgParseError(err) + } + source = scaffold.NewGitHubSource(owner, repo, githubRef, githubPath, args.TemplateName) + } + + // Create template context + displayName := args.DisplayName + if displayName == "" { + displayName = args.Name + } + context := scaffold.TemplateContext{ + Name: args.Name, + DisplayName: displayName, + TemplateName: args.TemplateName, + } + + // Create scaffolder with force flag, printer, and JSON output mode + scaffolder := scaffold.NewScaffolder(source, args.DestinationDir, context, args.Force, cli.Printer, cli.IsJson()) + // Execute scaffolding + result, cleanUpFn, err := scaffolder.Scaffold(cmd.Context()) + if err != nil { + return common.NewRuntimeError(err) + } + + err = cleanUpFn() + if err != nil { + return common.NewRuntimeError(fmt.Errorf("failed to clean up temporary files: %w", err)) + } + + if cli.IsJson() { + cli.Printer.PrintJson(map[string]interface{}{ + "success": result.Success, + "source": result.Source, + "destination": result.Destination, + "name": result.Name, + "template": result.Template, + "files_count": result.FilesCount, + "files": result.Files, + }) + } else { + // Display success message and next steps + cli.Printer.Successf("✓ Scaffold complete!") + cli.Printer.PrintLn("") + displayNextSteps(cli, args) + } + + return nil + } +} + +// parseGitHubRepo parses "owner/repo" format into separate owner and repo +func parseGitHubRepo(repoString string) (string, string, error) { + parts := strings.Split(repoString, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("invalid GitHub repository format '%s', expected 'owner/repo'", repoString) + } + return parts[0], parts[1], nil +} + +// validateStackpackName validates the stackpack name according to naming rules +func validateStackpackName(name string) error { + // Pattern: starts with [a-z], followed by [a-z0-9-]* + validNamePattern := regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + + if !validNamePattern.MatchString(name) { + return fmt.Errorf("invalid stackpack name '%s': must start with a lowercase letter [a-z] and contain only lowercase letters, digits, and hyphens", name) + } + + return nil +} + +func displayNextSteps(cli *di.Deps, args *ScaffoldArgs) { + cli.Printer.PrintLn("Next steps:") + cli.Printer.PrintLn("1. Review the generated files in: " + args.DestinationDir) + cli.Printer.PrintLn(fmt.Sprintf("2. Check the %s for instructions on what to do next.", filepath.Join(args.DestinationDir, "README.md"))) +} + +func defaultIfEmptyString(value, defaultValue string) string { + if value == "" { + return defaultValue + } + return value +} diff --git a/cmd/stackpack/stackpack_scaffold_test.go b/cmd/stackpack/stackpack_scaffold_test.go new file mode 100644 index 00000000..91623a4f --- /dev/null +++ b/cmd/stackpack/stackpack_scaffold_test.go @@ -0,0 +1,872 @@ +package stackpack + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stackvista/stackstate-cli/internal/di" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupStackpackScaffoldCmd(t *testing.T) (*di.MockDeps, *cobra.Command) { + cli := di.NewMockDeps(t) + cmd := StackpackScaffoldCommand(&cli.Deps) + return &cli, cmd +} + +func TestStackpackScaffoldCommand_RequiredFlags(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + errorMessage string + }{ + { + name: "missing name flag", + args: []string{}, + wantErr: true, + errorMessage: `required flag(s) "name" not set`, + }, + { + name: "valid minimal args with defaults", + args: []string{"--name", "test-stackpack"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !tt.wantErr { + // Create temporary directory for successful command execution to avoid polluting current directory + tempDir, err := os.MkdirTemp("", "stackpack-required-flags-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Change to temp directory so command uses it as default destination + originalWd, err := os.Getwd() + require.NoError(t, err) + defer func() { + err := os.Chdir(originalWd) + require.NoError(t, err, "Failed to change back to original working directory") + }() + + err = os.Chdir(tempDir) + require.NoError(t, err) + } + + cli, cmd := setupStackpackScaffoldCmd(t) + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, tt.args...) + + if tt.wantErr { + require.Error(t, err) + if tt.errorMessage != "" { + assert.Contains(t, err.Error(), tt.errorMessage) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestStackpackScaffoldCommand_MutuallyExclusiveFlags(t *testing.T) { + cli, cmd := setupStackpackScaffoldCmd(t) + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, + "--name", "test-stackpack", + "--template-local-dir", "./templates", + "--template-github-repo", "owner/repo", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "template-github-repo") +} + +func TestParseGitHubRepo(t *testing.T) { + tests := []struct { + name string + repoString string + expectedOwner string + expectedRepo string + wantErr bool + errorMessage string + }{ + { + name: "valid owner/repo format", + repoString: "stackvista/my-templates", + expectedOwner: "stackvista", + expectedRepo: "my-templates", + wantErr: false, + }, + { + name: "valid with numbers and dashes", + repoString: "owner-123/repo-456", + expectedOwner: "owner-123", + expectedRepo: "repo-456", + wantErr: false, + }, + { + name: "missing slash", + repoString: "invalidrepo", + wantErr: true, + errorMessage: "invalid GitHub repository format 'invalidrepo', expected 'owner/repo'", + }, + { + name: "empty owner", + repoString: "/repo", + wantErr: true, + errorMessage: "invalid GitHub repository format '/repo', expected 'owner/repo'", + }, + { + name: "empty repo", + repoString: "owner/", + wantErr: true, + errorMessage: "invalid GitHub repository format 'owner/', expected 'owner/repo'", + }, + { + name: "multiple slashes", + repoString: "owner/repo/extra", + wantErr: true, + errorMessage: "invalid GitHub repository format 'owner/repo/extra', expected 'owner/repo'", + }, + { + name: "empty string", + repoString: "", + wantErr: true, + errorMessage: "invalid GitHub repository format '', expected 'owner/repo'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner, repo, err := parseGitHubRepo(tt.repoString) + if tt.wantErr { + require.Error(t, err) + if tt.errorMessage != "" { + assert.Equal(t, tt.errorMessage, err.Error()) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedOwner, owner) + assert.Equal(t, tt.expectedRepo, repo) + } + }) + } +} + +func TestDefaultIfEmptyString(t *testing.T) { + tests := []struct { + name string + value string + defaultValue string + expected string + }{ + { + name: "empty value returns default", + value: "", + defaultValue: "default-value", + expected: "default-value", + }, + { + name: "non-empty value returns value", + value: "custom-value", + defaultValue: "default-value", + expected: "custom-value", + }, + { + name: "whitespace value returns whitespace", + value: " ", + defaultValue: "default-value", + expected: " ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := defaultIfEmptyString(tt.value, tt.defaultValue) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestStackpackScaffoldCommand_LocalDirSource(t *testing.T) { + // Create temporary directories for testing + tempDir, err := os.MkdirTemp("", "stackpack-scaffold-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a mock template structure + templateDir := filepath.Join(tempDir, "templates") + genericTemplateDir := filepath.Join(templateDir, "generic") + err = os.MkdirAll(genericTemplateDir, 0755) + require.NoError(t, err) + + // Create a test template file + templateFile := filepath.Join(genericTemplateDir, "test.txt") + err = os.WriteFile(templateFile, []byte("Hello <<.Name>>!"), 0644) + require.NoError(t, err) + + // Create destination directory + destDir := filepath.Join(tempDir, "destination") + err = os.MkdirAll(destDir, 0755) + require.NoError(t, err) + + tests := []struct { + name string + args []string + wantErr bool + }{ + { + name: "local directory source success", + args: []string{ + "--name", "test-stackpack", + "--template-local-dir", templateDir, + "--template-name", "generic", + "--destination-dir", destDir, + }, + wantErr: false, + }, + { + name: "local directory source with non-existent template", + args: []string{ + "--name", "test-stackpack", + "--template-local-dir", templateDir, + "--template-name", "non-existent", + "--destination-dir", destDir, + }, + wantErr: true, + }, + { + name: "local directory source with non-existent directory", + args: []string{ + "--name", "test-stackpack", + "--template-local-dir", "/non/existent/path", + "--template-name", "generic", + "--destination-dir", destDir, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cli, cmd := setupStackpackScaffoldCmd(t) + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, tt.args...) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + + // Verify the file was created and processed + createdFile := filepath.Join(destDir, "test.txt") + content, err := os.ReadFile(createdFile) + require.NoError(t, err) + assert.Equal(t, "Hello test-stackpack!", string(content)) + + // Clean up for next test + _ = os.RemoveAll(destDir) + } + }) + } +} + +func TestStackpackScaffoldCommand_JSONOutput(t *testing.T) { + // Create temporary directories for testing + tempDir, err := os.MkdirTemp("", "stackpack-scaffold-json-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a mock template structure + templateDir := filepath.Join(tempDir, "templates") + genericTemplateDir := filepath.Join(templateDir, "generic") + err = os.MkdirAll(genericTemplateDir, 0755) + require.NoError(t, err) + + // Create a test template file + templateFile := filepath.Join(genericTemplateDir, "config.json") + err = os.WriteFile(templateFile, []byte(`{"name": "<<.Name>>", "template": "<<.TemplateName>>"}`), 0644) + require.NoError(t, err) + + // Create destination directory + destDir := filepath.Join(tempDir, "destination") + err = os.MkdirAll(destDir, 0755) + require.NoError(t, err) + + tests := []struct { + name string + args []string + expectJson bool + expectText bool + }{ + { + name: "regular text output", + args: []string{ + "--name", "test-stackpack", + "--template-local-dir", templateDir, + "--template-name", "generic", + "--destination-dir", destDir, + }, + expectJson: false, + expectText: true, + }, + { + name: "JSON output", + args: []string{ + "--name", "test-stackpack", + "--template-local-dir", templateDir, + "--template-name", "generic", + "--destination-dir", destDir, + "-o", "json", + }, + expectJson: true, + expectText: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cli, cmd := setupStackpackScaffoldCmd(t) + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, tt.args...) + require.NoError(t, err) + + if tt.expectJson { + // Verify JSON output was called + require.Len(t, *cli.MockPrinter.PrintJsonCalls, 1) + jsonOutput := (*cli.MockPrinter.PrintJsonCalls)[0] + // The JSON output should contain the scaffold result + assert.Contains(t, jsonOutput, "success") + + // Verify no regular text calls were made for success messages + assert.False(t, cli.MockPrinter.HasNonJsonCalls) + } else { + // Verify text output was called + require.NotEmpty(t, *cli.MockPrinter.SuccessCalls) + successCall := (*cli.MockPrinter.SuccessCalls)[0] + assert.Contains(t, successCall, "Scaffold complete") + + // Verify PrintLn was called for next steps + require.NotEmpty(t, *cli.MockPrinter.PrintLnCalls) + } + + // Clean up for next test + _ = os.RemoveAll(destDir) + }) + } +} + +func TestStackpackScaffoldCommand_ForceFlag(t *testing.T) { + // Create temporary directories for testing + tempDir, err := os.MkdirTemp("", "stackpack-scaffold-force-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a mock template structure + templateDir := filepath.Join(tempDir, "templates") + genericTemplateDir := filepath.Join(templateDir, "generic") + err = os.MkdirAll(genericTemplateDir, 0755) + require.NoError(t, err) + + // Create a test template file + templateFile := filepath.Join(genericTemplateDir, "existing.txt") + err = os.WriteFile(templateFile, []byte("Template content"), 0644) + require.NoError(t, err) + + // Create destination directory with conflicting file + destDir := filepath.Join(tempDir, "destination") + err = os.MkdirAll(destDir, 0755) + require.NoError(t, err) + + existingFile := filepath.Join(destDir, "existing.txt") + err = os.WriteFile(existingFile, []byte("Existing content"), 0644) + require.NoError(t, err) + + t.Run("without force flag should fail on conflict", func(t *testing.T) { + cli, cmd := setupStackpackScaffoldCmd(t) + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, + "--name", "test-stackpack", + "--template-local-dir", templateDir, + "--template-name", "generic", + "--destination-dir", destDir, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "conflicting files exist") + }) + + t.Run("with force flag should succeed on conflict", func(t *testing.T) { + cli, cmd := setupStackpackScaffoldCmd(t) + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, + "--name", "test-stackpack", + "--template-local-dir", templateDir, + "--template-name", "generic", + "--destination-dir", destDir, + "--force", + ) + + require.NoError(t, err) + + // Verify the file was overwritten + content, err := os.ReadFile(existingFile) + require.NoError(t, err) + assert.Equal(t, "Template content", string(content)) + }) +} + +func TestStackpackScaffoldCommand_TemplateContextPassedCorrectly(t *testing.T) { + // Create temporary directories for testing + tempDir, err := os.MkdirTemp("", "stackpack-scaffold-context-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a mock template structure + templateDir := filepath.Join(tempDir, "templates") + customTemplateDir := filepath.Join(templateDir, "custom-template") + err = os.MkdirAll(customTemplateDir, 0755) + require.NoError(t, err) + + // Create a test template file with Name, DisplayName, and TemplateName variables + templateFile := filepath.Join(customTemplateDir, "config.yaml") + templateContent := `name: <<.Name>> +display_name: <<.DisplayName>> +template: <<.TemplateName>> +` + err = os.WriteFile(templateFile, []byte(templateContent), 0644) + require.NoError(t, err) + + // Create destination directory + destDir := filepath.Join(tempDir, "destination") + err = os.MkdirAll(destDir, 0755) + require.NoError(t, err) + + cli, cmd := setupStackpackScaffoldCmd(t) + + _, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, + "--name", "my-awesome-stackpack", + "--template-local-dir", templateDir, + "--template-name", "custom-template", + "--destination-dir", destDir, + ) + + require.NoError(t, err) + + // Verify the template variables were substituted correctly + createdFile := filepath.Join(destDir, "config.yaml") + content, err := os.ReadFile(createdFile) + require.NoError(t, err) + + expectedContent := `name: my-awesome-stackpack +display_name: my-awesome-stackpack +template: custom-template +` + assert.Equal(t, expectedContent, string(content)) +} + +func TestRunStackpackScaffoldCommand_SourceSelection(t *testing.T) { + tests := []struct { + name string + args ScaffoldArgs + expected string // Expected source type (for assertion purposes) + }{ + { + name: "local directory source selected", + args: ScaffoldArgs{ + TemplateLocalDir: "/path/to/templates", + Name: "test-stackpack", + TemplateName: "generic", + }, + expected: "local", + }, + { + name: "github source selected with defaults", + args: ScaffoldArgs{ + Name: "test-stackpack", + TemplateName: "generic", + }, + expected: "github-default", + }, + { + name: "github source selected with custom repo", + args: ScaffoldArgs{ + TemplateGitHubRepo: "custom/repo", + Name: "test-stackpack", + TemplateName: "generic", + }, + expected: "github-custom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // We can't easily test the actual source creation without mocking, + // but we can test the source selection logic by examining the defaults + switch tt.expected { + case "local": + assert.NotEmpty(t, tt.args.TemplateLocalDir, "Local dir should be set") + case "github-default": + assert.Empty(t, tt.args.TemplateLocalDir, "Local dir should be empty for GitHub source") + assert.Empty(t, tt.args.TemplateGitHubRepo, "GitHub repo should be empty to use defaults") + case "github-custom": + assert.Empty(t, tt.args.TemplateLocalDir, "Local dir should be empty for GitHub source") + assert.NotEmpty(t, tt.args.TemplateGitHubRepo, "GitHub repo should be set") + } + }) + } +} + +func TestStackpackScaffoldCommand_DefaultValues(t *testing.T) { + _, cmd := setupStackpackScaffoldCmd(t) + + // Test that defaults are properly set in the command flags + flags := cmd.Flags() + + // Check default values + templateGitHubRefFlag := flags.Lookup("template-github-ref") + assert.Equal(t, "main", templateGitHubRefFlag.DefValue) + + templateNameFlag := flags.Lookup("template-name") + assert.Equal(t, defaultTemplateName, templateNameFlag.DefValue) + + forceFlag := flags.Lookup("force") + assert.Equal(t, "false", forceFlag.DefValue) +} + +func TestDisplayNextSteps(t *testing.T) { + cli := di.NewMockDeps(t) + args := &ScaffoldArgs{ + DestinationDir: "/path/to/destination", + Name: "test-stackpack", + } + + displayNextSteps(&cli.Deps, args) + + // Verify that next steps were printed + printLnCalls := *cli.MockPrinter.PrintLnCalls + require.Greater(t, len(printLnCalls), 0) + + // Check that specific next steps are mentioned + allOutput := fmt.Sprintf("%v", printLnCalls) + assert.Contains(t, allOutput, "Next steps") + assert.Contains(t, allOutput, args.DestinationDir) + assert.Contains(t, allOutput, "Review the generated files") + assert.Contains(t, allOutput, "for instructions on what to do next") +} + +func TestStackpackScaffoldCommand_DisplayNameFallback(t *testing.T) { + // Create temporary directories for testing + tempDir, err := os.MkdirTemp("", "stackpack-scaffold-displayname-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a mock template structure + templateDir := filepath.Join(tempDir, "templates") + customTemplateDir := filepath.Join(templateDir, "custom-template") + err = os.MkdirAll(customTemplateDir, 0755) + require.NoError(t, err) + + // Create a test template file with DisplayName variable + templateFile := filepath.Join(customTemplateDir, "test-displayname.yaml") + templateContent := `name: <<.Name>> +display_name: <<.DisplayName>> +` + err = os.WriteFile(templateFile, []byte(templateContent), 0644) + require.NoError(t, err) + + // Create destination directory + destDir := filepath.Join(tempDir, "destination") + err = os.MkdirAll(destDir, 0755) + require.NoError(t, err) + + tests := []struct { + name string + args []string + expectedDisplayName string + description string + }{ + { + name: "with display-name flag provided", + args: []string{ + "--name", "my-stackpack", + "--display-name", "My Awesome StackPack", + "--template-local-dir", templateDir, + "--template-name", "custom-template", + "--destination-dir", destDir, + }, + expectedDisplayName: "My Awesome StackPack", + description: "Should use provided display name", + }, + { + name: "without display-name flag - should fallback to name", + args: []string{ + "--name", "my-stackpack-fallback", + "--template-local-dir", templateDir, + "--template-name", "custom-template", + "--destination-dir", destDir, + }, + expectedDisplayName: "my-stackpack-fallback", + description: "Should fallback to name when display-name not provided", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cli, cmd := setupStackpackScaffoldCmd(t) + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, tt.args...) + require.NoError(t, err, tt.description) + + // Verify the template variables were substituted correctly + createdFile := filepath.Join(destDir, "test-displayname.yaml") + content, err := os.ReadFile(createdFile) + require.NoError(t, err) + + // Check that DisplayName was correctly used + assert.Contains(t, string(content), "display_name: "+tt.expectedDisplayName, tt.description) + + // Clean up for next test iteration + _ = os.RemoveAll(destDir) + _ = os.MkdirAll(destDir, 0755) + }) + } +} + +//nolint:funlen +func TestValidateStackpackName(t *testing.T) { + tests := []struct { + name string + stackName string + wantErr bool + errorSubstr string + }{ + // Valid names + { + name: "valid single letter", + stackName: "a", + wantErr: false, + }, + { + name: "valid simple name", + stackName: "app", + wantErr: false, + }, + { + name: "valid name with hyphens", + stackName: "my-stackpack", + wantErr: false, + }, + { + name: "valid name with numbers", + stackName: "app123", + wantErr: false, + }, + { + name: "valid complex name", + stackName: "my-app-v2-123", + wantErr: false, + }, + // Invalid names - starts with digit + { + name: "invalid starts with digit", + stackName: "1-stackpack", + wantErr: true, + errorSubstr: "must start with a lowercase letter [a-z]", + }, + { + name: "invalid starts with number", + stackName: "2app", + wantErr: true, + errorSubstr: "must start with a lowercase letter [a-z]", + }, + // Invalid names - starts with hyphen + { + name: "invalid starts with hyphen", + stackName: "-stackpack", + wantErr: true, + errorSubstr: "must start with a lowercase letter [a-z]", + }, + // Invalid names - uppercase letters + { + name: "invalid uppercase first letter", + stackName: "My-Stackpack", + wantErr: true, + errorSubstr: "must start with a lowercase letter [a-z]", + }, + { + name: "invalid camelCase", + stackName: "myApp", + wantErr: true, + errorSubstr: "lowercase letters, digits, and hyphens", + }, + { + name: "invalid all uppercase", + stackName: "APP", + wantErr: true, + errorSubstr: "must start with a lowercase letter [a-z]", + }, + // Invalid names - spaces + { + name: "invalid with spaces", + stackName: "my stackpack", + wantErr: true, + errorSubstr: "lowercase letters, digits, and hyphens", + }, + { + name: "invalid app name with space", + stackName: "app name", + wantErr: true, + errorSubstr: "lowercase letters, digits, and hyphens", + }, + // Invalid names - special characters + { + name: "invalid underscore", + stackName: "my_stackpack", + wantErr: true, + errorSubstr: "lowercase letters, digits, and hyphens", + }, + { + name: "invalid at symbol", + stackName: "app@domain", + wantErr: true, + errorSubstr: "lowercase letters, digits, and hyphens", + }, + { + name: "invalid dot", + stackName: "stack.pack", + wantErr: true, + errorSubstr: "lowercase letters, digits, and hyphens", + }, + { + name: "invalid slash", + stackName: "my/app", + wantErr: true, + errorSubstr: "lowercase letters, digits, and hyphens", + }, + { + name: "invalid colon", + stackName: "app:v1", + wantErr: true, + errorSubstr: "lowercase letters, digits, and hyphens", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateStackpackName(tt.stackName) + if tt.wantErr { + require.Error(t, err, "Expected validation to fail for name '%s'", tt.stackName) + if tt.errorSubstr != "" { + assert.Contains(t, err.Error(), tt.errorSubstr, "Error message should contain expected substring") + } + assert.Contains(t, err.Error(), tt.stackName, "Error message should contain the invalid name") + } else { + require.NoError(t, err, "Expected validation to pass for name '%s'", tt.stackName) + } + }) + } +} + +func TestStackpackScaffoldCommand_NameValidation(t *testing.T) { + // Create temporary template directory for all tests to avoid GitHub template conflicts + tempDir, err := os.MkdirTemp("", "stackpack-validation-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a mock template structure + templateDir := filepath.Join(tempDir, "templates") + genericTemplateDir := filepath.Join(templateDir, "generic") + err = os.MkdirAll(genericTemplateDir, 0755) + require.NoError(t, err) + + // Create a simple test template file + templateFile := filepath.Join(genericTemplateDir, "test.txt") + err = os.WriteFile(templateFile, []byte("Test stackpack: <<.Name>>"), 0644) + require.NoError(t, err) + + // Create destination directory + destDir := filepath.Join(tempDir, "destination") + err = os.MkdirAll(destDir, 0755) + require.NoError(t, err) + + tests := []struct { + name string + args []string + wantErr bool + errorSubstr string + }{ + { + name: "valid name passes validation", + args: []string{ + "--name", "my-stackpack", + "--template-local-dir", templateDir, + "--destination-dir", destDir, + }, + wantErr: false, + }, + { + name: "invalid name with uppercase fails", + args: []string{ + "--name", "My-Stackpack", + "--template-local-dir", templateDir, + "--destination-dir", destDir, + }, + wantErr: true, + errorSubstr: "must start with a lowercase letter [a-z]", + }, + { + name: "invalid name with spaces fails", + args: []string{ + "--name", "my stackpack", + "--template-local-dir", templateDir, + "--destination-dir", destDir, + }, + wantErr: true, + errorSubstr: "lowercase letters, digits, and hyphens", + }, + { + name: "invalid name starting with number fails", + args: []string{ + "--name", "1-app", + "--template-local-dir", templateDir, + "--destination-dir", destDir, + }, + wantErr: true, + errorSubstr: "must start with a lowercase letter [a-z]", + }, + { + name: "invalid name with underscore fails", + args: []string{ + "--name", "my_app", + "--template-local-dir", templateDir, + "--destination-dir", destDir, + }, + wantErr: true, + errorSubstr: "lowercase letters, digits, and hyphens", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cli, cmd := setupStackpackScaffoldCmd(t) + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, tt.args...) + + if tt.wantErr { + require.Error(t, err, "Expected command to fail for invalid name") + if tt.errorSubstr != "" { + assert.Contains(t, err.Error(), tt.errorSubstr, "Error message should contain expected validation message") + } + } else { + require.NoError(t, err, "Expected command to succeed for valid name") + + // Clean up destination for next test iteration + _ = os.RemoveAll(destDir) + _ = os.MkdirAll(destDir, 0755) + } + }) + } +} diff --git a/cmd/stackpack_test.go b/cmd/stackpack_test.go new file mode 100644 index 00000000..8029cdea --- /dev/null +++ b/cmd/stackpack_test.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "os" + "testing" + + "github.com/stackvista/stackstate-cli/internal/di" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStackPackCommand_FeatureGating(t *testing.T) { + tests := []struct { + name string + envVarValue string + expectScaffoldCommand bool + description string + }{ + { + name: "scaffold command hidden by default", + envVarValue: "", + expectScaffoldCommand: false, + description: "When environment variable is not set, scaffold command should be hidden", + }, + { + name: "scaffold command visible when env var is set to 1", + envVarValue: "1", + expectScaffoldCommand: true, + description: "When environment variable is set to '1', scaffold command should be visible", + }, + { + name: "scaffold command visible when env var is set to any value", + envVarValue: "true", + expectScaffoldCommand: true, + description: "When environment variable is set to any non-empty value, scaffold command should be visible", + }, + { + name: "scaffold command visible when env var is set to enabled", + envVarValue: "enabled", + expectScaffoldCommand: true, + description: "When environment variable is set to 'enabled', scaffold command should be visible", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Store original environment value to restore later + originalValue := os.Getenv("STS_EXPERIMENTAL_STACKPACK_SCAFFOLD") + defer func() { + if originalValue == "" { + os.Unsetenv("STS_EXPERIMENTAL_STACKPACK_SCAFFOLD") + } else { + os.Setenv("STS_EXPERIMENTAL_STACKPACK_SCAFFOLD", originalValue) + } + }() + + // Set the environment variable for this test + if tt.envVarValue == "" { + os.Unsetenv("STS_EXPERIMENTAL_STACKPACK_SCAFFOLD") + } else { + err := os.Setenv("STS_EXPERIMENTAL_STACKPACK_SCAFFOLD", tt.envVarValue) + require.NoError(t, err) + } + + // Create the command + cli := di.NewMockDeps(t) + cmd := StackPackCommand(&cli.Deps) + + // Check if scaffold command exists + scaffoldCmd, _, err := cmd.Find([]string{"scaffold"}) + + if tt.expectScaffoldCommand { + assert.NoError(t, err, tt.description) + assert.NotNil(t, scaffoldCmd, tt.description) + assert.Equal(t, "scaffold", scaffoldCmd.Use, tt.description) + } else { + assert.Error(t, err, tt.description) + assert.Contains(t, err.Error(), "unknown command", tt.description) + } + }) + } +} + +func TestStackPackCommand_AlwaysPresentCommands(t *testing.T) { + // Ensure that other commands are always present regardless of environment variable + cli := di.NewMockDeps(t) + cmd := StackPackCommand(&cli.Deps) + + expectedCommands := []string{ + "upload", + "list", + "list-instances", + "install", + "list-parameters", + "uninstall", + "upgrade", + "confirm-manual-steps", + "describe", + } + + for _, cmdName := range expectedCommands { + t.Run("command_"+cmdName+"_always_present", func(t *testing.T) { + foundCmd, _, err := cmd.Find([]string{cmdName}) + assert.NoError(t, err, "Command %s should always be present", cmdName) + assert.NotNil(t, foundCmd, "Command %s should always be present", cmdName) + }) + } +} diff --git a/internal/common/common_cli_errors.go b/internal/common/common_cli_errors.go index b5d93359..b09f47ba 100644 --- a/internal/common/common_cli_errors.go +++ b/internal/common/common_cli_errors.go @@ -129,3 +129,12 @@ func NewAPIClientCreateError(message string) CLIError { exitCode: APIClientCreateErrorCode, } } + +func NewRuntimeError(err error) CLIError { + return StdCLIError{ + Err: err, + ServerResponse: nil, + showUsage: false, + exitCode: ExecutionErrorCode, + } +} diff --git a/pkg/scaffold/github_source.go b/pkg/scaffold/github_source.go new file mode 100644 index 00000000..e9794194 --- /dev/null +++ b/pkg/scaffold/github_source.go @@ -0,0 +1,246 @@ +package scaffold + +import ( + "archive/zip" + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +// GitHubSource implements TemplateSource for GitHub repositories +type GitHubSource struct { + Owner string // GitHub repository owner + Repo string // GitHub repository name + Ref string // Git ref (branch, tag, or commit SHA) - defaults to "main" + Path string // Path within the repository to the template directory + TemplateName string // Name of the template + tempDir string // Temporary directory for cleanup +} + +// NewGitHubSource creates a new GitHubSource +func NewGitHubSource(owner, repo, ref, path, templateName string) *GitHubSource { + if ref == "" { + ref = "main" // Default to main branch + } + + return &GitHubSource{ + Owner: owner, + Repo: repo, + Ref: ref, + Path: path, + TemplateName: templateName, + } +} + +// Validate checks if the source is valid +func (g *GitHubSource) Validate() error { + if g.Owner == "" { + return fmt.Errorf("GitHub repository owner is required") + } + + if g.Repo == "" { + return fmt.Errorf("GitHub repository name is required") + } + + if g.TemplateName == "" { + return fmt.Errorf("template name is required") + } + + return nil +} + +// Fetch retrieves templates from GitHub to a temporary directory +func (g *GitHubSource) Fetch(ctx context.Context) (string, error) { + // Create temporary directory + tempDir, err := os.MkdirTemp("", "stackpack-template-*") + if err != nil { + return "", fmt.Errorf("failed to create temporary directory: %w", err) + } + g.tempDir = tempDir + + // Download repository archive + archiveURL := fmt.Sprintf("https://github.com/%s/%s/archive/%s.zip", g.Owner, g.Repo, g.Ref) + + req, err := http.NewRequestWithContext(ctx, "GET", archiveURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to download repository archive from %s: %w", archiveURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to download repository archive: HTTP %d from %s", resp.StatusCode, archiveURL) + } + + // Create temporary zip file + zipFile := filepath.Join(tempDir, "archive.zip") + out, err := os.Create(zipFile) + if err != nil { + return "", fmt.Errorf("failed to create zip file: %w", err) + } + defer out.Close() + + // Download the zip file + _, err = io.Copy(out, resp.Body) + if err != nil { + return "", fmt.Errorf("failed to download zip file: %w", err) + } + + // Extract the zip file + extractDir := filepath.Join(tempDir, "extracted") + if err := g.extractZip(zipFile, extractDir); err != nil { + return "", fmt.Errorf("failed to extract zip file: %w", err) + } + + // Find the template directory within the extracted repository + templateDir, err := g.findTemplateDirectory(extractDir) + if err != nil { + return "", fmt.Errorf("failed to find template directory: %w", err) + } + + return templateDir, nil +} + +// Cleanup removes temporary files +func (g *GitHubSource) Cleanup() error { + if g.tempDir != "" { + return os.RemoveAll(g.tempDir) + } + return nil +} + +// extractZip extracts a zip file to the specified directory +func (g *GitHubSource) extractZip(src, dest string) error { + reader, err := zip.OpenReader(src) + if err != nil { + return err + } + defer reader.Close() + + // Create destination directory + if err := os.MkdirAll(dest, os.FileMode(defaultDirMode)); err != nil { + return err + } + + // Extract files + for _, file := range reader.File { + path := filepath.Join(dest, file.Name) + + // Security check: ensure the path is within the destination directory + if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) { + return fmt.Errorf("invalid file path in zip: %s", file.Name) + } + + if file.FileInfo().IsDir() { + if err := os.MkdirAll(path, file.FileInfo().Mode()); err != nil { + return err + } + continue + } + + // Create parent directories + if err := os.MkdirAll(filepath.Dir(path), os.FileMode(defaultDirMode)); err != nil { + return err + } + + // Extract file + if err := g.extractFile(file, path); err != nil { + return err + } + } + + return nil +} + +// extractFile extracts a single file from the zip +func (g *GitHubSource) extractFile(file *zip.File, destPath string) error { + rc, err := file.Open() + if err != nil { + return err + } + defer rc.Close() + + outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.FileInfo().Mode()) + if err != nil { + return err + } + defer outFile.Close() + + _, err = io.Copy(outFile, rc) + return err +} + +// findTemplateDirectory finds the template directory within the extracted repository +func (g *GitHubSource) findTemplateDirectory(extractedDir string) (string, error) { + // GitHub archives are extracted with a top-level directory named "{repo}-{ref}" + // We need to find this directory first + entries, err := os.ReadDir(extractedDir) + if err != nil { + return "", err + } + + var repoDir string + expectedPrefix := fmt.Sprintf("%s-%s", g.Repo, g.Ref) + + for _, entry := range entries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), expectedPrefix) { + repoDir = filepath.Join(extractedDir, entry.Name()) + break + } + } + + if repoDir == "" { + return "", fmt.Errorf("could not find repository directory in extracted archive") + } + + // Build the template base directory path + var templateBaseDir string + if g.Path != "" { + templateBaseDir = filepath.Join(repoDir, g.Path) + } else { + templateBaseDir = repoDir + } + + // Verify the template base directory exists + if _, err := os.Stat(templateBaseDir); os.IsNotExist(err) { + if g.Path != "" { + return "", fmt.Errorf("template base directory not found at path '%s' in repository %s/%s", g.Path, g.Owner, g.Repo) + } + return "", fmt.Errorf("repository directory not found") + } + + // Look for a subdirectory with the template name + templateDir := filepath.Join(templateBaseDir, g.TemplateName) + if _, err := os.Stat(templateDir); os.IsNotExist(err) { + return "", fmt.Errorf("template '%s' not found in directory '%s' of repository %s/%s", g.TemplateName, templateBaseDir, g.Owner, g.Repo) + } + + // Check if it's a directory + info, err := os.Stat(templateDir) + if err != nil { + return "", fmt.Errorf("failed to check template directory: %w", err) + } + + if !info.IsDir() { + return "", fmt.Errorf("template '%s' is not a directory in repository %s/%s", g.TemplateName, g.Owner, g.Repo) + } + + return templateDir, nil +} + +// String returns a user-friendly description of the GitHub source +func (g *GitHubSource) String() string { + if g.Path != "" { + return fmt.Sprintf("github:%s/%s@%s:%s", g.Owner, g.Repo, g.Ref, g.Path) + } + return fmt.Sprintf("github:%s/%s@%s", g.Owner, g.Repo, g.Ref) +} diff --git a/pkg/scaffold/github_source_test.go b/pkg/scaffold/github_source_test.go new file mode 100644 index 00000000..54ad0f5a --- /dev/null +++ b/pkg/scaffold/github_source_test.go @@ -0,0 +1,436 @@ +package scaffold + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGitHubSource_NewGitHubSource(t *testing.T) { + tests := []struct { + name string + owner string + repo string + ref string + path string + templateName string + expectedRef string + }{ + { + name: "all parameters provided", + owner: "testowner", + repo: "testrepo", + ref: "v1.0.0", + path: "templates", + templateName: "generic", + expectedRef: "v1.0.0", + }, + { + name: "empty ref defaults to main", + owner: "testowner", + repo: "testrepo", + ref: "", + path: "templates", + templateName: "generic", + expectedRef: "main", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + source := NewGitHubSource(tt.owner, tt.repo, tt.ref, tt.path, tt.templateName) + + if source.Owner != tt.owner { + t.Errorf("Expected Owner to be '%s', got '%s'", tt.owner, source.Owner) + } + if source.Repo != tt.repo { + t.Errorf("Expected Repo to be '%s', got '%s'", tt.repo, source.Repo) + } + if source.Ref != tt.expectedRef { + t.Errorf("Expected Ref to be '%s', got '%s'", tt.expectedRef, source.Ref) + } + if source.Path != tt.path { + t.Errorf("Expected Path to be '%s', got '%s'", tt.path, source.Path) + } + if source.TemplateName != tt.templateName { + t.Errorf("Expected TemplateName to be '%s', got '%s'", tt.templateName, source.TemplateName) + } + }) + } +} + +func TestGitHubSource_String(t *testing.T) { + tests := []struct { + name string + source *GitHubSource + expectedStr string + }{ + { + name: "with path", + source: NewGitHubSource("owner", "repo", "main", "templates", "generic"), + expectedStr: "github:owner/repo@main:templates", + }, + { + name: "without path", + source: NewGitHubSource("owner", "repo", "v1.0", "", "generic"), + expectedStr: "github:owner/repo@v1.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.source.String() + if result != tt.expectedStr { + t.Errorf("Expected String() to return '%s', got '%s'", tt.expectedStr, result) + } + }) + } +} + +func TestGitHubSource_Validate(t *testing.T) { + tests := []struct { + name string + owner string + repo string + templateName string + wantErr bool + expectedErr string + }{ + { + name: "valid source", + owner: "testowner", + repo: "testrepo", + templateName: "generic", + wantErr: false, + }, + { + name: "empty owner", + owner: "", + repo: "testrepo", + templateName: "generic", + wantErr: true, + expectedErr: "GitHub repository owner is required", + }, + { + name: "empty repo", + owner: "testowner", + repo: "", + templateName: "generic", + wantErr: true, + expectedErr: "GitHub repository name is required", + }, + { + name: "empty template name", + owner: "testowner", + repo: "testrepo", + templateName: "", + wantErr: true, + expectedErr: "template name is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + source := NewGitHubSource(tt.owner, tt.repo, "main", "templates", tt.templateName) + err := source.Validate() + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error but got none") + return + } + if err.Error() != tt.expectedErr { + t.Errorf("Expected error '%s', got '%s'", tt.expectedErr, err.Error()) + } + } else if err != nil { + t.Errorf("Expected no error but got: %v", err) + } + }) + } +} + +func TestGitHubSource_Cleanup(t *testing.T) { + // Test cleanup when no temp directory is set + source := NewGitHubSource("owner", "repo", "main", "", "generic") + err := source.Cleanup() + if err != nil { + t.Errorf("Expected no error when tempDir is empty, got: %v", err) + } + + // Test cleanup when temp directory is set + tempDir, err := os.MkdirTemp("", "test-cleanup-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + // Create a test file in temp directory + testFile := filepath.Join(tempDir, "test.txt") + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + source.tempDir = tempDir + err = source.Cleanup() + if err != nil { + t.Errorf("Expected no error during cleanup, got: %v", err) + } + + // Verify temp directory was removed + if _, err := os.Stat(tempDir); !os.IsNotExist(err) { + t.Errorf("Expected temp directory to be removed, but it still exists") + } +} + +func TestGitHubSource_extractZip(t *testing.T) { + // Create a test ZIP file + zipData := createTestZipFile(t, map[string]string{ + "test-repo-main/file1.txt": "content1", + "test-repo-main/dir/file2.txt": "content2", + }) + + // Write ZIP to temporary file + tempDir, err := os.MkdirTemp("", "test-extract-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + zipFile := filepath.Join(tempDir, "test.zip") + if err := os.WriteFile(zipFile, zipData, 0644); err != nil { + t.Fatalf("Failed to write ZIP file: %v", err) + } + + extractDir := filepath.Join(tempDir, "extracted") + source := NewGitHubSource("owner", "repo", "main", "", "generic") + + err = source.extractZip(zipFile, extractDir) + if err != nil { + t.Fatalf("Expected no error during extraction, got: %v", err) + } + + // Verify extracted files + expectedFiles := []string{ + "test-repo-main/file1.txt", + "test-repo-main/dir/file2.txt", + } + + for _, expectedFile := range expectedFiles { + fullPath := filepath.Join(extractDir, expectedFile) + if _, err := os.Stat(fullPath); os.IsNotExist(err) { + t.Errorf("Expected file %s to exist after extraction", expectedFile) + } + } +} + +//nolint:funlen +func TestGitHubSource_findTemplateDirectory(t *testing.T) { + tests := []struct { + name string + repo string + ref string + path string + templateName string + setupFiles map[string]string + wantErr bool + expectedErr string + }{ + { + name: "find template with path", + repo: "test-repo", + ref: "main", + path: "templates", + templateName: "generic", + setupFiles: map[string]string{ + "test-repo-main/templates/generic/file.txt": "content", + }, + wantErr: false, + }, + { + name: "find template without path", + repo: "test-repo", + ref: "main", + path: "", + templateName: "generic", + setupFiles: map[string]string{ + "test-repo-main/generic/file.txt": "content", + }, + wantErr: false, + }, + { + name: "repo directory not found", + repo: "test-repo", + ref: "main", + path: "", + templateName: "generic", + setupFiles: map[string]string{ + "wrong-repo-main/generic/file.txt": "content", + }, + wantErr: true, + expectedErr: "could not find repository directory in extracted archive", + }, + { + name: "template base directory not found", + repo: "test-repo", + ref: "main", + path: "nonexistent", + templateName: "generic", + setupFiles: map[string]string{ + "test-repo-main/templates/generic/file.txt": "content", + }, + wantErr: true, + expectedErr: "template base directory not found at path 'nonexistent'", + }, + { + name: "template not found", + repo: "test-repo", + ref: "main", + path: "templates", + templateName: "nonexistent", + setupFiles: map[string]string{ + "test-repo-main/templates/generic/file.txt": "content", + }, + wantErr: true, + expectedErr: "template 'nonexistent' not found in directory", + }, + { + name: "template is file not directory", + repo: "test-repo", + ref: "main", + path: "templates", + templateName: "generic", + setupFiles: map[string]string{ + "test-repo-main/templates/generic": "file content", + }, + wantErr: true, + expectedErr: "template 'generic' is not a directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "test-find-template-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create test file structure + for filePath, content := range tt.setupFiles { + fullPath := filepath.Join(tempDir, filePath) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + t.Fatalf("Failed to create directory structure: %v", err) + } + if content == "" && strings.HasSuffix(filePath, "/") { + // Directory + if err := os.MkdirAll(fullPath, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + } else { + // File + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create file: %v", err) + } + } + } + + source := NewGitHubSource("owner", tt.repo, tt.ref, tt.path, tt.templateName) + result, err := source.findTemplateDirectory(tempDir) + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error but got none") + return + } + if tt.expectedErr != "" && !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("Expected error to contain '%s', got '%s'", tt.expectedErr, err.Error()) + } + } else { + if err != nil { + t.Errorf("Expected no error but got: %v", err) + return + } + + // Verify the result is the correct path + var expectedPath string + if tt.path != "" { + expectedPath = filepath.Join(tempDir, fmt.Sprintf("%s-%s", tt.repo, tt.ref), tt.path, tt.templateName) + } else { + expectedPath = filepath.Join(tempDir, fmt.Sprintf("%s-%s", tt.repo, tt.ref), tt.templateName) + } + + if result != expectedPath { + t.Errorf("Expected result to be '%s', got '%s'", expectedPath, result) + } + } + }) + } +} + +func TestGitHubSource_Fetch_Integration(t *testing.T) { + // Create a mock HTTP server that serves a ZIP file + zipData := createTestZipFile(t, map[string]string{ + "test-repo-main/": "", + "test-repo-main/templates/": "", + "test-repo-main/templates/generic/": "", + "test-repo-main/templates/generic/file.txt": "template content", + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedURL := "/testowner/testrepo/archive/main.zip" + if r.URL.Path != expectedURL { + t.Errorf("Expected request to '%s', got '%s'", expectedURL, r.URL.Path) + http.Error(w, "Not found", 404) + return + } + + w.Header().Set("Content-Type", "application/zip") + w.WriteHeader(200) + _, _ = w.Write(zipData) + })) + defer server.Close() + + // We need to modify the Fetch method to use our test server, but since we can't easily do that, + // let's just test the zip extraction and directory finding parts separately. + // For a real integration test, we'd need dependency injection for the HTTP client. + + t.Skip("Integration test requires HTTP client dependency injection") +} + +// Helper function to create a ZIP file with given files +func createTestZipFile(t *testing.T, files map[string]string) []byte { + var buf bytes.Buffer + zipWriter := zip.NewWriter(&buf) + + for filePath, content := range files { + if strings.HasSuffix(filePath, "/") { + // Directory entry + _, err := zipWriter.Create(filePath) + if err != nil { + t.Fatalf("Failed to create directory entry in ZIP: %v", err) + } + } else { + // File entry + fileWriter, err := zipWriter.Create(filePath) + if err != nil { + t.Fatalf("Failed to create file entry in ZIP: %v", err) + } + _, err = io.WriteString(fileWriter, content) + if err != nil { + t.Fatalf("Failed to write file content to ZIP: %v", err) + } + } + } + + if err := zipWriter.Close(); err != nil { + t.Fatalf("Failed to close ZIP writer: %v", err) + } + + return buf.Bytes() +} diff --git a/pkg/scaffold/scaffolder.go b/pkg/scaffold/scaffolder.go new file mode 100644 index 00000000..1e0f3b66 --- /dev/null +++ b/pkg/scaffold/scaffolder.go @@ -0,0 +1,426 @@ +package scaffold + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "text/template" +) + +const ( + defaultDirMode = 0755 // Default directory permissions +) + +// TemplateContext holds the variables for template rendering +type TemplateContext struct { + Name string // Stackpack name (e.g. "my-stackpack") + DisplayName string // Display name for the stackpack + TemplateName string // Template name used for scaffolding +} + +// Scaffolder handles the scaffolding process +type Scaffolder struct { + source TemplateSource + destination string + context TemplateContext + force bool + printer Printer + jsonOutput bool +} + +// Printer interface for output (to avoid importing internal packages) +type Printer interface { + PrintLn(message string) + PrintWarn(message string) +} + +// NewScaffolder creates a new Scaffolder instance +func NewScaffolder(source TemplateSource, destination string, context TemplateContext, force bool, printer Printer, jsonOutput bool) *Scaffolder { + return &Scaffolder{ + source: source, + destination: destination, + context: context, + force: force, + printer: printer, + jsonOutput: jsonOutput, + } +} + +// Scaffold executes the complete scaffolding workflow +func (s *Scaffolder) Scaffold(ctx context.Context) (*ScaffoldResult, func() error, error) { + result := &ScaffoldResult{ + Success: false, + Source: s.source.String(), + Destination: s.destination, + Name: s.context.Name, + Template: s.context.TemplateName, + } + + cleanUpFn := s.source.Cleanup + + // Validate arguments + s.printProgress("✓ Validating arguments...") + if err := s.validateArgs(); err != nil { + return result, cleanUpFn, fmt.Errorf("validation failed: %w", err) + } + + // Check destination directory + s.printProgress("✓ Checking destination directory...") + if err := s.checkDestinationDirectory(); err != nil { + return result, cleanUpFn, fmt.Errorf("destination directory check failed: %w", err) + } + result.Destination = s.destination // Update with resolved destination + + // Fetch template to temp directory + s.printProgress("✓ Fetching template from " + s.source.String()) + tempDir, err := s.source.Fetch(ctx) + if err != nil { + return result, cleanUpFn, fmt.Errorf("failed to fetch template: %w", err) + } + + // Render templates + s.printProgress("✓ Rendering templates...") + renderedDir, err := s.renderTemplates(tempDir) + if err != nil { + return result, cleanUpFn, fmt.Errorf("failed to render templates: %w", err) + } + + // Validate rendered templates + s.printProgress("✓ Validating rendered templates...") + if err := s.validateRenderedTemplates(renderedDir); err != nil { + return result, cleanUpFn, fmt.Errorf("rendered template validation failed: %w", err) + } + + // Copy to destination + s.printProgress("✓ Copying files to destination...") + copyResult, err := s.copyToDestinationWithResult(renderedDir) + if err != nil { + return result, cleanUpFn, fmt.Errorf("failed to copy to destination: %w", err) + } + + // Update result with copied files info + result.Success = true + result.FilesCount = len(copyResult.CopiedFiles) + result.Files = copyResult.CopiedFiles + + return result, cleanUpFn, nil +} + +// validateArgs validates the scaffolding arguments +func (s *Scaffolder) validateArgs() error { + if err := s.source.Validate(); err != nil { + return err + } + + if s.context.Name == "" { + return fmt.Errorf("stackpack name is required") + } + + if s.context.TemplateName == "" { + return fmt.Errorf("template name is required") + } + + return nil +} + +// checkDestinationDirectory validates and prepares the destination directory +func (s *Scaffolder) checkDestinationDirectory() error { + if s.destination == "" { + s.destination = "." + } + + // Create destination directory if it doesn't exist + if err := os.MkdirAll(s.destination, os.FileMode(defaultDirMode)); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + + // Check if destination is empty (in the future, we might want to enforce this) + // For now, we'll allow non-empty destinations + + return nil +} + +// renderTemplates processes templates with context variables using Go templates with << >> delimiters +func (s *Scaffolder) renderTemplates(sourceDir string) (string, error) { + // Create a temporary directory for rendered templates + renderedDir, err := os.MkdirTemp("", "stackpack-rendered-*") + if err != nil { + return "", fmt.Errorf("failed to create rendered templates directory: %w", err) + } + + // Process all files in the source directory + err = filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Calculate relative path and destination path + relPath, err := filepath.Rel(sourceDir, path) + if err != nil { + return err + } + destPath := filepath.Join(renderedDir, relPath) + + // Handle directories + if info.IsDir() { + return os.MkdirAll(destPath, info.Mode()) + } + + // Process files - render templates or copy as-is + return s.renderFile(path, destPath, info.Mode()) + }) + + if err != nil { + os.RemoveAll(renderedDir) // Cleanup on error + return "", err + } + + return renderedDir, nil +} + +// renderFile processes a single file through Go template engine +func (s *Scaffolder) renderFile(srcPath, destPath string, mode os.FileMode) error { + // Read source file content + content, err := os.ReadFile(srcPath) + if err != nil { + return fmt.Errorf("failed to read template file %s: %w", srcPath, err) + } + + // Skip binary files (simple heuristic: contains null bytes) + if bytes.Contains(content, []byte{0}) { + // Copy binary files as-is + return s.copyBinaryFile(srcPath, destPath, mode) + } + + // Process text files through Go template engine + renderedContent, err := s.processTemplate(string(content), srcPath) + if err != nil { + return fmt.Errorf("failed to process template %s: %w", srcPath, err) + } + + // Write rendered content to destination + if err := os.WriteFile(destPath, []byte(renderedContent), mode); err != nil { + return fmt.Errorf("failed to write rendered file %s: %w", destPath, err) + } + + return nil +} + +// processTemplate processes template content with Go templates using << >> delimiters +func (s *Scaffolder) processTemplate(content, filename string) (string, error) { + // Check if content contains our template delimiters + if !strings.Contains(content, "<<") || !strings.Contains(content, ">>") { + // No template variables, return as-is + return content, nil + } + + // Create Go template with custom delimiters + tmpl, err := template.New(filepath.Base(filename)).Delims("<<", ">>").Parse(content) + if err != nil { + return "", fmt.Errorf("template parsing error: %w", err) + } + + // Execute template with context + var buf bytes.Buffer + if err := tmpl.Execute(&buf, s.context); err != nil { + return "", fmt.Errorf("template execution error: %w", err) + } + + return buf.String(), nil +} + +// copyBinaryFile copies binary files without template processing +func (s *Scaffolder) copyBinaryFile(srcPath, destPath string, mode os.FileMode) error { + src, err := os.Open(srcPath) + if err != nil { + return err + } + defer src.Close() + + dest, err := os.Create(destPath) + if err != nil { + return err + } + defer dest.Close() + + _, err = io.Copy(dest, src) + if err != nil { + return err + } + + return os.Chmod(destPath, mode) +} + +// validateRenderedTemplates checks if the rendered templates are valid +// This is a stub implementation +func (s *Scaffolder) validateRenderedTemplates(renderedDir string) error { + // Check if the directory exists + if _, err := os.Stat(renderedDir); os.IsNotExist(err) { + return fmt.Errorf("rendered template directory does not exist: %s", renderedDir) + } + + // In the future, this could validate: + // - Template syntax + + return nil +} + +// CopyResult holds information about copied files +type CopyResult struct { + CopiedFiles []string +} + +// ScaffoldResult holds the result of scaffolding operation for JSON output +type ScaffoldResult struct { + Success bool `json:"success"` + Source string `json:"source"` + Destination string `json:"destination"` + Name string `json:"name"` + Template string `json:"template"` + FilesCount int `json:"files_count"` + Files []string `json:"files,omitempty"` +} + +// checkForConflicts scans for existing files that would be overwritten +func (s *Scaffolder) checkForConflicts(src, dst string) ([]string, error) { + var conflicts []string + + err := filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories + if info.IsDir() { + return nil + } + + // Calculate the destination path + relPath, err := filepath.Rel(src, path) + if err != nil { + return err + } + destPath := filepath.Join(dst, relPath) + + // Check if destination file already exists + if _, err := os.Stat(destPath); err == nil { + conflicts = append(conflicts, relPath) + } + + return nil + }) + + return conflicts, err +} + +// copyDir recursively copies a directory (assumes no conflicts when called) +func (s *Scaffolder) copyDir(src, dst string) (*CopyResult, error) { + result := &CopyResult{ + CopiedFiles: []string{}, + } + + err := filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Calculate the destination path + relPath, err := filepath.Rel(src, path) + if err != nil { + return err + } + destPath := filepath.Join(dst, relPath) + + if info.IsDir() { + return os.MkdirAll(destPath, info.Mode()) + } + + // Copy the file (conflicts already handled in pre-flight check) + if err := s.copyFile(path, destPath); err != nil { + return err + } + + result.CopiedFiles = append(result.CopiedFiles, relPath) + return nil + }) + + return result, err +} + +// copyFile copies a single file +func (s *Scaffolder) copyFile(src, dst string) error { + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + destFile, err := os.Create(dst) + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, sourceFile) + if err != nil { + return err + } + + // Copy file permissions + sourceInfo, err := sourceFile.Stat() + if err != nil { + return err + } + + return os.Chmod(dst, sourceInfo.Mode()) +} + +// printProgress conditionally prints progress messages (suppressed in JSON mode) +func (s *Scaffolder) printProgress(message string) { + if !s.jsonOutput && s.printer != nil { + s.printer.PrintLn(message) + } +} + +// copyToDestinationWithResult copies files and returns detailed results +func (s *Scaffolder) copyToDestinationWithResult(renderedDir string) (*CopyResult, error) { + // Pre-flight check: scan for conflicts before making any changes + if !s.force { + conflicts, err := s.checkForConflicts(renderedDir, s.destination) + if err != nil { + return nil, fmt.Errorf("failed to check for file conflicts: %w", err) + } + + if len(conflicts) > 0 { + if !s.jsonOutput && s.printer != nil { + s.printer.PrintWarn("The following files already exist and would be overwritten:") + for _, file := range conflicts { + s.printer.PrintLn(" " + filepath.Join(s.destination, file)) + } + s.printer.PrintLn("") + s.printer.PrintLn("Use --force flag to overwrite existing files, or remove/rename the conflicting files.") + } + return nil, fmt.Errorf("conflicting files exist, use --force to overwrite or resolve conflicts manually") + } + } + + // No conflicts (or force flag used), proceed with copying + result, err := s.copyDir(renderedDir, s.destination) + if err != nil { + return nil, err + } + + // Print the list of copied files (only in non-JSON mode) + if !s.jsonOutput && s.printer != nil && len(result.CopiedFiles) > 0 { + s.printer.PrintLn("Files copied:") + for _, file := range result.CopiedFiles { + s.printer.PrintLn(" " + file) + } + } + + return result, nil +} diff --git a/pkg/scaffold/scaffolder_test.go b/pkg/scaffold/scaffolder_test.go new file mode 100644 index 00000000..76b693ac --- /dev/null +++ b/pkg/scaffold/scaffolder_test.go @@ -0,0 +1,608 @@ +package scaffold + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// MockTemplateSource for testing +type MockTemplateSource struct { + fetchResult string + fetchError error + validateError error + cleanupError error + stringResult string + cleanupCalled bool + fetchCalled bool +} + +func (m *MockTemplateSource) Fetch(ctx context.Context) (string, error) { + m.fetchCalled = true + return m.fetchResult, m.fetchError +} + +func (m *MockTemplateSource) Validate() error { + return m.validateError +} + +func (m *MockTemplateSource) Cleanup() error { + m.cleanupCalled = true + return m.cleanupError +} + +func (m *MockTemplateSource) String() string { + return m.stringResult +} + +// MockPrinter for testing +type MockPrinter struct { + printedLines []string + printedWarnings []string +} + +func (m *MockPrinter) PrintLn(message string) { + m.printedLines = append(m.printedLines, message) +} + +func (m *MockPrinter) PrintWarn(message string) { + m.printedWarnings = append(m.printedWarnings, message) +} + +func TestNewScaffolder(t *testing.T) { + mockSource := &MockTemplateSource{stringResult: "mock-source"} + context := TemplateContext{Name: "test-name", DisplayName: "test-name", TemplateName: "test-template"} + mockPrinter := &MockPrinter{} + + scaffolder := NewScaffolder(mockSource, "/test/dest", context, true, mockPrinter, false) + + if scaffolder.source != mockSource { + t.Errorf("Expected source to be mockSource") + } + if scaffolder.destination != "/test/dest" { + t.Errorf("Expected destination to be '/test/dest', got '%s'", scaffolder.destination) + } + if scaffolder.context.Name != "test-name" { + t.Errorf("Expected context.Name to be 'test-name', got '%s'", scaffolder.context.Name) + } + if scaffolder.force != true { + t.Errorf("Expected force to be true, got %v", scaffolder.force) + } + if scaffolder.printer != mockPrinter { + t.Errorf("Expected printer to be mockPrinter") + } +} + +func TestScaffolder_validateArgs(t *testing.T) { + tests := []struct { + name string + context TemplateContext + sourceErr error + wantErr bool + expectedErr string + }{ + { + name: "valid args", + context: TemplateContext{Name: "test-name", DisplayName: "test-name", TemplateName: "test-template"}, + sourceErr: nil, + wantErr: false, + }, + { + name: "source validation error", + context: TemplateContext{Name: "test-name", TemplateName: "test-template"}, + sourceErr: os.ErrInvalid, + wantErr: true, + expectedErr: "invalid argument", + }, + { + name: "empty name", + context: TemplateContext{Name: "", TemplateName: "test-template"}, + sourceErr: nil, + wantErr: true, + expectedErr: "stackpack name is required", + }, + { + name: "empty template name", + context: TemplateContext{Name: "test-name", TemplateName: ""}, + sourceErr: nil, + wantErr: true, + expectedErr: "template name is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockSource := &MockTemplateSource{ + validateError: tt.sourceErr, + stringResult: "mock-source", + } + mockPrinter := &MockPrinter{} + + scaffolder := NewScaffolder(mockSource, "/test/dest", tt.context, false, mockPrinter, false) + err := scaffolder.validateArgs() + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error but got none") + return + } + if tt.expectedErr != "" && !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("Expected error to contain '%s', got '%s'", tt.expectedErr, err.Error()) + } + } else if err != nil { + t.Errorf("Expected no error but got: %v", err) + } + }) + } +} + +func TestScaffolder_checkDestinationDirectory(t *testing.T) { + tests := []struct { + name string + destination string + setupFunc func() (string, func()) + wantErr bool + }{ + { + name: "valid destination directory", + destination: "", + setupFunc: func() (string, func()) { + tempDir, _ := os.MkdirTemp("", "test-dest-*") + return tempDir, func() { os.RemoveAll(tempDir) } + }, + wantErr: false, + }, + { + name: "empty destination defaults to current dir", + destination: "", + setupFunc: func() (string, func()) { + return "", func() {} + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + destination, cleanup := tt.setupFunc() + defer cleanup() + + if destination == "" && tt.name != "empty destination defaults to current dir" { + tt.destination = destination + } + + mockSource := &MockTemplateSource{stringResult: "mock-source"} + mockPrinter := &MockPrinter{} + context := TemplateContext{Name: "test-name", TemplateName: "test-template"} + + scaffolder := NewScaffolder(mockSource, tt.destination, context, false, mockPrinter, false) + err := scaffolder.checkDestinationDirectory() + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Expected no error but got: %v", err) + } + + // Check that destination is set correctly + expectedDest := tt.destination + if expectedDest == "" { + expectedDest = "." + } + if scaffolder.destination != expectedDest { + t.Errorf("Expected destination to be '%s', got '%s'", expectedDest, scaffolder.destination) + } + } + }) + } +} + +func TestScaffolder_processTemplate(t *testing.T) { + tests := []struct { + name string + content string + context TemplateContext + filename string + expectedResult string + wantErr bool + expectedErr string + }{ + { + name: "no template variables", + content: "plain text content", + context: TemplateContext{Name: "test-name", TemplateName: "test-template"}, + filename: "test.txt", + expectedResult: "plain text content", + wantErr: false, + }, + { + name: "template with Name variable", + content: "name: <<.Name>>", + context: TemplateContext{Name: "my-stackpack", TemplateName: "generic"}, + filename: "test.conf", + expectedResult: "name: my-stackpack", + wantErr: false, + }, + { + name: "template with TemplateName variable", + content: "template: <<.TemplateName>>", + context: TemplateContext{Name: "my-stackpack", TemplateName: "webapp"}, + filename: "test.conf", + expectedResult: "template: webapp", + wantErr: false, + }, + { + name: "template with multiple variables", + content: "name=<<.Name>>\ntemplate=<<.TemplateName>>", + context: TemplateContext{Name: "awesome-pack", TemplateName: "api"}, + filename: "config.txt", + expectedResult: "name=awesome-pack\ntemplate=api", + wantErr: false, + }, + { + name: "invalid template syntax", + content: "name: <<.InvalidField>>", + context: TemplateContext{Name: "test-name", TemplateName: "test-template"}, + filename: "test.conf", + wantErr: true, + expectedErr: "template execution error", + }, + { + name: "malformed template delimiters", + content: "name: <<.Name", + context: TemplateContext{Name: "test-name", TemplateName: "test-template"}, + filename: "test.conf", + wantErr: false, // Go templates are more permissive, this actually works as literal text + expectedResult: "name: <<.Name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockSource := &MockTemplateSource{stringResult: "mock-source"} + mockPrinter := &MockPrinter{} + + scaffolder := NewScaffolder(mockSource, "/test/dest", tt.context, false, mockPrinter, false) + result, err := scaffolder.processTemplate(tt.content, tt.filename) + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error but got none") + return + } + if tt.expectedErr != "" && !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("Expected error to contain '%s', got '%s'", tt.expectedErr, err.Error()) + } + } else { + if err != nil { + t.Errorf("Expected no error but got: %v", err) + return + } + if result != tt.expectedResult { + t.Errorf("Expected result to be '%s', got '%s'", tt.expectedResult, result) + } + } + }) + } +} + +//nolint:funlen +func TestScaffolder_renderTemplates(t *testing.T) { + tests := []struct { + name string + setupFiles map[string]string + context TemplateContext + wantErr bool + expectedErr string + }{ + { + name: "render text files with templates", + setupFiles: map[string]string{ + "config.conf": "name=<<.Name>>", + "readme.md": "# <<.Name>> StackPack\nTemplate: <<.TemplateName>>", + "data.json": `{"name": "<<.Name>>", "template": "<<.TemplateName>>"}`, + }, + context: TemplateContext{Name: "my-pack", TemplateName: "webapp"}, + wantErr: false, + }, + { + name: "copy files without template variables", + setupFiles: map[string]string{ + "plain.txt": "no template variables here", + "script.sh": "#!/bin/bash\necho 'hello world'", + }, + context: TemplateContext{Name: "my-pack", TemplateName: "webapp"}, + wantErr: false, + }, + { + name: "mixed template and non-template files", + setupFiles: map[string]string{ + "config.conf": "name=<<.Name>>", + "plain.txt": "static content", + "subdir/nested.conf": "template=<<.TemplateName>>", + }, + context: TemplateContext{Name: "test-pack", TemplateName: "generic"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary source directory + sourceDir, err := os.MkdirTemp("", "test-render-source-*") + if err != nil { + t.Fatalf("Failed to create temp source dir: %v", err) + } + defer os.RemoveAll(sourceDir) + + // Create test files + for filePath, content := range tt.setupFiles { + fullPath := filepath.Join(sourceDir, filePath) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + t.Fatalf("Failed to create directory structure: %v", err) + } + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + } + + mockSource := &MockTemplateSource{stringResult: "mock-source"} + mockPrinter := &MockPrinter{} + + scaffolder := NewScaffolder(mockSource, "/test/dest", tt.context, false, mockPrinter, false) + renderedDir, err := scaffolder.renderTemplates(sourceDir) + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error but got none") + return + } + if tt.expectedErr != "" && !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("Expected error to contain '%s', got '%s'", tt.expectedErr, err.Error()) + } + } else { + if err != nil { + t.Errorf("Expected no error but got: %v", err) + return + } + + // Clean up rendered directory + defer os.RemoveAll(renderedDir) + + // Verify rendered files exist and have correct content + for filePath, originalContent := range tt.setupFiles { + renderedPath := filepath.Join(renderedDir, filePath) + if _, err := os.Stat(renderedPath); os.IsNotExist(err) { + t.Errorf("Expected rendered file %s to exist", filePath) + continue + } + + renderedContent, err := os.ReadFile(renderedPath) + if err != nil { + t.Errorf("Failed to read rendered file %s: %v", filePath, err) + continue + } + + // Check if template processing occurred + contentStr := string(renderedContent) + if strings.Contains(originalContent, "<<") && strings.Contains(originalContent, ">>") { + // Should be processed + if strings.Contains(contentStr, "<<") || strings.Contains(contentStr, ">>") { + t.Errorf("Template variables not processed in file %s: %s", filePath, contentStr) + } + // Check for either Name or TemplateName in content depending on which was used + if strings.Contains(originalContent, "<<.Name>>") && !strings.Contains(contentStr, tt.context.Name) { + t.Errorf("Expected rendered content to contain name '%s' in file %s", tt.context.Name, filePath) + } + if strings.Contains(originalContent, "<<.TemplateName>>") && !strings.Contains(contentStr, tt.context.TemplateName) { + t.Errorf("Expected rendered content to contain template name '%s' in file %s", tt.context.TemplateName, filePath) + } + } else if contentStr != originalContent { + t.Errorf("Non-template file %s was modified: expected '%s', got '%s'", + filePath, originalContent, contentStr) + } + } + } + }) + } +} + +func TestScaffolder_checkForConflicts(t *testing.T) { + tests := []struct { + name string + sourceFiles []string + destFiles []string + expectedCount int + wantErr bool + }{ + { + name: "no conflicts", + sourceFiles: []string{"file1.txt", "file2.txt"}, + destFiles: []string{"other.txt"}, + expectedCount: 0, + wantErr: false, + }, + { + name: "some conflicts", + sourceFiles: []string{"file1.txt", "file2.txt", "file3.txt"}, + destFiles: []string{"file1.txt", "file3.txt"}, + expectedCount: 2, + wantErr: false, + }, + { + name: "all conflicts", + sourceFiles: []string{"file1.txt", "file2.txt"}, + destFiles: []string{"file1.txt", "file2.txt"}, + expectedCount: 2, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary source directory + sourceDir, err := os.MkdirTemp("", "test-conflicts-source-*") + if err != nil { + t.Fatalf("Failed to create temp source dir: %v", err) + } + defer os.RemoveAll(sourceDir) + + // Create temporary destination directory + destDir, err := os.MkdirTemp("", "test-conflicts-dest-*") + if err != nil { + t.Fatalf("Failed to create temp dest dir: %v", err) + } + defer os.RemoveAll(destDir) + + // Create source files + for _, fileName := range tt.sourceFiles { + filePath := filepath.Join(sourceDir, fileName) + if err := os.WriteFile(filePath, []byte("source content"), 0644); err != nil { + t.Fatalf("Failed to create source file: %v", err) + } + } + + // Create destination files + for _, fileName := range tt.destFiles { + filePath := filepath.Join(destDir, fileName) + if err := os.WriteFile(filePath, []byte("dest content"), 0644); err != nil { + t.Fatalf("Failed to create dest file: %v", err) + } + } + + mockSource := &MockTemplateSource{stringResult: "mock-source"} + mockPrinter := &MockPrinter{} + context := TemplateContext{Name: "test-name", TemplateName: "test-template"} + + scaffolder := NewScaffolder(mockSource, destDir, context, false, mockPrinter, false) + conflicts, err := scaffolder.checkForConflicts(sourceDir, destDir) + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Expected no error but got: %v", err) + return + } + if len(conflicts) != tt.expectedCount { + t.Errorf("Expected %d conflicts, got %d: %v", tt.expectedCount, len(conflicts), conflicts) + } + } + }) + } +} + +func TestScaffolder_JSONOutput(t *testing.T) { + // Create test files map + testFiles := map[string]string{ + "config.conf": "name=<<.Name>>", + "readme.md": "# <<.Name>> StackPack", + "static.txt": "no template variables here", + } + + tests := []struct { + name string + jsonOutput bool + expectLogs bool + }{ + { + name: "normal output mode", + jsonOutput: false, + expectLogs: true, + }, + { + name: "JSON output mode", + jsonOutput: true, + expectLogs: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary source directory for each test iteration + sourceDir, err := os.MkdirTemp("", "test-json-source-*") + if err != nil { + t.Fatalf("Failed to create temp source dir: %v", err) + } + defer os.RemoveAll(sourceDir) + + // Create temporary destination directory for each test iteration + destDir, err := os.MkdirTemp("", "test-json-dest-*") + if err != nil { + t.Fatalf("Failed to create temp dest dir: %v", err) + } + defer os.RemoveAll(destDir) + + // Create test files in source directory + for filePath, content := range testFiles { + fullPath := filepath.Join(sourceDir, filePath) + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + } + + mockSource := &MockTemplateSource{ + fetchResult: sourceDir, + stringResult: "mock-source", + } + mockPrinter := &MockPrinter{} + templateCtx := TemplateContext{Name: "test-pack", TemplateName: "test-template"} + + scaffolder := NewScaffolder(mockSource, destDir, templateCtx, false, mockPrinter, tt.jsonOutput) + result, cleanup, err := scaffolder.Scaffold(context.TODO()) + + if err != nil { + t.Errorf("Expected no error but got: %v", err) + return + } + + // Check result structure + if result.Success != true { + t.Errorf("Expected success=true, got %v", result.Success) + } + if result.Name != "test-pack" { + t.Errorf("Expected name='test-pack', got %s", result.Name) + } + if result.Template != "test-template" { + t.Errorf("Expected template='test-template', got %s", result.Template) + } + if result.Source != "mock-source" { + t.Errorf("Expected source='mock-source', got %s", result.Source) + } + if result.FilesCount != len(testFiles) { + t.Errorf("Expected files_count=%d, got %d", len(testFiles), result.FilesCount) + } + + // Check progress output behavior + if tt.expectLogs { + if len(mockPrinter.printedLines) == 0 { + t.Errorf("Expected progress messages in normal mode, but got none") + } + } else { + if len(mockPrinter.printedLines) > 0 { + t.Errorf("Expected no progress messages in JSON mode, but got: %v", mockPrinter.printedLines) + } + } + + // Verify files were actually created + for fileName := range testFiles { + destPath := filepath.Join(destDir, fileName) + if _, err := os.Stat(destPath); os.IsNotExist(err) { + t.Errorf("Expected file %s to be copied to destination", fileName) + } + } + + err = cleanup() + assert.NoError(t, err) + }) + } +} diff --git a/pkg/scaffold/template_source.go b/pkg/scaffold/template_source.go new file mode 100644 index 00000000..ed8c982a --- /dev/null +++ b/pkg/scaffold/template_source.go @@ -0,0 +1,103 @@ +package scaffold + +import ( + "context" + "fmt" + "os" + "path/filepath" +) + +// TemplateSource interface for different template sources +type TemplateSource interface { + // Fetch retrieves templates to a temporary directory + Fetch(ctx context.Context) (string, error) + // Validate checks if the source is valid + Validate() error + // Cleanup removes temporary files + Cleanup() error + // String returns a user-friendly description of the template source + String() string +} + +// LocalDirSource implements TemplateSource for local directories +type LocalDirSource struct { + Path string + TemplateName string + tempDir string +} + +// NewLocalDirSource creates a new LocalDirSource +func NewLocalDirSource(path, templateName string) *LocalDirSource { + return &LocalDirSource{ + Path: path, + TemplateName: templateName, + } +} + +// Validate checks if the source is valid +func (l *LocalDirSource) Validate() error { + if l.Path == "" { + return fmt.Errorf("template path is required") + } + + if l.TemplateName == "" { + return fmt.Errorf("template name is required") + } + + // Check if the path exists + if _, err := os.Stat(l.Path); os.IsNotExist(err) { + return fmt.Errorf("template directory does not exist: %s", l.Path) + } + + // Check if it's a directory + info, err := os.Stat(l.Path) + if err != nil { + return fmt.Errorf("failed to check template directory: %w", err) + } + + if !info.IsDir() { + return fmt.Errorf("template path is not a directory: %s", l.Path) + } + + return nil +} + +// Fetch retrieves templates to a temporary directory +func (l *LocalDirSource) Fetch(ctx context.Context) (string, error) { + // Resolve the base template directory + absPath, err := filepath.Abs(l.Path) + if err != nil { + return "", fmt.Errorf("failed to resolve absolute path: %w", err) + } + + // Look for a subdirectory with the template name + templatePath := filepath.Join(absPath, l.TemplateName) + if _, err := os.Stat(templatePath); os.IsNotExist(err) { + return "", fmt.Errorf("template '%s' not found in directory '%s'", l.TemplateName, absPath) + } + + // Check if it's a directory + info, err := os.Stat(templatePath) + if err != nil { + return "", fmt.Errorf("failed to check template directory: %w", err) + } + + if !info.IsDir() { + return "", fmt.Errorf("template '%s' is not a directory in '%s'", l.TemplateName, absPath) + } + + l.tempDir = templatePath + return templatePath, nil +} + +// Cleanup removes temporary files +func (l *LocalDirSource) Cleanup() error { + // For local directories, we don't need to cleanup anything + // In the future implementations, this might delete temp directories + return nil +} + +// String returns a user-friendly description of the local directory source +func (l *LocalDirSource) String() string { + return fmt.Sprintf("localdir:%s", l.Path) +} diff --git a/pkg/scaffold/template_source_test.go b/pkg/scaffold/template_source_test.go new file mode 100644 index 00000000..d31d59cb --- /dev/null +++ b/pkg/scaffold/template_source_test.go @@ -0,0 +1,217 @@ +package scaffold + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLocalDirSource_NewLocalDirSource(t *testing.T) { + source := NewLocalDirSource("/test/path", "template-name") + + if source.Path != "/test/path" { + t.Errorf("Expected Path to be '/test/path', got '%s'", source.Path) + } + + if source.TemplateName != "template-name" { + t.Errorf("Expected TemplateName to be 'template-name', got '%s'", source.TemplateName) + } +} + +func TestLocalDirSource_String(t *testing.T) { + source := NewLocalDirSource("/test/path", "template-name") + expected := "localdir:/test/path" + + if source.String() != expected { + t.Errorf("Expected String() to return '%s', got '%s'", expected, source.String()) + } +} + +func TestLocalDirSource_Validate(t *testing.T) { + tests := []struct { + name string + path string + templateName string + setupFunc func(string) error + wantErr bool + expectedErr string + }{ + { + name: "valid directory with template", + path: "", + templateName: "test-template", + setupFunc: func(tempDir string) error { + return os.MkdirAll(filepath.Join(tempDir, "test-template"), 0755) + }, + wantErr: false, + }, + { + name: "empty path", + path: "", + templateName: "test-template", + setupFunc: func(tempDir string) error { return nil }, + wantErr: true, + expectedErr: "template path is required", + }, + { + name: "empty template name", + path: "/test/path", + templateName: "", + setupFunc: func(tempDir string) error { return nil }, + wantErr: true, + expectedErr: "template name is required", + }, + { + name: "non-existent directory", + path: "/non/existent/path", + templateName: "test-template", + setupFunc: func(tempDir string) error { return nil }, + wantErr: true, + expectedErr: "template directory does not exist: /non/existent/path", + }, + { + name: "path is a file not directory", + path: "", + templateName: "test-template", + setupFunc: func(tempDir string) error { + return os.WriteFile(tempDir+"/file.txt", []byte("test"), 0644) + }, + wantErr: true, + expectedErr: "template path is not a directory:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "test-local-source-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Setup test scenario + if err := tt.setupFunc(tempDir); err != nil { + t.Fatalf("Failed to setup test: %v", err) + } + + // Use temp dir as path if not specified + path := tt.path + if path == "" && tt.name != "empty path" { + path = tempDir + } + if tt.name == "path is a file not directory" { + path = tempDir + "/file.txt" + } + + source := NewLocalDirSource(path, tt.templateName) + err = source.Validate() + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error but got none") + return + } + if tt.expectedErr != "" && !containsString(err.Error(), tt.expectedErr) { + t.Errorf("Expected error to contain '%s', got '%s'", tt.expectedErr, err.Error()) + } + } else if err != nil { + t.Errorf("Expected no error but got: %v", err) + } + }) + } +} + +func TestLocalDirSource_Fetch(t *testing.T) { + tests := []struct { + name string + templateName string + setupFunc func(string) error + wantErr bool + expectedErr string + }{ + { + name: "successful fetch", + templateName: "test-template", + setupFunc: func(tempDir string) error { + return os.MkdirAll(filepath.Join(tempDir, "test-template"), 0755) + }, + wantErr: false, + }, + { + name: "template not found", + templateName: "non-existent-template", + setupFunc: func(tempDir string) error { return nil }, + wantErr: true, + expectedErr: "template 'non-existent-template' not found in directory", + }, + { + name: "template is file not directory", + templateName: "file-template", + setupFunc: func(tempDir string) error { + return os.WriteFile(filepath.Join(tempDir, "file-template"), []byte("test"), 0644) + }, + wantErr: true, + expectedErr: "template 'file-template' is not a directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "test-local-fetch-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Setup test scenario + if err := tt.setupFunc(tempDir); err != nil { + t.Fatalf("Failed to setup test: %v", err) + } + + source := NewLocalDirSource(tempDir, tt.templateName) + result, err := source.Fetch(context.Background()) + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error but got none") + return + } + if tt.expectedErr != "" && !containsString(err.Error(), tt.expectedErr) { + t.Errorf("Expected error to contain '%s', got '%s'", tt.expectedErr, err.Error()) + } + } else { + if err != nil { + t.Errorf("Expected no error but got: %v", err) + return + } + + expectedPath := filepath.Join(tempDir, tt.templateName) + if result != expectedPath { + t.Errorf("Expected result to be '%s', got '%s'", expectedPath, result) + } + + // Verify the returned path exists + if _, err := os.Stat(result); os.IsNotExist(err) { + t.Errorf("Returned path does not exist: %s", result) + } + } + }) + } +} + +func TestLocalDirSource_Cleanup(t *testing.T) { + source := NewLocalDirSource("/test/path", "template-name") + + // Cleanup should not return error for local directories + err := source.Cleanup() + if err != nil { + t.Errorf("Expected no error from Cleanup, got: %v", err) + } +} + +// Helper function to check if a string contains a substring +func containsString(s, substr string) bool { + return strings.Contains(s, substr) +}