diff --git a/cmd/extension/extension_create.go b/cmd/extension/extension_create.go new file mode 100644 index 000000000..c8fa4be68 --- /dev/null +++ b/cmd/extension/extension_create.go @@ -0,0 +1,151 @@ +package extension + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/shopware/shopware-cli/internal/extension" + "github.com/shopware/shopware-cli/internal/system" +) + +// To ensure consistent naming the flag names are provided as constants +const NameFlagName = "name" +const TypeFlagName = "type" +const VendorFlagName = "vendor" // the user can choose to provide a vendor even if he did not enable --store +const StoreFlagName = "store" + +func newCreateCmd() *cobra.Command { + opts := &extension.CreateOptions{} + + isProvided := make(map[string]bool) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new extension", + Long: `Create a new plugin or theme with scaffolding inside a Shopware project.`, + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + // Collect provided flags + cmd.Flags().VisitAll(func(f *pflag.Flag) { + isProvided[f.Name] = cmd.Flags().Changed(f.Name) + }) + + interactive := system.IsInteractionEnabled(cmd.Context()) + + var errs error + + // Validate the relationships of provided flags + err := validateFlagRelations(isProvided, opts.Store, interactive) + if err != nil { + errs = errors.Join(errs, fmt.Errorf("\n%w", err)) + } + + // Validate the values of provided flags + err = validateFlagValues(opts, isProvided) + if err != nil { + errs = errors.Join(errs, fmt.Errorf("\n%w", err)) + } + + if errs != nil { + return errs + } + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + shouldRunForm := missing(isProvided, opts.Store) && system.IsInteractionEnabled(cmd.Context()) + + if shouldRunForm { + if err := runInteractiveCreateFormWithValidation(opts, isProvided); err != nil { + return fmt.Errorf("running create form: %w", err) + } + } + + return extension.Create(cmd.Context(), *opts) + }, + } + + flags := cmd.Flags() + flags.StringVar(&opts.Name, NameFlagName, "", "Extension name (PascalCase)") + flags.StringVar(&opts.Vendor, VendorFlagName, "", "Vendor prefix (PascalCase) for the extension name and namespace. Required if --store is enabled.") + flags.BoolVar(&opts.Store, StoreFlagName, false, "Enable if you plan to publish the extension on the Shopware Community Store.") + flags.StringVarP((*string)(&opts.Type), TypeFlagName, "t", string(extension.Plugin), "Extension type (plugin|theme)") + + _ = cmd.RegisterFlagCompletionFunc("type", cobra.FixedCompletions( + []string{string(extension.Plugin), string(extension.Theme)}, + cobra.ShellCompDirectiveNoFileComp, + )) + + return cmd +} + +func init() { + extensionRootCmd.AddCommand(newCreateCmd()) +} + +func missing(isProvided map[string]bool, store bool) bool { + required := []string{NameFlagName, TypeFlagName} + + if isProvided[StoreFlagName] && store { + required = append(required, VendorFlagName) + } + + for _, flagName := range required { + if !isProvided[flagName] { + return true + } + } + + return false +} + +func validateFlagRelations(isProvided map[string]bool, store bool, interactive bool) error { + if !interactive { + var errs error + requiredFlags := []string{NameFlagName, TypeFlagName} + for _, flagName := range requiredFlags { + if !isProvided[flagName] { + errs = errors.Join(errs, fmt.Errorf("required flag missing: --%s is required in non-interactive mode", flagName)) + } + } + + if store && !isProvided[VendorFlagName] { + errs = errors.Join(errs, errors.New("required flag missing: --vendor is required when --store is enabled")) + } + + if errs != nil { + return errs + } + } + return nil +} + +func validateFlagValues(opts *extension.CreateOptions, isProvided map[string]bool) error { + var errs []error + + if isProvided[VendorFlagName] { + if err := extension.ValidateVendor(opts.Vendor); err != nil { + errs = append(errs, err) + } + } + + if isProvided[NameFlagName] { + if err := extension.ValidateName(opts.Name); err != nil { + errs = append(errs, err) + } + } + + if isProvided[TypeFlagName] { + if err := extension.ValidateType(opts.Type); err != nil { + errs = append(errs, err) + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} diff --git a/cmd/extension/extension_create_form.go b/cmd/extension/extension_create_form.go new file mode 100644 index 000000000..113ba152e --- /dev/null +++ b/cmd/extension/extension_create_form.go @@ -0,0 +1,88 @@ +package extension + +import ( + "charm.land/huh/v2" + + "github.com/shopware/shopware-cli/internal/extension" + "github.com/shopware/shopware-cli/internal/tui" +) + +func runInteractiveCreateFormWithValidation(opts *extension.CreateOptions, isProvided map[string]bool) error { + // Print the shopware banner + tui.PrintBanner() + + // Define the theme for the interactive form. + theme := huh.ThemeFunc(func(isDark bool) *huh.Styles { + s := huh.ThemeCharm(isDark) + s.Focused.Title = s.Focused.Title.Foreground(tui.BlueColor) + s.Blurred.Title = s.Blurred.Title.Foreground(tui.BlueColor) + return s + }) + + // Create the form dynamically based on required input. + var groups []*huh.Group + + if !isProvided[TypeFlagName] { + groups = append(groups, + huh.NewGroup( + huh.NewSelect[extension.ExtensionType](). + Title("Extension Type"). + Description("Choose the type of extension you want to create."). + Options( + huh.NewOption("Plugin", extension.Plugin), + huh.NewOption("Theme", extension.Theme), + ). + Value(&opts.Type), + ), + ) + } + + if !isProvided[StoreFlagName] { + groups = append(groups, + huh.NewGroup( + huh.NewSelect[bool](). + Title("Do you plan to publish this extension in the Community Store?"). + Description("This affects where the extension is created. Store extensions require a vendor-prefixed name."). + Options( + huh.NewOption("No, it's only for this project.", false), + huh.NewOption("Yes, I plan to publish it.", true), + ). + Value(&opts.Store), + ), + ) + } + + if !isProvided[VendorFlagName] { + groups = append(groups, + huh.NewGroup( + huh.NewInput(). + Title("Vendor Prefix"). + Description("Use PascalCase, e.g. SwagBasicExample."). + Placeholder("Swag"). + Value(&opts.Vendor). + Validate(extension.ValidateVendor), + ).WithHideFunc(func() bool { + return !opts.Store + }), + ) + } + + if !isProvided[NameFlagName] { + groups = append(groups, + huh.NewGroup( + huh.NewInput(). + Title("Extension Name"). + Description("Use PascalCase and, for Community Store extensions, a vendor prefix, e.g. SwagBasicExample."). + Placeholder("BasicExample"). + Value(&opts.Name). + Validate(extension.ValidateName), + ), + ) + } + + if len(groups) == 0 { + return nil + } + + return huh.NewForm(groups...).WithTheme(theme).Run() +} diff --git a/cmd/extension/extension_create_test.go b/cmd/extension/extension_create_test.go new file mode 100644 index 000000000..ce1d10051 --- /dev/null +++ b/cmd/extension/extension_create_test.go @@ -0,0 +1,44 @@ +package extension + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateFlagRelations(t *testing.T) { + t.Run("non-interactive mode with missing flag", func(t *testing.T) { + err := validateFlagRelations(map[string]bool{ + NameFlagName: false, + TypeFlagName: true, + }, false, false) + + require.Error(t, err) + assert.ErrorContains(t, err, "--name") + }) + + t.Run("non-interactive mode with store enabled and vendor missing", func(t *testing.T) { + err := validateFlagRelations(map[string]bool{ + NameFlagName: true, + TypeFlagName: true, + StoreFlagName: true, + }, true, false) + + require.Error(t, err) + assert.ErrorContains(t, err, "--vendor") + }) + + t.Run("non-interactive mode with all required flags", func(t *testing.T) { + require.NoError(t, validateFlagRelations(map[string]bool{ + NameFlagName: true, + TypeFlagName: true, + StoreFlagName: true, + VendorFlagName: true, + }, true, false)) + }) + + t.Run("interactive mode does not require any flag", func(t *testing.T) { + require.NoError(t, validateFlagRelations(map[string]bool{}, true, true)) + }) +} diff --git a/internal/extension/create.go b/internal/extension/create.go new file mode 100644 index 000000000..4503a5c96 --- /dev/null +++ b/internal/extension/create.go @@ -0,0 +1,82 @@ +package extension + +import ( + "context" + "errors" + "fmt" + "path/filepath" + + "github.com/shopware/shopware-cli/internal/extension/scaffolding" + "github.com/shopware/shopware-cli/internal/shop" + "github.com/shopware/shopware-cli/logging" +) + +type ExtensionType string + +const ( + Plugin ExtensionType = "plugin" + Theme ExtensionType = "theme" +) + +// CreateOptions contains the choices used to create extension scaffolding. +type CreateOptions struct { + Name string + Vendor string + Type ExtensionType + Store bool +} + +// Create writes extension scaffolding in the closest Shopware project. +func Create(ctx context.Context, opts CreateOptions) (err error) { + logger := logging.FromContext(ctx) + + logger.Info("Creating extension...") + + projectDir, err := shop.FindClosestShopwareProject(false) + if err != nil { + return err + } + + technicalName := deriveTechnicalName(opts.Name, opts.Vendor) + extensionDir := deriveExtensionDirectoryName(projectDir, opts.Store, technicalName) + + err = scaffolding.CreateExtensionDir(extensionDir) + if err != nil { + return err + } + + // Remove only the directory created above if a later step fails. + defer func() { + if err == nil { + return + } + logger.Debugf("Rollback of %s", extensionDir) + if cleanupErr := scaffolding.RemoveCreatedExtensionDir(extensionDir); cleanupErr != nil { + err = errors.Join(err, fmt.Errorf("rollback failed: %w", cleanupErr)) + } + }() + + if err = scaffolding.CreateExtensionFiles(extensionDir, opts.Name, opts.Vendor); err != nil { + return fmt.Errorf("create extension files: %w", err) + } + + logger.Infof("✓ Extension successfully created in %s", extensionDir) + + return nil +} + +func deriveTechnicalName(name, vendor string) string { + if vendor == "" { + return name + } + return vendor + name +} + +func deriveExtensionDirectoryName(projectDir string, store bool, technicalName string) string { + pluginDir := "static-plugins" + if store { + pluginDir = "plugins" + } + + return filepath.Join(projectDir, "custom", pluginDir, technicalName) +} diff --git a/internal/extension/create_test.go b/internal/extension/create_test.go new file mode 100644 index 000000000..686284ab8 --- /dev/null +++ b/internal/extension/create_test.go @@ -0,0 +1,119 @@ +package extension + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shopware/shopware-cli/internal/system" +) + +func TestDeriveTechnicalName(t *testing.T) { + t.Parallel() + + assert.Equal(t, "MyExtension", deriveTechnicalName("MyExtension", "")) + assert.Equal(t, "MyVendorMyExtension", deriveTechnicalName("MyExtension", "MyVendor")) +} +func TestDeriveExtensionDirectoryName(t *testing.T) { + projectDir := newProject(t) + + path := filepath.Join(projectDir, "custom", "static-plugins", "MyExtension") + pathStore := filepath.Join(projectDir, "custom", "plugins", "MyVendorMyExtension") + + assert.Equal(t, path, deriveExtensionDirectoryName(projectDir, false, "MyExtension")) + assert.Equal(t, pathStore, deriveExtensionDirectoryName(projectDir, true, "MyVendorMyExtension")) +} + +func TestValidateExtensionName(t *testing.T) { + t.Parallel() + + for _, valid := range []string{"SwagBasicExample", "MyPlugin", "AcmePayPal", "Swag2Example", "Example"} { + assert.NoError(t, ValidateName(valid), valid) + } + + for _, invalid := range []string{ + "", "swagBasicExample", "my-plugin", "My_Plugin", + "My Plugin", "1Plugin", "Swag.Example", + } { + assert.Error(t, ValidateName(invalid), invalid) + } +} + +func TestValidateVendorName(t *testing.T) { + t.Parallel() + + for _, valid := range []string{"Vendor", "MyVendor", "VendorAG"} { + assert.NoError(t, ValidateVendor(valid), valid) + } + + for _, invalid := range []string{ + "", "vendor", "my-vendor", "My_Vendor", + "My Vendor", "1Vendor", "Vendor.Example", + } { + assert.Error(t, ValidateVendor(invalid), invalid) + } +} + +func TestValidateExtensionType(t *testing.T) { + t.Parallel() + + for _, valid := range []ExtensionType{Plugin, Theme} { + assert.NoError(t, ValidateType(valid), valid) + } + + for _, invalid := range []ExtensionType{"", "pluginx", "themey", "invalid"} { + assert.Error(t, ValidateType(invalid), invalid) + } +} + +func TestCreateFailsOutsideShopwareProject(t *testing.T) { + t.Setenv("PROJECT_ROOT", "") + t.Chdir(t.TempDir()) + + err := Create(system.WithInteraction(t.Context(), false), validCreateOptions()) + + assert.ErrorContains(t, err, "cannot find Shopware project") +} + +func TestCreateGeneratesAnExtension(t *testing.T) { + for _, store := range []bool{false, true} { + t.Run(fmt.Sprintf("store=%t", store), func(t *testing.T) { + projectDir := newProject(t) + opts := validCreateOptions() + opts.Store = store + + require.NoError(t, Create(t.Context(), opts)) + + technicalName := deriveTechnicalName(opts.Name, opts.Vendor) + extensionDir := deriveExtensionDirectoryName(projectDir, opts.Store, technicalName) + assert.FileExists(t, filepath.Join(extensionDir, "composer.json")) + assert.FileExists(t, filepath.Join(extensionDir, "src", "Resources", "config", "config.xml")) + assert.FileExists(t, filepath.Join(extensionDir, ".gitignore")) + assert.FileExists(t, filepath.Join(extensionDir, "phpunit.xml")) + assert.FileExists(t, filepath.Join(extensionDir, "src", technicalName+".php")) + assert.FileExists(t, filepath.Join(extensionDir, "tests", "TestBootstrap.php")) + }) + } +} + +func validCreateOptions() CreateOptions { + return CreateOptions{ + Name: "MyExtension", + Vendor: "MyVendor", + Type: Plugin, + } +} + +func newProject(t *testing.T) string { + t.Helper() + + projectDir := t.TempDir() + t.Setenv("PROJECT_ROOT", projectDir) + require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "custom", "plugins"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "custom", "static-plugins"), 0o755)) + return projectDir +} diff --git a/internal/extension/create_validate.go b/internal/extension/create_validate.go new file mode 100644 index 000000000..34a2c8dd8 --- /dev/null +++ b/internal/extension/create_validate.go @@ -0,0 +1,45 @@ +package extension + +import ( + "errors" + "fmt" + "regexp" +) + +var ( + extensionNameRegexp = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*$`) + vendorNameRegexp = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*$`) +) + +func ValidateName(name string) error { + if name == "" { + return errors.New("extension name must not be empty") + } + + if !extensionNameRegexp.MatchString(name) { + return fmt.Errorf("invalid extension name %q: use PascalCase, letters and digits only", name) + } + + return nil +} + +func ValidateVendor(vendor string) error { + if vendor == "" { + return errors.New("vendor name must not be empty") + } + + if !vendorNameRegexp.MatchString(vendor) { + return fmt.Errorf("invalid vendor name %q: use PascalCase, letters and digits only", vendor) + } + + return nil +} + +func ValidateType(extensionType ExtensionType) error { + switch extensionType { + case Plugin, Theme: + return nil + default: + return fmt.Errorf("invalid extension type %q, must be theme or plugin", extensionType) + } +} diff --git a/internal/extension/scaffolding/scaffolding.go b/internal/extension/scaffolding/scaffolding.go new file mode 100644 index 000000000..fa60eacc6 --- /dev/null +++ b/internal/extension/scaffolding/scaffolding.go @@ -0,0 +1,288 @@ +package scaffolding + +import ( + "embed" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + "unicode" +) + +const ( + privatePluginRoot = "custom/static-plugins" + storePluginRoot = "custom/plugins" + composerNameRegex = "^[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9](([_.]?|-{0,2})[a-z0-9]+)*$" + packageNameRegex = "^[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9](([_.]|-{1,2})?[a-z0-9]+)*$" +) + +//go:embed stubs/* +var stubsFS embed.FS + +// stubFuncs are helpers available inside the stub templates. +var stubFuncs = template.FuncMap{ + // jsonEscape makes a value safe inside a JSON string + "jsonEscape": func(value string) (string, error) { + encoded, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("escape %q for json: %w", value, err) + } + + // Drop the surrounding quotes json.Marshal adds. + return string(encoded[1 : len(encoded)-1]), nil + }, + // escapeBackslash makes a value safe inside PHP strings by escaping backslashes. + "escapeBackslash": func(value string) string { + return strings.ReplaceAll(value, "\\", "\\\\") + }, +} + +type scaffoldingFile struct { + Path string + StubPath string +} + +// scaffoldingFiles returns a list of files with their paths and corresponding stub paths. +func scaffoldingFiles(className string) []scaffoldingFile { + return []scaffoldingFile{ + { + Path: "composer.json", + StubPath: "stubs/composer.json.tmpl", + }, + { + Path: "phpunit.xml", + StubPath: "stubs/phpunit.xml.tmpl", + }, + { + Path: "tests/TestBootstrap.php", + StubPath: "stubs/test_bootstrap.php.tmpl", + }, + { + Path: ".gitignore", + StubPath: "stubs/gitignore.tmpl", + }, + { + Path: "src/Resources/config/config.xml", + StubPath: "stubs/config.xml.tmpl", + }, + { + Path: filepath.Join("src", className+".php"), + StubPath: "stubs/plugin_class.php.tmpl", + }, + } +} + +// CreateExtensionDir creates an empty extension directory. Its parents must already exist. +func CreateExtensionDir(extensionDir string) error { + info, err := os.Stat(extensionDir) + if err == nil { + if !info.IsDir() { + return fmt.Errorf("%s exists and is not a directory", extensionDir) + } + return fmt.Errorf("extension directory already exists: %s", extensionDir) + } + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("stat extension directory: %w", err) + } + + parent := filepath.Dir(extensionDir) + info, err = os.Stat(parent) + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("extension parent directory does not exist: %s", parent) + } + if err != nil { + return fmt.Errorf("stat extension parent directory: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("extension parent path is not a directory: %s", parent) + } + + if err := os.Mkdir(extensionDir, 0o755); err != nil { + return fmt.Errorf("create extension directory: %w", err) + } + + return nil +} + +// CreateExtensionFiles creates all scaffolding Files that are given back by scaffoldingFiles() +func CreateExtensionFiles(extensionDir, extensionName, vendorName string) error { + data := createScaffoldingData(vendorName, extensionName) + for _, file := range scaffoldingFiles(data.ClassName) { + err := createFileWithScaffolding(extensionDir, file, data) + if err != nil { + return err + } + } + + return nil +} + +// createFileWithScaffolding renders one embedded template into an existing extension. +func createFileWithScaffolding(extensionDir string, file scaffoldingFile, data scaffoldData) (err error) { + dest := filepath.Join(extensionDir, file.Path) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("create subdirectories: %w", err) + } + + f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer func() { + if closeErr := f.Close(); err == nil && closeErr != nil { + err = fmt.Errorf("close file: %w", closeErr) + } + }() + + stubBytes, err := stubsFS.ReadFile(file.StubPath) + if err != nil { + return fmt.Errorf("read stub file: %w", err) + } + + tmpl, err := template.New(file.Path).Funcs(stubFuncs).Parse(string(stubBytes)) + if err != nil { + return fmt.Errorf("parse stub: %w", err) + } + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("render: %w", err) + } + if err := f.Sync(); err != nil { + return fmt.Errorf("flush file to disk: %w", err) + } + + return nil +} + +type scaffoldData struct { + Namespace string + ClassName string + ComposerName string +} + +func createScaffoldingData(vendorName string, extensionName string) scaffoldData { + return scaffoldData{ + Namespace: DeriveNamespace(vendorName, extensionName), + ClassName: DeriveClassName(vendorName, extensionName), + ComposerName: DeriveComposerName(vendorName, extensionName), + } +} + +// DeriveNamespace turns a given extension name and vendor name into a PHP namespace. +func DeriveNamespace(vendorName string, extensionName string) string { + if vendorName == "" { + return extensionName + } + return vendorName + "\\" + extensionName +} + +// DeriveComposerName turns a given extension name and vendor name into a valid Composer package name: +// Vendor, BasicExample → vendor/basic-example. +func DeriveComposerName(vendor string, name string) string { + vendorParts := splitPascalCase(vendor) + nameParts := splitPascalCase(name) + + lowerVendor := strings.ToLower(strings.Join(vendorParts, "-")) + lowerName := strings.ToLower(strings.Join(nameParts, "-")) + + if lowerVendor == "" { + lowerVendor = lowerName + } + + composerName := lowerVendor + "/" + lowerName + + return composerName +} + +// DeriveClassName turns a given extension name and vendor name into a valid PHP class name. +func DeriveClassName(vendorName string, extensionName string) string { + return vendorName + extensionName +} + +// splitPascalCase is a helper function and splits a PascalCase string into its constituent words. +func splitPascalCase(name string) []string { + if name == "" { + return nil + } + + runes := []rune(name) + start := 0 + parts := make([]string, 0, 4) + + for i := 1; i < len(runes); i++ { + if unicode.IsUpper(runes[i]) { + parts = append(parts, string(runes[start:i])) + start = i + } + } + + return append(parts, string(runes[start:])) +} + +// RemoveCreatedExtensionDir deletes the directory created by CreateExtensionDir. +// It only removes a path that is an extension folder (custom/plugins/ or +// custom/static-plugins/), never parents, the project root, or a symlink. +func RemoveCreatedExtensionDir(extensionDir string) error { + if err := validateRemovableExtensionDir(extensionDir); err != nil { + return err + } + abs, _ := filepath.Abs(extensionDir) // Already validated, so error can be ignored. + + // Delete the folder and everything inside it. + if err := os.RemoveAll(abs); err != nil { + return fmt.Errorf("remove extension directory: %w", err) + } + + return nil +} + +func validateRemovableExtensionDir(extensionDir string) error { + // Reject an empty path variable. + if strings.TrimSpace(extensionDir) == "" { + return errors.New("extension directory variable must not be empty") + } + + // Turn the path into an absolute, cleaned path (no ".."). + abs, err := filepath.Abs(extensionDir) + if err != nil { + return fmt.Errorf("resolve extension directory: %w", err) + } + abs = filepath.Clean(abs) + + // Never delete the filesystem root. + if abs == string(filepath.Separator) { + return fmt.Errorf("refusing to remove %s", abs) + } + + // The last segment must be a real folder name. + name := filepath.Base(abs) + if name == "." || name == ".." || name == string(filepath.Separator) { + return fmt.Errorf("refusing to remove %s", abs) + } + + // Parent must be custom/plugins or custom/static-plugins. + parent := filepath.Dir(abs) + pluginRoot := filepath.Join(filepath.Base(filepath.Dir(parent)), filepath.Base(parent)) + if pluginRoot != filepath.FromSlash(storePluginRoot) && pluginRoot != filepath.FromSlash(privatePluginRoot) { + return fmt.Errorf("refusing to remove %s: not an extension directory", abs) + } + + // Inspect the path itself, do not follow a symlink. + info, err := os.Lstat(abs) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil // Already gone. + } + return fmt.Errorf("stat extension directory: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing to remove %s: is a symlink", abs) + } + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", abs) + } + return nil +} diff --git a/internal/extension/scaffolding/scaffolding_test.go b/internal/extension/scaffolding/scaffolding_test.go new file mode 100644 index 000000000..3a2c0e71c --- /dev/null +++ b/internal/extension/scaffolding/scaffolding_test.go @@ -0,0 +1,235 @@ +package scaffolding + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeriveNamespace(t *testing.T) { + t.Parallel() + + assert.Equal(t, "MyExtension", DeriveNamespace("", "MyExtension")) + assert.Equal(t, `MyVendor\MyExtension`, DeriveNamespace("MyVendor", "MyExtension")) +} + +func TestDeriveComposerName(t *testing.T) { + t.Parallel() + + assert.Equal(t, "my-extension/my-extension", DeriveComposerName("", "MyExtension")) + assert.Equal(t, "my-vendor/my-extension", DeriveComposerName("MyVendor", "MyExtension")) +} + +func TestDeriveClassName(t *testing.T) { + t.Parallel() + + assert.Equal(t, "MyVendorMyExtension", DeriveClassName("MyVendor", "MyExtension")) +} + +func TestSplitPascalCase(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{"My", "Extension"}, splitPascalCase("MyExtension")) + assert.Equal(t, []string{"M", "E"}, splitPascalCase("ME")) +} + +// CreateExtensionDir should create a directory with the given name. +func TestCreateExtensionDirCreatesDirectoryWithGivenName(t *testing.T) { + // Store extensions live in custom/plugins, project ones in custom/static-plugins. + for _, pluginRoot := range []string{"plugins", "static-plugins"} { + t.Run(pluginRoot, func(t *testing.T) { + extensionDir := filepath.Join(newProject(t), "custom", pluginRoot, "MyExtension") + + require.NoError(t, CreateExtensionDir(extensionDir)) + + info, err := os.Stat(extensionDir) + require.NoError(t, err) + assert.True(t, info.IsDir()) + assert.Equal(t, os.FileMode(0o755), info.Mode().Perm()) + }) + } +} + +func TestCreateExtensionDirErrors(t *testing.T) { + t.Run("extension directory already exists", func(t *testing.T) { + extensionDir := filepath.Join(newProject(t), "custom", "plugins", "MyExtension") + require.NoError(t, CreateExtensionDir(extensionDir)) + + assert.ErrorContains(t, CreateExtensionDir(extensionDir), "already exists") + }) + + t.Run("path exists as file", func(t *testing.T) { + extensionDir := filepath.Join(newProject(t), "custom", "plugins", "MyExtension") + require.NoError(t, os.WriteFile(extensionDir, nil, 0o644)) + + assert.ErrorContains(t, CreateExtensionDir(extensionDir), "not a directory") + }) + + t.Run("plugin root does not exist", func(t *testing.T) { + projectDir := newProject(t) + require.NoError(t, os.RemoveAll(filepath.Join(projectDir, "custom"))) + extensionDir := filepath.Join(projectDir, "custom", "plugins", "MyExtension") + + assert.ErrorContains(t, CreateExtensionDir(extensionDir), "does not exist") + assert.NoDirExists(t, extensionDir) + }) + + t.Run("parent path not a directory", func(t *testing.T) { + projectDir := newProject(t) + parentPath := filepath.Join(projectDir, "custom", "plugins", "MyVendor") + require.NoError(t, os.WriteFile(parentPath, nil, 0o644)) + extensionDir := filepath.Join(parentPath, "MyExtension") + + assert.ErrorContains(t, CreateExtensionDir(extensionDir), "not a directory") + assert.NoDirExists(t, extensionDir) + }) +} + +func TestCreateExtensionFiles(t *testing.T) { + projectDir := newProject(t) + technicalName := "MyVendorMyExtension" + extensionDir := filepath.Join(projectDir, "custom", "plugins", technicalName) + require.NoError(t, os.MkdirAll(extensionDir, 0o755)) + + require.NoError(t, CreateExtensionFiles(extensionDir, "MyExtension", "MyVendor")) + + assert.DirExists(t, filepath.Join(extensionDir, "src", "Resources", "config")) + assert.DirExists(t, filepath.Join(extensionDir, "tests")) + + // all expected files for an installable extension are created + assert.FileExists(t, filepath.Join(extensionDir, "composer.json")) + assert.FileExists(t, filepath.Join(extensionDir, "src", "Resources", "config", "config.xml")) + assert.FileExists(t, filepath.Join(extensionDir, ".gitignore")) + assert.FileExists(t, filepath.Join(extensionDir, "phpunit.xml")) + assert.FileExists(t, filepath.Join(extensionDir, "src", technicalName+".php")) + assert.FileExists(t, filepath.Join(extensionDir, "tests", "TestBootstrap.php")) +} + +func TestCreateFileWithScaffoldingErrors(t *testing.T) { + t.Run("destination file already exists", func(t *testing.T) { + extensionDir := filepath.Join(t.TempDir(), "MyVendorMyExtension") + file := scaffoldingFile{Path: filepath.Join("src", "MyExtension.php"), StubPath: "stubs/plugin_class.php.tmpl"} + data := createScaffoldingData("MyVendor", "MyExtension") + + require.NoError(t, createFileWithScaffolding(extensionDir, file, data)) + assert.ErrorContains(t, createFileWithScaffolding(extensionDir, file, data), "file exists") + }) + + t.Run("stub file does not exist", func(t *testing.T) { + extensionDir := filepath.Join(t.TempDir(), "MyVendorMyExtension") + file := scaffoldingFile{Path: filepath.Join("src", "MyExtension.php"), StubPath: "stubs/does_not_exist.tmpl"} + data := createScaffoldingData("MyVendor", "MyExtension") + + assert.ErrorContains(t, createFileWithScaffolding(extensionDir, file, data), "stub") + }) +} + +func TestCreateFileWithScaffolding(t *testing.T) { + extensionDir := filepath.Join(t.TempDir(), "MyVendorMyExtension") + file := scaffoldingFile{Path: filepath.Join("src", "MyExtension.php"), StubPath: "stubs/plugin_class.php.tmpl"} + data := createScaffoldingData("MyVendor", "MyExtension") + + require.NoError(t, createFileWithScaffolding(extensionDir, file, data)) + // assert it also created the necessary subdirectories + assert.DirExists(t, filepath.Join(extensionDir, "src")) + assert.FileExists(t, filepath.Join(extensionDir, file.Path)) +} + +func TestRemoveCreatedExtensionDir(t *testing.T) { + for _, pluginRoot := range []string{"plugins", "static-plugins"} { + t.Run(pluginRoot, func(t *testing.T) { + extensionDir := filepath.Join(newProject(t), "custom", pluginRoot, "MyExtension") + require.NoError(t, CreateExtensionDir(extensionDir)) + require.NoError(t, os.WriteFile(filepath.Join(extensionDir, "composer.json"), nil, 0o644)) + + // The directory and its content are gone. + require.NoError(t, RemoveCreatedExtensionDir(extensionDir)) + assert.NoDirExists(t, extensionDir) + + // Removing an already absent extension is safe. + require.NoError(t, RemoveCreatedExtensionDir(extensionDir)) + }) + } +} + +func TestValidateRemovableExtensionDirErrors(t *testing.T) { + t.Run("empty path", func(t *testing.T) { + assert.Error(t, validateRemovableExtensionDir("")) + assert.Error(t, validateRemovableExtensionDir(" ")) + }) + + t.Run("filesystem root", func(t *testing.T) { + root := string(filepath.Separator) + + assert.Error(t, validateRemovableExtensionDir(root)) + }) + + t.Run("not inside a plugin root", func(t *testing.T) { + otherDir := filepath.Join(newProject(t), "custom", "apps", "MyExtension") + require.NoError(t, os.MkdirAll(otherDir, 0o755)) + + assert.ErrorContains(t, validateRemovableExtensionDir(otherDir), "not an extension directory") + }) + + t.Run("plugin root itself", func(t *testing.T) { + pluginRoot := filepath.Join(newProject(t), "custom", "plugins") + + assert.ErrorContains(t, validateRemovableExtensionDir(pluginRoot), "not an extension directory") + }) + + t.Run("symlink", func(t *testing.T) { + pluginRoot := filepath.Join(newProject(t), "custom", "plugins") + target := filepath.Join(pluginRoot, "Target") + link := filepath.Join(pluginRoot, "MyExtension") + require.NoError(t, os.Mkdir(target, 0o755)) + require.NoError(t, os.Symlink(target, link)) + + assert.ErrorContains(t, validateRemovableExtensionDir(link), "symlink") + }) + + t.Run("not a directory", func(t *testing.T) { + file := filepath.Join(newProject(t), "custom", "plugins", "MyExtension") + require.NoError(t, os.WriteFile(file, nil, 0o644)) + + assert.ErrorContains(t, validateRemovableExtensionDir(file), "not a directory") + }) +} + +func TestRemoveCreatedExtensionDirErrorsWhenRemovalFails(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root may remove files in a read-only directory") + } + + pluginRoot := filepath.Join(newProject(t), "custom", "plugins") + extensionDir := filepath.Join(pluginRoot, "MyExtension") + require.NoError(t, CreateExtensionDir(extensionDir)) + require.NoError(t, os.WriteFile(filepath.Join(extensionDir, "composer.json"), nil, 0o644)) + + // A read-only plugin root makes the removal fail. + require.NoError(t, os.Chmod(pluginRoot, 0o500)) + t.Cleanup(func() { + require.NoError(t, os.Chmod(pluginRoot, 0o755)) + }) + + err := RemoveCreatedExtensionDir(extensionDir) + + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "remove"), "unexpected error: %v", err) + assert.DirExists(t, extensionDir) +} + +// newProject creates an empty Shopware project with both plugin roots and +// returns the project directory. +func newProject(t *testing.T) string { + t.Helper() + + projectDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "custom", "plugins"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "custom", "static-plugins"), 0o755)) + + return projectDir +} diff --git a/internal/extension/scaffolding/stubs/composer.json.tmpl b/internal/extension/scaffolding/stubs/composer.json.tmpl new file mode 100644 index 000000000..7786031f1 --- /dev/null +++ b/internal/extension/scaffolding/stubs/composer.json.tmpl @@ -0,0 +1,27 @@ +{ + "name": "{{ .ComposerName }}", + "description": "{{ .ComposerName }}", + "type": "shopware-platform-plugin", + "version": "1.0.0", + "license": "MIT", + "require": { + "shopware/core": "~6.7.0" + }, + "extra": { + "shopware-plugin-class": "{{ jsonEscape .Namespace }}\\{{ .ClassName }}", + "label": { + "de-DE": "Skeleton plugin", + "en-GB": "Skeleton plugin" + } + }, + "autoload": { + "psr-4": { + "{{ jsonEscape .Namespace }}\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "{{ jsonEscape .Namespace }}\\Tests\\": "tests/" + } + } +} diff --git a/internal/extension/scaffolding/stubs/config.xml.tmpl b/internal/extension/scaffolding/stubs/config.xml.tmpl new file mode 100644 index 000000000..b45fed585 --- /dev/null +++ b/internal/extension/scaffolding/stubs/config.xml.tmpl @@ -0,0 +1,16 @@ + + + + + + Minimal configuration + + + textField + + test + + + + diff --git a/internal/extension/scaffolding/stubs/gitignore.tmpl b/internal/extension/scaffolding/stubs/gitignore.tmpl new file mode 100644 index 000000000..7ca242719 --- /dev/null +++ b/internal/extension/scaffolding/stubs/gitignore.tmpl @@ -0,0 +1,5 @@ +/composer.lock +/src/Resources/app/administration/node_modules/ +/src/Resources/app/administration/src/.vite +/src/Resources/public/ +/vendor \ No newline at end of file diff --git a/internal/extension/scaffolding/stubs/phpunit.xml.tmpl b/internal/extension/scaffolding/stubs/phpunit.xml.tmpl new file mode 100644 index 000000000..907969bc0 --- /dev/null +++ b/internal/extension/scaffolding/stubs/phpunit.xml.tmpl @@ -0,0 +1,23 @@ + + + + + ./src/ + + + + + + + + + + + + tests + + + diff --git a/internal/extension/scaffolding/stubs/plugin_class.php.tmpl b/internal/extension/scaffolding/stubs/plugin_class.php.tmpl new file mode 100644 index 000000000..c2c01af14 --- /dev/null +++ b/internal/extension/scaffolding/stubs/plugin_class.php.tmpl @@ -0,0 +1,54 @@ +keepUserData()) { + return; + } + + // Remove or deactivate the data created by the plugin + } + + public function activate(ActivateContext $activateContext): void + { + // Activate entities, such as a new payment method + // Or create new entities here, because now your plugin is installed and active for sure + } + + public function deactivate(DeactivateContext $deactivateContext): void + { + // Deactivate entities, such as a new payment method + // Or remove previously created entities + } + + public function update(UpdateContext $updateContext): void + { + // Update necessary stuff, mostly non-database related + } + + public function postInstall(InstallContext $installContext): void + { + } + + public function postUpdate(UpdateContext $updateContext): void + { + } +} diff --git a/internal/extension/scaffolding/stubs/test_bootstrap.php.tmpl b/internal/extension/scaffolding/stubs/test_bootstrap.php.tmpl new file mode 100644 index 000000000..1bd6c84b8 --- /dev/null +++ b/internal/extension/scaffolding/stubs/test_bootstrap.php.tmpl @@ -0,0 +1,12 @@ +addCallingPlugin() + ->addActivePlugins('{{ .ClassName }}') + ->setForceInstallPlugins(true) + ->bootstrap() + ->getClassLoader(); + +$loader->addPsr4('{{ escapeBackslash .Namespace }}\\Tests\\', __DIR__);