From 7b14ca1a7e9452f25f457c3820dab72da0204d13 Mon Sep 17 00:00:00 2001 From: Anne Date: Mon, 7 Sep 2026 14:40:54 +0200 Subject: [PATCH 1/5] feat: add extension create command --- cmd/extension/extension_create.go | 74 ++++ cmd/extension/extension_create_form.go | 52 +++ cmd/extension/extension_create_test.go | 165 ++++++++ internal/extension/create.go | 80 ++++ internal/extension/create_installable.go | 220 +++++++++++ internal/extension/create_test.go | 371 ++++++++++++++++++ internal/extension/create_validate.go | 40 ++ internal/extension/scaffolding/scaffolding.go | 272 +++++++++++++ .../extension/scaffolding/scaffolding_test.go | 146 +++++++ .../scaffolding/stubs/composer.json.tmpl | 27 ++ .../scaffolding/stubs/config.xml.tmpl | 16 + .../scaffolding/stubs/gitignore.tmpl | 5 + .../scaffolding/stubs/phpunit.xml.tmpl | 23 ++ .../scaffolding/stubs/plugin_class.php.tmpl | 54 +++ .../scaffolding/stubs/test_bootstrap.php.tmpl | 12 + 15 files changed, 1557 insertions(+) create mode 100644 cmd/extension/extension_create.go create mode 100644 cmd/extension/extension_create_form.go create mode 100644 cmd/extension/extension_create_test.go create mode 100644 internal/extension/create.go create mode 100644 internal/extension/create_installable.go create mode 100644 internal/extension/create_test.go create mode 100644 internal/extension/create_validate.go create mode 100644 internal/extension/scaffolding/scaffolding.go create mode 100644 internal/extension/scaffolding/scaffolding_test.go create mode 100644 internal/extension/scaffolding/stubs/composer.json.tmpl create mode 100644 internal/extension/scaffolding/stubs/config.xml.tmpl create mode 100644 internal/extension/scaffolding/stubs/gitignore.tmpl create mode 100644 internal/extension/scaffolding/stubs/phpunit.xml.tmpl create mode 100644 internal/extension/scaffolding/stubs/plugin_class.php.tmpl create mode 100644 internal/extension/scaffolding/stubs/test_bootstrap.php.tmpl diff --git a/cmd/extension/extension_create.go b/cmd/extension/extension_create.go new file mode 100644 index 000000000..e8edb5706 --- /dev/null +++ b/cmd/extension/extension_create.go @@ -0,0 +1,74 @@ +package extension + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/shopware/shopware-cli/internal/extension" + "github.com/shopware/shopware-cli/internal/system" +) + +func newCreateCmd() *cobra.Command { + opts := &extension.CreateOptions{} + + 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 { + return validateInput(opts) + }, + RunE: func(cmd *cobra.Command, args []string) error { + needsName := opts.Name == "" + needsStore := !cmd.Flags().Changed("store") + + if needsName { + if !system.IsInteractionEnabled(cmd.Context()) { + return errors.New("extension name is required when interaction is disabled") + } + + if err := runInteractiveCreateForm(opts, needsName, needsStore); err != nil { + return fmt.Errorf("running create form: %w", err) + } + } + + if err := extension.ValidateName(opts.Name, opts.Store); err != nil { + return err + } + + return extension.Create(cmd.Context(), *opts) + }, + } + + flags := cmd.Flags() + flags.StringVar(&opts.Name, "name", "", "Extension name (PascalCase)") + flags.BoolVar(&opts.Store, "store", false, "Planning commercial use in Shopware Community Store") + flags.StringVarP((*string)(&opts.Type), "type", "t", string(extension.Plugin), "Extension type (plugin|theme)") + + _ = flags.MarkHidden("type") // Since "theme" is not implemented yet, this flag is hidden from the user + + _ = cmd.RegisterFlagCompletionFunc("type", cobra.FixedCompletions( + []string{string(extension.Plugin), string(extension.Theme)}, + cobra.ShellCompDirectiveNoFileComp, + )) + + return cmd +} + +func validateInput(opts *extension.CreateOptions) error { + if err := extension.ValidateType(opts.Type); err != nil { + return err + } + if opts.Name == "" { + return nil + } + + return extension.ValidateName(opts.Name, opts.Store) +} + +func init() { + extensionRootCmd.AddCommand(newCreateCmd()) +} diff --git a/cmd/extension/extension_create_form.go b/cmd/extension/extension_create_form.go new file mode 100644 index 000000000..e5df01032 --- /dev/null +++ b/cmd/extension/extension_create_form.go @@ -0,0 +1,52 @@ +package extension + +import ( + "charm.land/huh/v2" + + "github.com/shopware/shopware-cli/internal/extension" + "github.com/shopware/shopware-cli/internal/tui" +) + +func runInteractiveCreateForm(opts *extension.CreateOptions, needsName bool, needsStore bool) error { + // Print the shopware banner + tui.PrintBanner() + + // Create the form dynamically based on required input. + var groups []*huh.Group + + if needsStore { + 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 needsName { + 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("SwagBasicExample"). + Value(&opts.Name). + Validate(func(name string) error { + return extension.ValidateName(name, opts.Store) + }), + ), + ) + } + + if len(groups) == 0 { + return nil + } + + return huh.NewForm(groups...).Run() +} diff --git a/cmd/extension/extension_create_test.go b/cmd/extension/extension_create_test.go new file mode 100644 index 000000000..0033df1d9 --- /dev/null +++ b/cmd/extension/extension_create_test.go @@ -0,0 +1,165 @@ +package extension + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + internalextension "github.com/shopware/shopware-cli/internal/extension" + "github.com/shopware/shopware-cli/internal/system" +) + +func TestCreateCommandDefaults(t *testing.T) { + cmd := newCreateCmd() + + extensionType, err := cmd.Flags().GetString("type") + require.NoError(t, err) + assert.Equal(t, string(internalextension.Plugin), extensionType) + + store, err := cmd.Flags().GetBool("store") + require.NoError(t, err) + assert.False(t, store) +} + +func TestCreateCommandMapsFlags(t *testing.T) { + projectDir := t.TempDir() + t.Setenv("PROJECT_ROOT", projectDir) + require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "custom", "plugins"), 0o755)) + + cmd := newCreateCmd() + cmd.SetContext(system.WithInteraction(t.Context(), false)) + cmd.SetArgs([]string{ + "--store", + "--name", "SwagBasicExample", + }) + + require.NoError(t, cmd.Execute()) + assert.FileExists(t, filepath.Join( + projectDir, + "custom", + "plugins", + "SwagBasicExample", + "composer.json", + )) +} + +func TestCreateCommandValidatesTypeFlag(t *testing.T) { + tests := []struct { + name string + extensionType string + error string + }{ + { + name: "plugin", + extensionType: "plugin", + }, + { + name: "theme", + extensionType: "theme", + }, + { + name: "rejected", + extensionType: "unknown", + error: `invalid extension type "unknown"`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + projectDir := t.TempDir() + t.Setenv("PROJECT_ROOT", projectDir) + require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "custom", "static-plugins"), 0o755)) + + cmd := newCreateCmd() + cmd.SetContext(system.WithInteraction(t.Context(), false)) + cmd.SetArgs([]string{"--type", test.extensionType, "--name", "SwagBasicExample"}) + + err := cmd.Execute() + if test.error != "" { + assert.ErrorContains(t, err, test.error) + assert.NoDirExists(t, filepath.Join(projectDir, "custom", "static-plugins", "SwagBasicExample")) + return + } + + require.NoError(t, err) + assert.FileExists(t, filepath.Join( + projectDir, + "custom", + "static-plugins", + "SwagBasicExample", + "composer.json", + )) + }) + } +} + +func TestCreateCommandValidatesNameFlag(t *testing.T) { + cmd := newCreateCmd() + cmd.SetContext(system.WithInteraction(t.Context(), false)) + cmd.SetArgs([]string{"--name", "invalid-name"}) + + err := cmd.Execute() + + assert.ErrorContains(t, err, "invalid extension name") +} + +func TestCreateCommandAcceptsNameFlag(t *testing.T) { + projectDir := t.TempDir() + t.Setenv("PROJECT_ROOT", projectDir) + require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "custom", "static-plugins"), 0o755)) + + cmd := newCreateCmd() + cmd.SetContext(system.WithInteraction(t.Context(), false)) + cmd.SetArgs([]string{"--name", "SwagBasicExample"}) + + require.NoError(t, cmd.Execute()) + assert.FileExists(t, filepath.Join( + projectDir, + "custom", + "static-plugins", + "SwagBasicExample", + "composer.json", + )) +} + +func TestCreateCommandAcceptsPrivateNameWithoutVendorPrefix(t *testing.T) { + projectDir := t.TempDir() + t.Setenv("PROJECT_ROOT", projectDir) + require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "custom", "static-plugins"), 0o755)) + + cmd := newCreateCmd() + cmd.SetContext(system.WithInteraction(t.Context(), false)) + cmd.SetArgs([]string{"--name", "Example"}) + + require.NoError(t, cmd.Execute()) + assert.FileExists(t, filepath.Join( + projectDir, + "custom", + "static-plugins", + "Example", + "composer.json", + )) +} + +func TestCreateCommandRequiresVendorPrefixForStore(t *testing.T) { + cmd := newCreateCmd() + cmd.SetContext(system.WithInteraction(t.Context(), false)) + cmd.SetArgs([]string{"--store", "--name", "Example"}) + + err := cmd.Execute() + + assert.ErrorContains(t, err, "vendor prefix") +} + +func TestCreateCommandRejectsArguments(t *testing.T) { + cmd := newCreateCmd() + cmd.SetContext(system.WithInteraction(t.Context(), false)) + cmd.SetArgs([]string{"SwagBasicExample"}) + + err := cmd.Execute() + + assert.ErrorContains(t, err, "unknown command") +} diff --git a/internal/extension/create.go b/internal/extension/create.go new file mode 100644 index 000000000..2cf8d1d1d --- /dev/null +++ b/internal/extension/create.go @@ -0,0 +1,80 @@ +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 + 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 plugin...") + + projectDir, err := shop.FindClosestShopwareProject(false) + if err != nil { + return err + } + + extensionDir := extensionDirectory(projectDir, opts.Store, opts.Name) + + 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); err != nil { + return fmt.Errorf("create extension files: %w", err) + } + + logger.Info("✓ Extension created") + + if err = validateCreatedExtension(ctx, extensionDir); err != nil { + return fmt.Errorf("validate created extension: %w", err) + } + + logger.Info("✓ Extension validation passed") + logger.Infof("Extension created successfully in %s", extensionDir) + + return nil +} + +func extensionDirectory(projectDir string, store bool, name string) string { + pluginDir := "static-plugins" + if store { + pluginDir = "plugins" + } + + return filepath.Join(projectDir, "custom", pluginDir, name) +} diff --git a/internal/extension/create_installable.go b/internal/extension/create_installable.go new file mode 100644 index 000000000..6b1b47d45 --- /dev/null +++ b/internal/extension/create_installable.go @@ -0,0 +1,220 @@ +package extension + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/shopware/shopware-cli/internal/extension/scaffolding" + "github.com/shopware/shopware-cli/internal/validation" +) + +const shopwarePluginBaseClass = `Shopware\Core\Framework\Plugin` + +var ( + phpNamespaceRegexp = regexp.MustCompile(`(?m)^namespace\s+([^;]+);`) + phpClassRegexp = regexp.MustCompile(`(?m)^(?:final\s+|abstract\s+)?class\s+(\w+)(?:\s+extends\s+([\w\\]+))?`) +) + +// validateCreatedExtension reloads the generated files before validation. This +// checks what was written to disk rather than trusting the template input. +func validateCreatedExtension(ctx context.Context, extensionDir string) error { + ext, err := GetExtensionByFolder(ctx, extensionDir) + if err != nil { + return fmt.Errorf("load extension: %w", err) + } + + plugin, ok := ext.(*PlatformPlugin) + if !ok { + return fmt.Errorf("%s is not a %s", extensionDir, ComposerTypePlugin) + } + + check := &installabilityCheck{} + validatePluginInstallable(plugin, check) + if check.HasErrors() { + return installabilityError{results: check.GetResults()} + } + + return nil +} + +// validatePluginInstallable checks the metadata, autoload mapping, and PHP +// class Shopware needs to discover and install a plugin. Loading the plugin +// above already verifies that composer.json is valid and has the right type. +func validatePluginInstallable(plugin *PlatformPlugin, check validation.Check) { + pluginClass := plugin.Composer.Extra.ShopwarePluginClass + namespace, className := splitPHPClass(pluginClass) + if namespace == "" || className == "" { + addInstallableError(check, "composer.json", "installable.plugin-class", + fmt.Sprintf("extra.shopware-plugin-class must be a full class name like %s, got %q", `Swag\BasicExample\SwagBasicExample`, pluginClass)) + return + } + + if _, err := plugin.GetShopwareVersionConstraint(); err != nil { + addInstallableError(check, "composer.json", "installable.shopware-core", + "require.shopware/core must be a valid version constraint: "+err.Error()) + } + + if plugin.Composer.Extra.Label["en-GB"] == "" { + addInstallableError(check, "composer.json", "installable.label", + "extra.label must contain a label for en-GB") + } + + technicalName := filepath.Base(plugin.GetPath()) + if technicalName != className { + addInstallableError(check, "composer.json", "installable.technical-name", + fmt.Sprintf("extra.shopware-plugin-class must end with the directory name %q, got %q", technicalName, className)) + } + + validatePluginNameDerivation(technicalName, plugin.Composer.Name, namespace, check) + + classFile, found := pluginClassFile(plugin.Composer, namespace, className) + if !found { + addInstallableError(check, "composer.json", "installable.autoload", + fmt.Sprintf(`autoload must map the namespace "%s\\" through psr-4 or psr-0`, namespace)) + return + } + + validatePluginClassFile(plugin.GetPath(), classFile, namespace, className, check) +} + +func validatePluginNameDerivation(technicalName, composerName, namespace string, check validation.Check) { + if expected := scaffolding.DeriveComposerName(technicalName); composerName != expected { + addInstallableError(check, "composer.json", "installable.composer-name", + fmt.Sprintf("name must be %q for plugin %s, got %q", expected, technicalName, composerName)) + } + + if expected := scaffolding.DeriveNamespace(technicalName); namespace != expected { + addInstallableError(check, "composer.json", "installable.namespace", + fmt.Sprintf("plugin namespace must be %q for plugin %s, got %q", expected, technicalName, namespace)) + } +} + +func validatePluginClassFile(extensionDir, classFile, namespace, className string, check validation.Check) { + content, err := os.ReadFile(filepath.Join(extensionDir, classFile)) + if err != nil { + addInstallableError(check, classFile, "installable.plugin-class-file", + "plugin class file could not be read: "+err.Error()) + return + } + + php := string(content) + if declared := firstSubmatch(phpNamespaceRegexp, php); declared != namespace { + addInstallableError(check, classFile, "installable.plugin-class-namespace", + fmt.Sprintf("file must declare namespace %q, got %q", namespace, declared)) + } + + class := phpClassRegexp.FindStringSubmatch(php) + if class == nil { + addInstallableError(check, classFile, "installable.plugin-class-file", + "file must declare class "+className) + return + } + + if class[1] != className { + addInstallableError(check, classFile, "installable.plugin-class-file", + fmt.Sprintf("file must declare class %q, got %q", className, class[1])) + } + + if !extendsShopwarePlugin(class[2], php) { + addInstallableError(check, classFile, "installable.plugin-base-class", + fmt.Sprintf("class %s must extend %s", class[1], shopwarePluginBaseClass)) + } +} + +// pluginClassFile resolves the class file using the mappings generated in +// composer.json. PSR-4 strips the namespace prefix; PSR-0 keeps the namespace +// as directories below its mapped path. +func pluginClassFile(composer PlatformComposerJson, namespace, className string) (string, bool) { + prefix := namespace + `\` + fileName := className + ".php" + + if dir, ok := composer.Autoload.Psr4[prefix]; ok { + return filepath.Join(dir, fileName), true + } + if dir, ok := composer.Autoload.Psr0[prefix]; ok { + namespacePath := filepath.Join(strings.Split(namespace, `\`)...) + return filepath.Join(dir, namespacePath, fileName), true + } + + return "", false +} + +func splitPHPClass(fullClassName string) (namespace, className string) { + parts := strings.Split(strings.TrimPrefix(fullClassName, `\`), `\`) + if len(parts) < 2 || slices.Contains(parts, "") { + return "", "" + } + + return strings.Join(parts[:len(parts)-1], `\`), parts[len(parts)-1] +} + +func extendsShopwarePlugin(parent, php string) bool { + parent = strings.TrimPrefix(parent, `\`) + if parent == shopwarePluginBaseClass { + return true + } + + return parent == "Plugin" && strings.Contains(php, "use "+shopwarePluginBaseClass+";") +} + +func firstSubmatch(pattern *regexp.Regexp, content string) string { + match := pattern.FindStringSubmatch(content) + if match == nil { + return "" + } + + return strings.TrimSpace(match[1]) +} + +func addInstallableError(check validation.Check, path, identifier, message string) { + check.AddResult(validation.CheckResult{ + Path: path, + Identifier: identifier, + Message: message, + Severity: validation.SeverityError, + }) +} + +type installabilityCheck struct { + results []validation.CheckResult +} + +func (c *installabilityCheck) AddResult(result validation.CheckResult) { + c.results = append(c.results, result) +} + +func (c *installabilityCheck) GetResults() []validation.CheckResult { + return c.results +} + +func (c *installabilityCheck) HasErrors() bool { + for _, result := range c.results { + if result.Severity == validation.SeverityError { + return true + } + } + + return false +} + +func (c *installabilityCheck) RemoveByIdentifier([]validation.ToolConfigIgnore) validation.Check { + return c +} + +type installabilityError struct { + results []validation.CheckResult +} + +func (e installabilityError) Error() string { + messages := make([]string, 0, len(e.results)) + for _, result := range e.results { + messages = append(messages, fmt.Sprintf("%s [%s]: %s", result.Path, result.Identifier, result.Message)) + } + + return strings.Join(messages, "; ") +} diff --git a/internal/extension/create_test.go b/internal/extension/create_test.go new file mode 100644 index 000000000..f8a1e490b --- /dev/null +++ b/internal/extension/create_test.go @@ -0,0 +1,371 @@ +package extension + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shopware/shopware-cli/internal/extension/scaffolding" + "github.com/shopware/shopware-cli/internal/system" + "github.com/shopware/shopware-cli/internal/validation" +) + +func TestValidateExtensionName(t *testing.T) { + t.Parallel() + + for _, valid := range []string{"SwagBasicExample", "MyPlugin", "AcmePayPal", "Swag2Example", "Example"} { + assert.NoError(t, ValidateName(valid, false), valid) + } + + for _, invalid := range []string{ + "", "swagBasicExample", "my-plugin", "My_Plugin", + "My Plugin", "1Plugin", "Swag.Example", + } { + assert.Error(t, ValidateName(invalid, false), invalid) + } + + assert.NoError(t, ValidateName("SwagBasicExample", true)) + assert.Error(t, ValidateName("Example", true)) + assert.Error(t, ValidateName("Swag", true)) +} + +func TestCreate(t *testing.T) { + for _, store := range []bool{false, true} { + t.Run(fmt.Sprintf("store=%t", store), func(t *testing.T) { + projectDir := prepareProject(t) + opts := validCreateOptions() + opts.Store = store + + require.NoError(t, Create(system.WithInteraction(t.Context(), false), opts)) + + extensionDir := extensionDirectory(projectDir, opts.Store, opts.Name) + assert.FileExists(t, filepath.Join(extensionDir, "composer.json")) + assert.FileExists(t, filepath.Join(extensionDir, "src", opts.Name+".php")) + require.NoError(t, validateCreatedExtension(t.Context(), extensionDir)) + }) + } +} + +func TestCreateFindsClosestProject(t *testing.T) { + t.Setenv("PROJECT_ROOT", "") + projectDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "bin"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "bin", "console"), nil, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(projectDir, "composer.json"), + []byte(`{"require":{"shopware/core":"~6.7.0"}}`), + 0o644, + )) + require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "custom", "static-plugins"), 0o755)) + nestedDir := filepath.Join(projectDir, "custom") + t.Chdir(nestedDir) + + opts := validCreateOptions() + require.NoError(t, Create(system.WithInteraction(t.Context(), false), opts)) + + assert.FileExists(t, filepath.Join(extensionDirectory(projectDir, opts.Store, opts.Name), "composer.json")) +} + +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 TestCreateReportsMissingExtensionParent(t *testing.T) { + projectDir := t.TempDir() + t.Setenv("PROJECT_ROOT", projectDir) + opts := validCreateOptions() + + err := Create(system.WithInteraction(t.Context(), false), opts) + + assert.ErrorContains(t, err, "extension parent directory does not exist") + assert.NoDirExists(t, extensionDirectory(projectDir, opts.Store, opts.Name)) +} + +func TestCreateDoesNotRemoveExistingDirectory(t *testing.T) { + projectDir := prepareProject(t) + opts := validCreateOptions() + extensionDir := extensionDirectory(projectDir, opts.Store, opts.Name) + require.NoError(t, os.Mkdir(extensionDir, 0o755)) + marker := filepath.Join(extensionDir, "keep.txt") + require.NoError(t, os.WriteFile(marker, []byte("keep"), 0o644)) + + err := Create(system.WithInteraction(t.Context(), false), opts) + + assert.ErrorContains(t, err, "already exists") + assert.FileExists(t, marker) +} + +func TestValidateCreatedExtensionReportsDetails(t *testing.T) { + extensionDir := scaffoldPlugin(t) + mutateComposer(t, extensionDir, func(composer *PlatformComposerJson) { + composer.Extra.Label["en-GB"] = "" + }) + + err := validateCreatedExtension(t.Context(), extensionDir) + + assert.ErrorContains(t, err, "installable.label") + assert.ErrorContains(t, err, "extra.label") +} + +func TestValidatePluginInstallable(t *testing.T) { + extensionDir := scaffoldPlugin(t) + check := &testCheck{} + + validatePluginInstallable(loadPlugin(t, extensionDir), check) + + assert.Empty(t, check.GetResults()) +} + +func TestValidatePluginInstallableReportsBrokenPlugin(t *testing.T) { + tests := []struct { + name string + breakPlugin func(*testing.T, string) + identifiers []string + }{ + { + name: "plugin class is missing", + breakPlugin: func(t *testing.T, dir string) { + t.Helper() + mutateComposer(t, dir, func(c *PlatformComposerJson) { c.Extra.ShopwarePluginClass = "" }) + }, + identifiers: []string{"installable.plugin-class"}, + }, + { + name: "shopware core requirement is missing", + breakPlugin: func(t *testing.T, dir string) { + t.Helper() + mutateComposer(t, dir, func(c *PlatformComposerJson) { delete(c.Require, "shopware/core") }) + }, + identifiers: []string{"installable.shopware-core"}, + }, + { + name: "shopware core constraint is invalid", + breakPlugin: func(t *testing.T, dir string) { + t.Helper() + mutateComposer(t, dir, func(c *PlatformComposerJson) { c.Require["shopware/core"] = "not a version" }) + }, + identifiers: []string{"installable.shopware-core"}, + }, + { + name: "English label is empty", + breakPlugin: func(t *testing.T, dir string) { + t.Helper() + mutateComposer(t, dir, func(c *PlatformComposerJson) { c.Extra.Label["en-GB"] = "" }) + }, + identifiers: []string{"installable.label"}, + }, + { + name: "technical name differs", + breakPlugin: func(t *testing.T, dir string) { + t.Helper() + mutateComposer(t, dir, func(c *PlatformComposerJson) { + c.Extra.ShopwarePluginClass = `Swag\BasicExample\SwagOtherExample` + }) + }, + identifiers: []string{"installable.technical-name", "installable.plugin-class-file"}, + }, + { + name: "composer name differs", + breakPlugin: func(t *testing.T, dir string) { + t.Helper() + mutateComposer(t, dir, func(c *PlatformComposerJson) { c.Name = "swag/basic_example" }) + }, + identifiers: []string{"installable.composer-name"}, + }, + { + name: "namespace differs", + breakPlugin: func(t *testing.T, dir string) { + t.Helper() + mutateComposer(t, dir, func(c *PlatformComposerJson) { + c.Extra.ShopwarePluginClass = `Swag\OtherExample\SwagBasicExample` + c.Autoload.Psr4 = map[string]string{`Swag\OtherExample\`: "src/"} + }) + }, + identifiers: []string{"installable.namespace", "installable.plugin-class-namespace"}, + }, + { + name: "namespace is not autoloaded", + breakPlugin: func(t *testing.T, dir string) { + t.Helper() + mutateComposer(t, dir, func(c *PlatformComposerJson) { + c.Autoload.Psr4 = map[string]string{`Swag\WrongExample\`: "src/"} + }) + }, + identifiers: []string{"installable.autoload"}, + }, + { + name: "class file is missing", + breakPlugin: func(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.Remove(filepath.Join(dir, "src", "SwagBasicExample.php"))) + }, + identifiers: []string{"installable.plugin-class-file"}, + }, + { + name: "class file namespace differs", + breakPlugin: func(t *testing.T, dir string) { + t.Helper() + replaceInFile(t, filepath.Join(dir, "src", "SwagBasicExample.php"), + `namespace Swag\BasicExample;`, `namespace Swag\WrongExample;`) + }, + identifiers: []string{"installable.plugin-class-namespace"}, + }, + { + name: "class name differs", + breakPlugin: func(t *testing.T, dir string) { + t.Helper() + replaceInFile(t, filepath.Join(dir, "src", "SwagBasicExample.php"), + "class SwagBasicExample extends Plugin", "class OtherClass extends Plugin") + }, + identifiers: []string{"installable.plugin-class-file"}, + }, + { + name: "base class is missing", + breakPlugin: func(t *testing.T, dir string) { + t.Helper() + replaceInFile(t, filepath.Join(dir, "src", "SwagBasicExample.php"), + "class SwagBasicExample extends Plugin", "class SwagBasicExample") + }, + identifiers: []string{"installable.plugin-base-class"}, + }, + { + name: "multiple metadata errors", + breakPlugin: func(t *testing.T, dir string) { + t.Helper() + mutateComposer(t, dir, func(c *PlatformComposerJson) { + delete(c.Require, "shopware/core") + c.Extra.Label["en-GB"] = "" + c.Name = "wrong/name" + }) + }, + identifiers: []string{ + "installable.shopware-core", + "installable.label", + "installable.composer-name", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + extensionDir := scaffoldPlugin(t) + test.breakPlugin(t, extensionDir) + check := &testCheck{} + + validatePluginInstallable(loadPlugin(t, extensionDir), check) + + assert.ElementsMatch(t, test.identifiers, resultIdentifiers(check.GetResults())) + }) + } +} + +func TestPluginClassFileSupportsPSR0(t *testing.T) { + extensionDir := scaffoldPlugin(t) + oldClassFile := filepath.Join(extensionDir, "src", "SwagBasicExample.php") + newClassFile := filepath.Join(extensionDir, "src", "Swag", "BasicExample", "SwagBasicExample.php") + require.NoError(t, os.MkdirAll(filepath.Dir(newClassFile), 0o755)) + require.NoError(t, os.Rename(oldClassFile, newClassFile)) + mutateComposer(t, extensionDir, func(c *PlatformComposerJson) { + c.Autoload.Psr4 = nil + c.Autoload.Psr0 = map[string]string{`Swag\BasicExample\`: "src/"} + }) + check := &testCheck{} + + validatePluginInstallable(loadPlugin(t, extensionDir), check) + + assert.Empty(t, check.GetResults()) +} + +func TestPluginClassMayExtendFullyQualifiedBaseClass(t *testing.T) { + extensionDir := scaffoldPlugin(t) + replaceInFile(t, filepath.Join(extensionDir, "src", "SwagBasicExample.php"), + "class SwagBasicExample extends Plugin", + `class SwagBasicExample extends \Shopware\Core\Framework\Plugin`) + check := &testCheck{} + + validatePluginInstallable(loadPlugin(t, extensionDir), check) + + assert.Empty(t, check.GetResults()) +} + +func validCreateOptions() CreateOptions { + return CreateOptions{ + Name: "SwagBasicExample", + Type: Plugin, + } +} + +func prepareProject(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 +} + +func scaffoldPlugin(t *testing.T) string { + t.Helper() + + const name = "SwagBasicExample" + extensionDir := filepath.Join(t.TempDir(), name) + require.NoError(t, os.Mkdir(extensionDir, 0o755)) + require.NoError(t, scaffolding.CreateExtensionFiles(extensionDir, name)) + return extensionDir +} + +func loadPlugin(t *testing.T, extensionDir string) *PlatformPlugin { + t.Helper() + + ext, err := GetExtensionByFolder(t.Context(), extensionDir) + require.NoError(t, err) + plugin, ok := ext.(*PlatformPlugin) + require.True(t, ok, "%s is not a platform plugin", extensionDir) + return plugin +} + +func mutateComposer(t *testing.T, extensionDir string, mutate func(*PlatformComposerJson)) { + t.Helper() + + path := filepath.Join(extensionDir, "composer.json") + content, err := os.ReadFile(path) + require.NoError(t, err) + + var composer PlatformComposerJson + require.NoError(t, json.Unmarshal(content, &composer)) + mutate(&composer) + + content, err = json.MarshalIndent(composer, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(content, '\n'), 0o644)) +} + +func replaceInFile(t *testing.T, path, old, replacement string) { + t.Helper() + + content, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(content), old) + updated := strings.Replace(string(content), old, replacement, 1) + require.NoError(t, os.WriteFile(path, []byte(updated), 0o644)) +} + +func resultIdentifiers(results []validation.CheckResult) []string { + identifiers := make([]string, 0, len(results)) + for _, result := range results { + identifiers = append(identifiers, result.Identifier) + } + return identifiers +} diff --git a/internal/extension/create_validate.go b/internal/extension/create_validate.go new file mode 100644 index 000000000..98d25eca3 --- /dev/null +++ b/internal/extension/create_validate.go @@ -0,0 +1,40 @@ +package extension + +import ( + "errors" + "fmt" + "regexp" +) + +// Shopware technical names use UpperCamelCase. Community Store plugins also +// need a vendor prefix, for example SwagBasicExample. +var ( + extensionNameRegexp = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*$`) + storeExtensionNameRegexp = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*$`) +) + +func ValidateName(name string, store bool) error { + if name == "" { + return errors.New("extension name must not be empty") + } + if store { + if !storeExtensionNameRegexp.MatchString(name) { + return fmt.Errorf("invalid extension name %q: Community Store extensions need UpperCamelCase with a vendor prefix, letters and digits only (for example SwagBasicExample)", name) + } + return nil + } + if !extensionNameRegexp.MatchString(name) { + return fmt.Errorf("invalid extension name %q: use UpperCamelCase, letters and digits only (for example Example or SwagBasicExample)", name) + } + + return nil +} + +func ValidateType(extensionType ExtensionType) error { + switch extensionType { + case Plugin, Theme: + return nil + default: + return fmt.Errorf("invalid extension type %q", extensionType) + } +} diff --git a/internal/extension/scaffolding/scaffolding.go b/internal/extension/scaffolding/scaffolding.go new file mode 100644 index 000000000..79ba13c70 --- /dev/null +++ b/internal/extension/scaffolding/scaffolding.go @@ -0,0 +1,272 @@ +package scaffolding + +import ( + "embed" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + "unicode" +) + +const ( + privatePluginRoot = "custom/static-plugins" + storePluginRoot = "custom/plugins" +) + +//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, e.g. the + // backslashes of a PHP namespace: Swag\Example -> Swag\\Example. + "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 + }, +} + +type scaffoldingFile struct { + Path string + StubPath string +} + +// scaffoldingFiles returns a list of files with their paths and corresponding stub paths. +func scaffoldingFiles(extensionName 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", extensionName+".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 string) error { + data := createScaffoldingData(extensionName) + for _, file := range scaffoldingFiles(extensionName) { + 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.Create(dest) + 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(extensionName string) scaffoldData { + return scaffoldData{ + Namespace: DeriveNamespace(extensionName), + ClassName: extensionName, + ComposerName: DeriveComposerName(extensionName), + } +} + +// DeriveNamespace turns a technical plugin name into a PHP namespace. +// The first PascalCase word is the vendor prefix, the rest stay one segment: +// SwagBasicExample → Swag\BasicExample. +func DeriveNamespace(extensionName string) string { + parts := splitPascalCase(extensionName) + if len(parts) < 2 { + return extensionName + } + + return parts[0] + "\\" + strings.Join(parts[1:], "") +} + +// DeriveComposerName turns a technical plugin name into a Composer package name: +// SwagBasicExample → swag/basic-example. +func DeriveComposerName(extensionName string) string { + parts := splitPascalCase(extensionName) + if len(parts) == 0 { + return "" + } + + vendor := strings.ToLower(parts[0]) + if len(parts) == 1 { + return vendor + "/" + vendor + } + + return vendor + "/" + strings.ToLower(strings.Join(parts[1:], "-")) +} + +// 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 { + // 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) + } + + // Delete the folder and everything inside it. + if err := os.RemoveAll(abs); err != nil { + return fmt.Errorf("remove extension directory: %w", err) + } + + return nil +} diff --git a/internal/extension/scaffolding/scaffolding_test.go b/internal/extension/scaffolding/scaffolding_test.go new file mode 100644 index 000000000..a3d8a74a2 --- /dev/null +++ b/internal/extension/scaffolding/scaffolding_test.go @@ -0,0 +1,146 @@ +package scaffolding + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNameDerivation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + parts []string + namespace string + composerName string + }{ + { + name: "SwagBasicExample", + parts: []string{"Swag", "Basic", "Example"}, + namespace: `Swag\BasicExample`, + composerName: "swag/basic-example", + }, + { + name: "AcmePayPal", + parts: []string{"Acme", "Pay", "Pal"}, + namespace: `Acme\PayPal`, + composerName: "acme/pay-pal", + }, + { + name: "Swag2Example", + parts: []string{"Swag2", "Example"}, + namespace: `Swag2\Example`, + composerName: "swag2/example", + }, + { + name: "", + parts: nil, + namespace: "", + composerName: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, test.parts, splitPascalCase(test.name)) + assert.Equal(t, test.namespace, DeriveNamespace(test.name)) + assert.Equal(t, test.composerName, DeriveComposerName(test.name)) + }) + } +} + +func TestCreateScaffoldingFiles(t *testing.T) { + extensionDir := t.TempDir() + + require.NoError(t, CreateExtensionFiles(extensionDir, "SwagBasicExample")) + + expectedFiles := []string{ + ".gitignore", + "composer.json", + "phpunit.xml", + filepath.Join("src", "SwagBasicExample.php"), + filepath.Join("src", "Resources", "config", "config.xml"), + filepath.Join("tests", "TestBootstrap.php"), + } + for _, file := range expectedFiles { + assert.FileExists(t, filepath.Join(extensionDir, file)) + } + + content, err := os.ReadFile(filepath.Join(extensionDir, "composer.json")) + require.NoError(t, err) + var composer struct { + Name string `json:"name"` + Extra struct { + PluginClass string `json:"shopware-plugin-class"` + } `json:"extra"` + } + require.NoError(t, json.Unmarshal(content, &composer)) + assert.Equal(t, "swag/basic-example", composer.Name) + assert.Equal(t, `Swag\BasicExample\SwagBasicExample`, composer.Extra.PluginClass) + + pluginClass, err := os.ReadFile(filepath.Join(extensionDir, "src", "SwagBasicExample.php")) + require.NoError(t, err) + assert.Contains(t, string(pluginClass), `namespace Swag\BasicExample;`) + assert.Contains(t, string(pluginClass), "class SwagBasicExample extends Plugin") +} + +func TestCreateExtensionDir(t *testing.T) { + parent := t.TempDir() + extensionDir := filepath.Join(parent, "SwagBasicExample") + + require.NoError(t, CreateExtensionDir(extensionDir)) + assert.DirExists(t, extensionDir) + assert.ErrorContains(t, CreateExtensionDir(extensionDir), "already exists") + + filePath := filepath.Join(parent, "file") + require.NoError(t, os.WriteFile(filePath, nil, 0o644)) + assert.ErrorContains(t, CreateExtensionDir(filePath), "not a directory") + + missingParent := filepath.Join(parent, "missing", "Extension") + assert.ErrorContains(t, CreateExtensionDir(missingParent), "parent directory does not exist") + assert.NoDirExists(t, filepath.Dir(missingParent)) +} + +func TestRemoveCreatedExtensionDir(t *testing.T) { + for _, pluginRoot := range []string{"plugins", "static-plugins"} { + t.Run(pluginRoot, func(t *testing.T) { + extensionDir := filepath.Join(t.TempDir(), "custom", pluginRoot, "SwagBasicExample") + require.NoError(t, os.MkdirAll(extensionDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(extensionDir, "file.txt"), nil, 0o644)) + + require.NoError(t, RemoveCreatedExtensionDir(extensionDir)) + assert.NoDirExists(t, extensionDir) + // Removing an already absent extension is safe and idempotent. + require.NoError(t, RemoveCreatedExtensionDir(extensionDir)) + }) + } +} + +func TestRemoveCreatedExtensionDirRejectsUnsafePaths(t *testing.T) { + root := t.TempDir() + pluginRoot := filepath.Join(root, "custom", "plugins") + require.NoError(t, os.MkdirAll(pluginRoot, 0o755)) + + ordinaryDir := filepath.Join(root, "ordinary", "SwagBasicExample") + require.NoError(t, os.MkdirAll(ordinaryDir, 0o755)) + assert.ErrorContains(t, RemoveCreatedExtensionDir(ordinaryDir), "not an extension directory") + assert.DirExists(t, ordinaryDir) + + assert.Error(t, RemoveCreatedExtensionDir("")) + assert.Error(t, RemoveCreatedExtensionDir(string(filepath.Separator))) + assert.ErrorContains(t, RemoveCreatedExtensionDir(pluginRoot), "not an extension directory") + assert.DirExists(t, pluginRoot) + + target := filepath.Join(pluginRoot, "Target") + link := filepath.Join(pluginRoot, "Link") + require.NoError(t, os.Mkdir(target, 0o755)) + require.NoError(t, os.Symlink(target, link)) + assert.ErrorContains(t, RemoveCreatedExtensionDir(link), "symlink") + assert.DirExists(t, target) +} 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..78f75f314 --- /dev/null +++ b/internal/extension/scaffolding/stubs/test_bootstrap.php.tmpl @@ -0,0 +1,12 @@ +addCallingPlugin() + ->addActivePlugins('{{ .ClassName }}') + ->setForceInstallPlugins(true) + ->bootstrap() + ->getClassLoader(); + +$loader->addPsr4('{{ .Namespace }}\\Tests\\', __DIR__); From 786397fd8f3e14286331ea28bf03b13f8cc95e23 Mon Sep 17 00:00:00 2001 From: Anne Hintzpeter Date: Sun, 13 Sep 2026 16:58:00 +0200 Subject: [PATCH 2/5] feat: add extension create command --- cmd/extension/extension_create.go | 129 +++++-- cmd/extension/extension_create_form.go | 52 ++- cmd/extension/extension_create_test.go | 177 ++------- internal/extension/create.go | 34 +- internal/extension/create_installable.go | 220 ----------- internal/extension/create_test.go | 362 +++--------------- internal/extension/create_validate.go | 31 +- internal/extension/scaffolding/scaffolding.go | 90 +++-- .../extension/scaffolding/scaffolding_test.go | 301 ++++++++++----- .../scaffolding/stubs/test_bootstrap.php.tmpl | 2 +- 10 files changed, 515 insertions(+), 883 deletions(-) delete mode 100644 internal/extension/create_installable.go diff --git a/cmd/extension/extension_create.go b/cmd/extension/extension_create.go index e8edb5706..c8fa4be68 100644 --- a/cmd/extension/extension_create.go +++ b/cmd/extension/extension_create.go @@ -5,50 +5,74 @@ import ( "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 { - return validateInput(opts) + // 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 { - needsName := opts.Name == "" - needsStore := !cmd.Flags().Changed("store") - - if needsName { - if !system.IsInteractionEnabled(cmd.Context()) { - return errors.New("extension name is required when interaction is disabled") - } + shouldRunForm := missing(isProvided, opts.Store) && system.IsInteractionEnabled(cmd.Context()) - if err := runInteractiveCreateForm(opts, needsName, needsStore); err != nil { + if shouldRunForm { + if err := runInteractiveCreateFormWithValidation(opts, isProvided); err != nil { return fmt.Errorf("running create form: %w", err) } } - if err := extension.ValidateName(opts.Name, opts.Store); err != nil { - return err - } - return extension.Create(cmd.Context(), *opts) }, } flags := cmd.Flags() - flags.StringVar(&opts.Name, "name", "", "Extension name (PascalCase)") - flags.BoolVar(&opts.Store, "store", false, "Planning commercial use in Shopware Community Store") - flags.StringVarP((*string)(&opts.Type), "type", "t", string(extension.Plugin), "Extension type (plugin|theme)") - - _ = flags.MarkHidden("type") // Since "theme" is not implemented yet, this flag is hidden from the user + 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)}, @@ -58,17 +82,70 @@ func newCreateCmd() *cobra.Command { return cmd } -func validateInput(opts *extension.CreateOptions) error { - if err := extension.ValidateType(opts.Type); err != nil { - return err +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) } - if opts.Name == "" { - return nil + + for _, flagName := range required { + if !isProvided[flagName] { + return true + } } - return extension.ValidateName(opts.Name, opts.Store) + return false } -func init() { - extensionRootCmd.AddCommand(newCreateCmd()) +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 index e5df01032..113ba152e 100644 --- a/cmd/extension/extension_create_form.go +++ b/cmd/extension/extension_create_form.go @@ -7,14 +7,37 @@ import ( "github.com/shopware/shopware-cli/internal/tui" ) -func runInteractiveCreateForm(opts *extension.CreateOptions, needsName bool, needsStore bool) error { +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 needsStore { + 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](). @@ -29,17 +52,30 @@ func runInteractiveCreateForm(opts *extension.CreateOptions, needsName bool, nee ) } - if needsName { + 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("SwagBasicExample"). + Placeholder("BasicExample"). Value(&opts.Name). - Validate(func(name string) error { - return extension.ValidateName(name, opts.Store) - }), + Validate(extension.ValidateName), ), ) } @@ -48,5 +84,5 @@ func runInteractiveCreateForm(opts *extension.CreateOptions, needsName bool, nee return nil } - return huh.NewForm(groups...).Run() + return huh.NewForm(groups...).WithTheme(theme).Run() } diff --git a/cmd/extension/extension_create_test.go b/cmd/extension/extension_create_test.go index 0033df1d9..ce1d10051 100644 --- a/cmd/extension/extension_create_test.go +++ b/cmd/extension/extension_create_test.go @@ -1,165 +1,44 @@ package extension import ( - "os" - "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - internalextension "github.com/shopware/shopware-cli/internal/extension" - "github.com/shopware/shopware-cli/internal/system" ) -func TestCreateCommandDefaults(t *testing.T) { - cmd := newCreateCmd() - - extensionType, err := cmd.Flags().GetString("type") - require.NoError(t, err) - assert.Equal(t, string(internalextension.Plugin), extensionType) - - store, err := cmd.Flags().GetBool("store") - require.NoError(t, err) - assert.False(t, store) -} - -func TestCreateCommandMapsFlags(t *testing.T) { - projectDir := t.TempDir() - t.Setenv("PROJECT_ROOT", projectDir) - require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "custom", "plugins"), 0o755)) +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) - cmd := newCreateCmd() - cmd.SetContext(system.WithInteraction(t.Context(), false)) - cmd.SetArgs([]string{ - "--store", - "--name", "SwagBasicExample", + require.Error(t, err) + assert.ErrorContains(t, err, "--name") }) - require.NoError(t, cmd.Execute()) - assert.FileExists(t, filepath.Join( - projectDir, - "custom", - "plugins", - "SwagBasicExample", - "composer.json", - )) -} - -func TestCreateCommandValidatesTypeFlag(t *testing.T) { - tests := []struct { - name string - extensionType string - error string - }{ - { - name: "plugin", - extensionType: "plugin", - }, - { - name: "theme", - extensionType: "theme", - }, - { - name: "rejected", - extensionType: "unknown", - error: `invalid extension type "unknown"`, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - projectDir := t.TempDir() - t.Setenv("PROJECT_ROOT", projectDir) - require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "custom", "static-plugins"), 0o755)) - - cmd := newCreateCmd() - cmd.SetContext(system.WithInteraction(t.Context(), false)) - cmd.SetArgs([]string{"--type", test.extensionType, "--name", "SwagBasicExample"}) - - err := cmd.Execute() - if test.error != "" { - assert.ErrorContains(t, err, test.error) - assert.NoDirExists(t, filepath.Join(projectDir, "custom", "static-plugins", "SwagBasicExample")) - return - } - - require.NoError(t, err) - assert.FileExists(t, filepath.Join( - projectDir, - "custom", - "static-plugins", - "SwagBasicExample", - "composer.json", - )) - }) - } -} - -func TestCreateCommandValidatesNameFlag(t *testing.T) { - cmd := newCreateCmd() - cmd.SetContext(system.WithInteraction(t.Context(), false)) - cmd.SetArgs([]string{"--name", "invalid-name"}) - - err := cmd.Execute() - - assert.ErrorContains(t, err, "invalid extension 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) -func TestCreateCommandAcceptsNameFlag(t *testing.T) { - projectDir := t.TempDir() - t.Setenv("PROJECT_ROOT", projectDir) - require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "custom", "static-plugins"), 0o755)) - - cmd := newCreateCmd() - cmd.SetContext(system.WithInteraction(t.Context(), false)) - cmd.SetArgs([]string{"--name", "SwagBasicExample"}) - - require.NoError(t, cmd.Execute()) - assert.FileExists(t, filepath.Join( - projectDir, - "custom", - "static-plugins", - "SwagBasicExample", - "composer.json", - )) -} - -func TestCreateCommandAcceptsPrivateNameWithoutVendorPrefix(t *testing.T) { - projectDir := t.TempDir() - t.Setenv("PROJECT_ROOT", projectDir) - require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "custom", "static-plugins"), 0o755)) - - cmd := newCreateCmd() - cmd.SetContext(system.WithInteraction(t.Context(), false)) - cmd.SetArgs([]string{"--name", "Example"}) - - require.NoError(t, cmd.Execute()) - assert.FileExists(t, filepath.Join( - projectDir, - "custom", - "static-plugins", - "Example", - "composer.json", - )) -} - -func TestCreateCommandRequiresVendorPrefixForStore(t *testing.T) { - cmd := newCreateCmd() - cmd.SetContext(system.WithInteraction(t.Context(), false)) - cmd.SetArgs([]string{"--store", "--name", "Example"}) - - err := cmd.Execute() - - assert.ErrorContains(t, err, "vendor prefix") -} - -func TestCreateCommandRejectsArguments(t *testing.T) { - cmd := newCreateCmd() - cmd.SetContext(system.WithInteraction(t.Context(), false)) - cmd.SetArgs([]string{"SwagBasicExample"}) + require.Error(t, err) + assert.ErrorContains(t, err, "--vendor") + }) - err := cmd.Execute() + 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)) + }) - assert.ErrorContains(t, err, "unknown command") + 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 index 2cf8d1d1d..4503a5c96 100644 --- a/internal/extension/create.go +++ b/internal/extension/create.go @@ -20,23 +20,25 @@ const ( // CreateOptions contains the choices used to create extension scaffolding. type CreateOptions struct { - Name string - Type ExtensionType - Store bool + 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 plugin...") + logger.Info("Creating extension...") projectDir, err := shop.FindClosestShopwareProject(false) if err != nil { return err } - extensionDir := extensionDirectory(projectDir, opts.Store, opts.Name) + technicalName := deriveTechnicalName(opts.Name, opts.Vendor) + extensionDir := deriveExtensionDirectoryName(projectDir, opts.Store, technicalName) err = scaffolding.CreateExtensionDir(extensionDir) if err != nil { @@ -54,27 +56,27 @@ func Create(ctx context.Context, opts CreateOptions) (err error) { } }() - if err = scaffolding.CreateExtensionFiles(extensionDir, opts.Name); err != nil { + if err = scaffolding.CreateExtensionFiles(extensionDir, opts.Name, opts.Vendor); err != nil { return fmt.Errorf("create extension files: %w", err) } - logger.Info("✓ Extension created") - - if err = validateCreatedExtension(ctx, extensionDir); err != nil { - return fmt.Errorf("validate created extension: %w", err) - } - - logger.Info("✓ Extension validation passed") - logger.Infof("Extension created successfully in %s", extensionDir) + logger.Infof("✓ Extension successfully created in %s", extensionDir) return nil } -func extensionDirectory(projectDir string, store bool, name string) string { +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, name) + return filepath.Join(projectDir, "custom", pluginDir, technicalName) } diff --git a/internal/extension/create_installable.go b/internal/extension/create_installable.go deleted file mode 100644 index 6b1b47d45..000000000 --- a/internal/extension/create_installable.go +++ /dev/null @@ -1,220 +0,0 @@ -package extension - -import ( - "context" - "fmt" - "os" - "path/filepath" - "regexp" - "slices" - "strings" - - "github.com/shopware/shopware-cli/internal/extension/scaffolding" - "github.com/shopware/shopware-cli/internal/validation" -) - -const shopwarePluginBaseClass = `Shopware\Core\Framework\Plugin` - -var ( - phpNamespaceRegexp = regexp.MustCompile(`(?m)^namespace\s+([^;]+);`) - phpClassRegexp = regexp.MustCompile(`(?m)^(?:final\s+|abstract\s+)?class\s+(\w+)(?:\s+extends\s+([\w\\]+))?`) -) - -// validateCreatedExtension reloads the generated files before validation. This -// checks what was written to disk rather than trusting the template input. -func validateCreatedExtension(ctx context.Context, extensionDir string) error { - ext, err := GetExtensionByFolder(ctx, extensionDir) - if err != nil { - return fmt.Errorf("load extension: %w", err) - } - - plugin, ok := ext.(*PlatformPlugin) - if !ok { - return fmt.Errorf("%s is not a %s", extensionDir, ComposerTypePlugin) - } - - check := &installabilityCheck{} - validatePluginInstallable(plugin, check) - if check.HasErrors() { - return installabilityError{results: check.GetResults()} - } - - return nil -} - -// validatePluginInstallable checks the metadata, autoload mapping, and PHP -// class Shopware needs to discover and install a plugin. Loading the plugin -// above already verifies that composer.json is valid and has the right type. -func validatePluginInstallable(plugin *PlatformPlugin, check validation.Check) { - pluginClass := plugin.Composer.Extra.ShopwarePluginClass - namespace, className := splitPHPClass(pluginClass) - if namespace == "" || className == "" { - addInstallableError(check, "composer.json", "installable.plugin-class", - fmt.Sprintf("extra.shopware-plugin-class must be a full class name like %s, got %q", `Swag\BasicExample\SwagBasicExample`, pluginClass)) - return - } - - if _, err := plugin.GetShopwareVersionConstraint(); err != nil { - addInstallableError(check, "composer.json", "installable.shopware-core", - "require.shopware/core must be a valid version constraint: "+err.Error()) - } - - if plugin.Composer.Extra.Label["en-GB"] == "" { - addInstallableError(check, "composer.json", "installable.label", - "extra.label must contain a label for en-GB") - } - - technicalName := filepath.Base(plugin.GetPath()) - if technicalName != className { - addInstallableError(check, "composer.json", "installable.technical-name", - fmt.Sprintf("extra.shopware-plugin-class must end with the directory name %q, got %q", technicalName, className)) - } - - validatePluginNameDerivation(technicalName, plugin.Composer.Name, namespace, check) - - classFile, found := pluginClassFile(plugin.Composer, namespace, className) - if !found { - addInstallableError(check, "composer.json", "installable.autoload", - fmt.Sprintf(`autoload must map the namespace "%s\\" through psr-4 or psr-0`, namespace)) - return - } - - validatePluginClassFile(plugin.GetPath(), classFile, namespace, className, check) -} - -func validatePluginNameDerivation(technicalName, composerName, namespace string, check validation.Check) { - if expected := scaffolding.DeriveComposerName(technicalName); composerName != expected { - addInstallableError(check, "composer.json", "installable.composer-name", - fmt.Sprintf("name must be %q for plugin %s, got %q", expected, technicalName, composerName)) - } - - if expected := scaffolding.DeriveNamespace(technicalName); namespace != expected { - addInstallableError(check, "composer.json", "installable.namespace", - fmt.Sprintf("plugin namespace must be %q for plugin %s, got %q", expected, technicalName, namespace)) - } -} - -func validatePluginClassFile(extensionDir, classFile, namespace, className string, check validation.Check) { - content, err := os.ReadFile(filepath.Join(extensionDir, classFile)) - if err != nil { - addInstallableError(check, classFile, "installable.plugin-class-file", - "plugin class file could not be read: "+err.Error()) - return - } - - php := string(content) - if declared := firstSubmatch(phpNamespaceRegexp, php); declared != namespace { - addInstallableError(check, classFile, "installable.plugin-class-namespace", - fmt.Sprintf("file must declare namespace %q, got %q", namespace, declared)) - } - - class := phpClassRegexp.FindStringSubmatch(php) - if class == nil { - addInstallableError(check, classFile, "installable.plugin-class-file", - "file must declare class "+className) - return - } - - if class[1] != className { - addInstallableError(check, classFile, "installable.plugin-class-file", - fmt.Sprintf("file must declare class %q, got %q", className, class[1])) - } - - if !extendsShopwarePlugin(class[2], php) { - addInstallableError(check, classFile, "installable.plugin-base-class", - fmt.Sprintf("class %s must extend %s", class[1], shopwarePluginBaseClass)) - } -} - -// pluginClassFile resolves the class file using the mappings generated in -// composer.json. PSR-4 strips the namespace prefix; PSR-0 keeps the namespace -// as directories below its mapped path. -func pluginClassFile(composer PlatformComposerJson, namespace, className string) (string, bool) { - prefix := namespace + `\` - fileName := className + ".php" - - if dir, ok := composer.Autoload.Psr4[prefix]; ok { - return filepath.Join(dir, fileName), true - } - if dir, ok := composer.Autoload.Psr0[prefix]; ok { - namespacePath := filepath.Join(strings.Split(namespace, `\`)...) - return filepath.Join(dir, namespacePath, fileName), true - } - - return "", false -} - -func splitPHPClass(fullClassName string) (namespace, className string) { - parts := strings.Split(strings.TrimPrefix(fullClassName, `\`), `\`) - if len(parts) < 2 || slices.Contains(parts, "") { - return "", "" - } - - return strings.Join(parts[:len(parts)-1], `\`), parts[len(parts)-1] -} - -func extendsShopwarePlugin(parent, php string) bool { - parent = strings.TrimPrefix(parent, `\`) - if parent == shopwarePluginBaseClass { - return true - } - - return parent == "Plugin" && strings.Contains(php, "use "+shopwarePluginBaseClass+";") -} - -func firstSubmatch(pattern *regexp.Regexp, content string) string { - match := pattern.FindStringSubmatch(content) - if match == nil { - return "" - } - - return strings.TrimSpace(match[1]) -} - -func addInstallableError(check validation.Check, path, identifier, message string) { - check.AddResult(validation.CheckResult{ - Path: path, - Identifier: identifier, - Message: message, - Severity: validation.SeverityError, - }) -} - -type installabilityCheck struct { - results []validation.CheckResult -} - -func (c *installabilityCheck) AddResult(result validation.CheckResult) { - c.results = append(c.results, result) -} - -func (c *installabilityCheck) GetResults() []validation.CheckResult { - return c.results -} - -func (c *installabilityCheck) HasErrors() bool { - for _, result := range c.results { - if result.Severity == validation.SeverityError { - return true - } - } - - return false -} - -func (c *installabilityCheck) RemoveByIdentifier([]validation.ToolConfigIgnore) validation.Check { - return c -} - -type installabilityError struct { - results []validation.CheckResult -} - -func (e installabilityError) Error() string { - messages := make([]string, 0, len(e.results)) - for _, result := range e.results { - messages = append(messages, fmt.Sprintf("%s [%s]: %s", result.Path, result.Identifier, result.Message)) - } - - return strings.Join(messages, "; ") -} diff --git a/internal/extension/create_test.go b/internal/extension/create_test.go index f8a1e490b..686284ab8 100644 --- a/internal/extension/create_test.go +++ b/internal/extension/create_test.go @@ -1,75 +1,73 @@ package extension import ( - "encoding/json" "fmt" "os" "path/filepath" - "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/shopware/shopware-cli/internal/extension/scaffolding" "github.com/shopware/shopware-cli/internal/system" - "github.com/shopware/shopware-cli/internal/validation" ) +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, false), valid) + 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, false), invalid) + assert.Error(t, ValidateName(invalid), invalid) } - - assert.NoError(t, ValidateName("SwagBasicExample", true)) - assert.Error(t, ValidateName("Example", true)) - assert.Error(t, ValidateName("Swag", true)) } -func TestCreate(t *testing.T) { - for _, store := range []bool{false, true} { - t.Run(fmt.Sprintf("store=%t", store), func(t *testing.T) { - projectDir := prepareProject(t) - opts := validCreateOptions() - opts.Store = store +func TestValidateVendorName(t *testing.T) { + t.Parallel() - require.NoError(t, Create(system.WithInteraction(t.Context(), false), opts)) + for _, valid := range []string{"Vendor", "MyVendor", "VendorAG"} { + assert.NoError(t, ValidateVendor(valid), valid) + } - extensionDir := extensionDirectory(projectDir, opts.Store, opts.Name) - assert.FileExists(t, filepath.Join(extensionDir, "composer.json")) - assert.FileExists(t, filepath.Join(extensionDir, "src", opts.Name+".php")) - require.NoError(t, validateCreatedExtension(t.Context(), extensionDir)) - }) + for _, invalid := range []string{ + "", "vendor", "my-vendor", "My_Vendor", + "My Vendor", "1Vendor", "Vendor.Example", + } { + assert.Error(t, ValidateVendor(invalid), invalid) } } -func TestCreateFindsClosestProject(t *testing.T) { - t.Setenv("PROJECT_ROOT", "") - projectDir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "bin"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(projectDir, "bin", "console"), nil, 0o755)) - require.NoError(t, os.WriteFile( - filepath.Join(projectDir, "composer.json"), - []byte(`{"require":{"shopware/core":"~6.7.0"}}`), - 0o644, - )) - require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "custom", "static-plugins"), 0o755)) - nestedDir := filepath.Join(projectDir, "custom") - t.Chdir(nestedDir) +func TestValidateExtensionType(t *testing.T) { + t.Parallel() - opts := validCreateOptions() - require.NoError(t, Create(system.WithInteraction(t.Context(), false), opts)) + for _, valid := range []ExtensionType{Plugin, Theme} { + assert.NoError(t, ValidateType(valid), valid) + } - assert.FileExists(t, filepath.Join(extensionDirectory(projectDir, opts.Store, opts.Name), "composer.json")) + for _, invalid := range []ExtensionType{"", "pluginx", "themey", "invalid"} { + assert.Error(t, ValidateType(invalid), invalid) + } } func TestCreateFailsOutsideShopwareProject(t *testing.T) { @@ -81,232 +79,36 @@ func TestCreateFailsOutsideShopwareProject(t *testing.T) { assert.ErrorContains(t, err, "cannot find Shopware project") } -func TestCreateReportsMissingExtensionParent(t *testing.T) { - projectDir := t.TempDir() - t.Setenv("PROJECT_ROOT", projectDir) - opts := validCreateOptions() - - err := Create(system.WithInteraction(t.Context(), false), opts) - - assert.ErrorContains(t, err, "extension parent directory does not exist") - assert.NoDirExists(t, extensionDirectory(projectDir, opts.Store, opts.Name)) -} - -func TestCreateDoesNotRemoveExistingDirectory(t *testing.T) { - projectDir := prepareProject(t) - opts := validCreateOptions() - extensionDir := extensionDirectory(projectDir, opts.Store, opts.Name) - require.NoError(t, os.Mkdir(extensionDir, 0o755)) - marker := filepath.Join(extensionDir, "keep.txt") - require.NoError(t, os.WriteFile(marker, []byte("keep"), 0o644)) - - err := Create(system.WithInteraction(t.Context(), false), opts) - - assert.ErrorContains(t, err, "already exists") - assert.FileExists(t, marker) -} - -func TestValidateCreatedExtensionReportsDetails(t *testing.T) { - extensionDir := scaffoldPlugin(t) - mutateComposer(t, extensionDir, func(composer *PlatformComposerJson) { - composer.Extra.Label["en-GB"] = "" - }) - - err := validateCreatedExtension(t.Context(), extensionDir) - - assert.ErrorContains(t, err, "installable.label") - assert.ErrorContains(t, err, "extra.label") -} - -func TestValidatePluginInstallable(t *testing.T) { - extensionDir := scaffoldPlugin(t) - check := &testCheck{} - - validatePluginInstallable(loadPlugin(t, extensionDir), check) - - assert.Empty(t, check.GetResults()) -} - -func TestValidatePluginInstallableReportsBrokenPlugin(t *testing.T) { - tests := []struct { - name string - breakPlugin func(*testing.T, string) - identifiers []string - }{ - { - name: "plugin class is missing", - breakPlugin: func(t *testing.T, dir string) { - t.Helper() - mutateComposer(t, dir, func(c *PlatformComposerJson) { c.Extra.ShopwarePluginClass = "" }) - }, - identifiers: []string{"installable.plugin-class"}, - }, - { - name: "shopware core requirement is missing", - breakPlugin: func(t *testing.T, dir string) { - t.Helper() - mutateComposer(t, dir, func(c *PlatformComposerJson) { delete(c.Require, "shopware/core") }) - }, - identifiers: []string{"installable.shopware-core"}, - }, - { - name: "shopware core constraint is invalid", - breakPlugin: func(t *testing.T, dir string) { - t.Helper() - mutateComposer(t, dir, func(c *PlatformComposerJson) { c.Require["shopware/core"] = "not a version" }) - }, - identifiers: []string{"installable.shopware-core"}, - }, - { - name: "English label is empty", - breakPlugin: func(t *testing.T, dir string) { - t.Helper() - mutateComposer(t, dir, func(c *PlatformComposerJson) { c.Extra.Label["en-GB"] = "" }) - }, - identifiers: []string{"installable.label"}, - }, - { - name: "technical name differs", - breakPlugin: func(t *testing.T, dir string) { - t.Helper() - mutateComposer(t, dir, func(c *PlatformComposerJson) { - c.Extra.ShopwarePluginClass = `Swag\BasicExample\SwagOtherExample` - }) - }, - identifiers: []string{"installable.technical-name", "installable.plugin-class-file"}, - }, - { - name: "composer name differs", - breakPlugin: func(t *testing.T, dir string) { - t.Helper() - mutateComposer(t, dir, func(c *PlatformComposerJson) { c.Name = "swag/basic_example" }) - }, - identifiers: []string{"installable.composer-name"}, - }, - { - name: "namespace differs", - breakPlugin: func(t *testing.T, dir string) { - t.Helper() - mutateComposer(t, dir, func(c *PlatformComposerJson) { - c.Extra.ShopwarePluginClass = `Swag\OtherExample\SwagBasicExample` - c.Autoload.Psr4 = map[string]string{`Swag\OtherExample\`: "src/"} - }) - }, - identifiers: []string{"installable.namespace", "installable.plugin-class-namespace"}, - }, - { - name: "namespace is not autoloaded", - breakPlugin: func(t *testing.T, dir string) { - t.Helper() - mutateComposer(t, dir, func(c *PlatformComposerJson) { - c.Autoload.Psr4 = map[string]string{`Swag\WrongExample\`: "src/"} - }) - }, - identifiers: []string{"installable.autoload"}, - }, - { - name: "class file is missing", - breakPlugin: func(t *testing.T, dir string) { - t.Helper() - require.NoError(t, os.Remove(filepath.Join(dir, "src", "SwagBasicExample.php"))) - }, - identifiers: []string{"installable.plugin-class-file"}, - }, - { - name: "class file namespace differs", - breakPlugin: func(t *testing.T, dir string) { - t.Helper() - replaceInFile(t, filepath.Join(dir, "src", "SwagBasicExample.php"), - `namespace Swag\BasicExample;`, `namespace Swag\WrongExample;`) - }, - identifiers: []string{"installable.plugin-class-namespace"}, - }, - { - name: "class name differs", - breakPlugin: func(t *testing.T, dir string) { - t.Helper() - replaceInFile(t, filepath.Join(dir, "src", "SwagBasicExample.php"), - "class SwagBasicExample extends Plugin", "class OtherClass extends Plugin") - }, - identifiers: []string{"installable.plugin-class-file"}, - }, - { - name: "base class is missing", - breakPlugin: func(t *testing.T, dir string) { - t.Helper() - replaceInFile(t, filepath.Join(dir, "src", "SwagBasicExample.php"), - "class SwagBasicExample extends Plugin", "class SwagBasicExample") - }, - identifiers: []string{"installable.plugin-base-class"}, - }, - { - name: "multiple metadata errors", - breakPlugin: func(t *testing.T, dir string) { - t.Helper() - mutateComposer(t, dir, func(c *PlatformComposerJson) { - delete(c.Require, "shopware/core") - c.Extra.Label["en-GB"] = "" - c.Name = "wrong/name" - }) - }, - identifiers: []string{ - "installable.shopware-core", - "installable.label", - "installable.composer-name", - }, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - extensionDir := scaffoldPlugin(t) - test.breakPlugin(t, extensionDir) - check := &testCheck{} +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 - validatePluginInstallable(loadPlugin(t, extensionDir), check) + require.NoError(t, Create(t.Context(), opts)) - assert.ElementsMatch(t, test.identifiers, resultIdentifiers(check.GetResults())) + 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 TestPluginClassFileSupportsPSR0(t *testing.T) { - extensionDir := scaffoldPlugin(t) - oldClassFile := filepath.Join(extensionDir, "src", "SwagBasicExample.php") - newClassFile := filepath.Join(extensionDir, "src", "Swag", "BasicExample", "SwagBasicExample.php") - require.NoError(t, os.MkdirAll(filepath.Dir(newClassFile), 0o755)) - require.NoError(t, os.Rename(oldClassFile, newClassFile)) - mutateComposer(t, extensionDir, func(c *PlatformComposerJson) { - c.Autoload.Psr4 = nil - c.Autoload.Psr0 = map[string]string{`Swag\BasicExample\`: "src/"} - }) - check := &testCheck{} - - validatePluginInstallable(loadPlugin(t, extensionDir), check) - - assert.Empty(t, check.GetResults()) -} - -func TestPluginClassMayExtendFullyQualifiedBaseClass(t *testing.T) { - extensionDir := scaffoldPlugin(t) - replaceInFile(t, filepath.Join(extensionDir, "src", "SwagBasicExample.php"), - "class SwagBasicExample extends Plugin", - `class SwagBasicExample extends \Shopware\Core\Framework\Plugin`) - check := &testCheck{} - - validatePluginInstallable(loadPlugin(t, extensionDir), check) - - assert.Empty(t, check.GetResults()) -} - func validCreateOptions() CreateOptions { return CreateOptions{ - Name: "SwagBasicExample", - Type: Plugin, + Name: "MyExtension", + Vendor: "MyVendor", + Type: Plugin, } } -func prepareProject(t *testing.T) string { +func newProject(t *testing.T) string { t.Helper() projectDir := t.TempDir() @@ -315,57 +117,3 @@ func prepareProject(t *testing.T) string { require.NoError(t, os.MkdirAll(filepath.Join(projectDir, "custom", "static-plugins"), 0o755)) return projectDir } - -func scaffoldPlugin(t *testing.T) string { - t.Helper() - - const name = "SwagBasicExample" - extensionDir := filepath.Join(t.TempDir(), name) - require.NoError(t, os.Mkdir(extensionDir, 0o755)) - require.NoError(t, scaffolding.CreateExtensionFiles(extensionDir, name)) - return extensionDir -} - -func loadPlugin(t *testing.T, extensionDir string) *PlatformPlugin { - t.Helper() - - ext, err := GetExtensionByFolder(t.Context(), extensionDir) - require.NoError(t, err) - plugin, ok := ext.(*PlatformPlugin) - require.True(t, ok, "%s is not a platform plugin", extensionDir) - return plugin -} - -func mutateComposer(t *testing.T, extensionDir string, mutate func(*PlatformComposerJson)) { - t.Helper() - - path := filepath.Join(extensionDir, "composer.json") - content, err := os.ReadFile(path) - require.NoError(t, err) - - var composer PlatformComposerJson - require.NoError(t, json.Unmarshal(content, &composer)) - mutate(&composer) - - content, err = json.MarshalIndent(composer, "", " ") - require.NoError(t, err) - require.NoError(t, os.WriteFile(path, append(content, '\n'), 0o644)) -} - -func replaceInFile(t *testing.T, path, old, replacement string) { - t.Helper() - - content, err := os.ReadFile(path) - require.NoError(t, err) - require.Contains(t, string(content), old) - updated := strings.Replace(string(content), old, replacement, 1) - require.NoError(t, os.WriteFile(path, []byte(updated), 0o644)) -} - -func resultIdentifiers(results []validation.CheckResult) []string { - identifiers := make([]string, 0, len(results)) - for _, result := range results { - identifiers = append(identifiers, result.Identifier) - } - return identifiers -} diff --git a/internal/extension/create_validate.go b/internal/extension/create_validate.go index 98d25eca3..34a2c8dd8 100644 --- a/internal/extension/create_validate.go +++ b/internal/extension/create_validate.go @@ -6,25 +6,30 @@ import ( "regexp" ) -// Shopware technical names use UpperCamelCase. Community Store plugins also -// need a vendor prefix, for example SwagBasicExample. var ( - extensionNameRegexp = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*$`) - storeExtensionNameRegexp = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*$`) + extensionNameRegexp = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*$`) + vendorNameRegexp = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*$`) ) -func ValidateName(name string, store bool) error { +func ValidateName(name string) error { if name == "" { return errors.New("extension name must not be empty") } - if store { - if !storeExtensionNameRegexp.MatchString(name) { - return fmt.Errorf("invalid extension name %q: Community Store extensions need UpperCamelCase with a vendor prefix, letters and digits only (for example SwagBasicExample)", name) - } - return nil - } + if !extensionNameRegexp.MatchString(name) { - return fmt.Errorf("invalid extension name %q: use UpperCamelCase, letters and digits only (for example Example or SwagBasicExample)", 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 @@ -35,6 +40,6 @@ func ValidateType(extensionType ExtensionType) error { case Plugin, Theme: return nil default: - return fmt.Errorf("invalid extension type %q", extensionType) + 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 index 79ba13c70..fa60eacc6 100644 --- a/internal/extension/scaffolding/scaffolding.go +++ b/internal/extension/scaffolding/scaffolding.go @@ -15,6 +15,8 @@ import ( 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/* @@ -22,8 +24,7 @@ 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, e.g. the - // backslashes of a PHP namespace: Swag\Example -> Swag\\Example. + // jsonEscape makes a value safe inside a JSON string "jsonEscape": func(value string) (string, error) { encoded, err := json.Marshal(value) if err != nil { @@ -33,6 +34,10 @@ var stubFuncs = template.FuncMap{ // 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 { @@ -41,7 +46,7 @@ type scaffoldingFile struct { } // scaffoldingFiles returns a list of files with their paths and corresponding stub paths. -func scaffoldingFiles(extensionName string) []scaffoldingFile { +func scaffoldingFiles(className string) []scaffoldingFile { return []scaffoldingFile{ { Path: "composer.json", @@ -64,7 +69,7 @@ func scaffoldingFiles(extensionName string) []scaffoldingFile { StubPath: "stubs/config.xml.tmpl", }, { - Path: filepath.Join("src", extensionName+".php"), + Path: filepath.Join("src", className+".php"), StubPath: "stubs/plugin_class.php.tmpl", }, } @@ -103,9 +108,9 @@ func CreateExtensionDir(extensionDir string) error { } // CreateExtensionFiles creates all scaffolding Files that are given back by scaffoldingFiles() -func CreateExtensionFiles(extensionDir, extensionName string) error { - data := createScaffoldingData(extensionName) - for _, file := range scaffoldingFiles(extensionName) { +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 @@ -122,7 +127,7 @@ func createFileWithScaffolding(extensionDir string, file scaffoldingFile, data s return fmt.Errorf("create subdirectories: %w", err) } - f, err := os.Create(dest) + 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) } @@ -158,40 +163,43 @@ type scaffoldData struct { ComposerName string } -func createScaffoldingData(extensionName string) scaffoldData { +func createScaffoldingData(vendorName string, extensionName string) scaffoldData { return scaffoldData{ - Namespace: DeriveNamespace(extensionName), - ClassName: extensionName, - ComposerName: DeriveComposerName(extensionName), + Namespace: DeriveNamespace(vendorName, extensionName), + ClassName: DeriveClassName(vendorName, extensionName), + ComposerName: DeriveComposerName(vendorName, extensionName), } } -// DeriveNamespace turns a technical plugin name into a PHP namespace. -// The first PascalCase word is the vendor prefix, the rest stay one segment: -// SwagBasicExample → Swag\BasicExample. -func DeriveNamespace(extensionName string) string { - parts := splitPascalCase(extensionName) - if len(parts) < 2 { +// 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 parts[0] + "\\" + strings.Join(parts[1:], "") + return vendorName + "\\" + extensionName } -// DeriveComposerName turns a technical plugin name into a Composer package name: -// SwagBasicExample → swag/basic-example. -func DeriveComposerName(extensionName string) string { - parts := splitPascalCase(extensionName) - if len(parts) == 0 { - return "" - } +// 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) - vendor := strings.ToLower(parts[0]) - if len(parts) == 1 { - return vendor + "/" + vendor + lowerVendor := strings.ToLower(strings.Join(vendorParts, "-")) + lowerName := strings.ToLower(strings.Join(nameParts, "-")) + + if lowerVendor == "" { + lowerVendor = lowerName } - return vendor + "/" + strings.ToLower(strings.Join(parts[1:], "-")) + 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. @@ -218,6 +226,20 @@ func splitPascalCase(name string) []string { // 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") @@ -262,11 +284,5 @@ func RemoveCreatedExtensionDir(extensionDir string) error { if !info.IsDir() { return fmt.Errorf("%s is not a directory", abs) } - - // Delete the folder and everything inside it. - if err := os.RemoveAll(abs); err != nil { - return fmt.Errorf("remove extension directory: %w", err) - } - return nil } diff --git a/internal/extension/scaffolding/scaffolding_test.go b/internal/extension/scaffolding/scaffolding_test.go index a3d8a74a2..3a2c0e71c 100644 --- a/internal/extension/scaffolding/scaffolding_test.go +++ b/internal/extension/scaffolding/scaffolding_test.go @@ -1,146 +1,235 @@ package scaffolding import ( - "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestNameDerivation(t *testing.T) { +func TestDeriveNamespace(t *testing.T) { t.Parallel() - tests := []struct { - name string - parts []string - namespace string - composerName string - }{ - { - name: "SwagBasicExample", - parts: []string{"Swag", "Basic", "Example"}, - namespace: `Swag\BasicExample`, - composerName: "swag/basic-example", - }, - { - name: "AcmePayPal", - parts: []string{"Acme", "Pay", "Pal"}, - namespace: `Acme\PayPal`, - composerName: "acme/pay-pal", - }, - { - name: "Swag2Example", - parts: []string{"Swag2", "Example"}, - namespace: `Swag2\Example`, - composerName: "swag2/example", - }, - { - name: "", - parts: nil, - namespace: "", - composerName: "", - }, - } + 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") - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, test.parts, splitPascalCase(test.name)) - assert.Equal(t, test.namespace, DeriveNamespace(test.name)) - assert.Equal(t, test.composerName, DeriveComposerName(test.name)) + 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 TestCreateScaffoldingFiles(t *testing.T) { - extensionDir := t.TempDir() +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)) - require.NoError(t, CreateExtensionFiles(extensionDir, "SwagBasicExample")) + assert.ErrorContains(t, CreateExtensionDir(extensionDir), "already exists") + }) - expectedFiles := []string{ - ".gitignore", - "composer.json", - "phpunit.xml", - filepath.Join("src", "SwagBasicExample.php"), - filepath.Join("src", "Resources", "config", "config.xml"), - filepath.Join("tests", "TestBootstrap.php"), - } - for _, file := range expectedFiles { - assert.FileExists(t, filepath.Join(extensionDir, file)) - } + 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)) - content, err := os.ReadFile(filepath.Join(extensionDir, "composer.json")) - require.NoError(t, err) - var composer struct { - Name string `json:"name"` - Extra struct { - PluginClass string `json:"shopware-plugin-class"` - } `json:"extra"` - } - require.NoError(t, json.Unmarshal(content, &composer)) - assert.Equal(t, "swag/basic-example", composer.Name) - assert.Equal(t, `Swag\BasicExample\SwagBasicExample`, composer.Extra.PluginClass) - - pluginClass, err := os.ReadFile(filepath.Join(extensionDir, "src", "SwagBasicExample.php")) - require.NoError(t, err) - assert.Contains(t, string(pluginClass), `namespace Swag\BasicExample;`) - assert.Contains(t, string(pluginClass), "class SwagBasicExample extends Plugin") + 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 TestCreateExtensionDir(t *testing.T) { - parent := t.TempDir() - extensionDir := filepath.Join(parent, "SwagBasicExample") +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, CreateExtensionDir(extensionDir)) - assert.DirExists(t, extensionDir) - assert.ErrorContains(t, CreateExtensionDir(extensionDir), "already exists") + require.NoError(t, CreateExtensionFiles(extensionDir, "MyExtension", "MyVendor")) - filePath := filepath.Join(parent, "file") - require.NoError(t, os.WriteFile(filePath, nil, 0o644)) - assert.ErrorContains(t, CreateExtensionDir(filePath), "not a directory") + assert.DirExists(t, filepath.Join(extensionDir, "src", "Resources", "config")) + assert.DirExists(t, filepath.Join(extensionDir, "tests")) - missingParent := filepath.Join(parent, "missing", "Extension") - assert.ErrorContains(t, CreateExtensionDir(missingParent), "parent directory does not exist") - assert.NoDirExists(t, filepath.Dir(missingParent)) + // 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(t.TempDir(), "custom", pluginRoot, "SwagBasicExample") - require.NoError(t, os.MkdirAll(extensionDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(extensionDir, "file.txt"), nil, 0o644)) + 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 and idempotent. + + // Removing an already absent extension is safe. require.NoError(t, RemoveCreatedExtensionDir(extensionDir)) }) } } -func TestRemoveCreatedExtensionDirRejectsUnsafePaths(t *testing.T) { - root := t.TempDir() - pluginRoot := filepath.Join(root, "custom", "plugins") - require.NoError(t, os.MkdirAll(pluginRoot, 0o755)) - - ordinaryDir := filepath.Join(root, "ordinary", "SwagBasicExample") - require.NoError(t, os.MkdirAll(ordinaryDir, 0o755)) - assert.ErrorContains(t, RemoveCreatedExtensionDir(ordinaryDir), "not an extension directory") - assert.DirExists(t, ordinaryDir) - - assert.Error(t, RemoveCreatedExtensionDir("")) - assert.Error(t, RemoveCreatedExtensionDir(string(filepath.Separator))) - assert.ErrorContains(t, RemoveCreatedExtensionDir(pluginRoot), "not an extension directory") - assert.DirExists(t, pluginRoot) - - target := filepath.Join(pluginRoot, "Target") - link := filepath.Join(pluginRoot, "Link") - require.NoError(t, os.Mkdir(target, 0o755)) - require.NoError(t, os.Symlink(target, link)) - assert.ErrorContains(t, RemoveCreatedExtensionDir(link), "symlink") - assert.DirExists(t, target) +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/test_bootstrap.php.tmpl b/internal/extension/scaffolding/stubs/test_bootstrap.php.tmpl index 78f75f314..1bd6c84b8 100644 --- a/internal/extension/scaffolding/stubs/test_bootstrap.php.tmpl +++ b/internal/extension/scaffolding/stubs/test_bootstrap.php.tmpl @@ -9,4 +9,4 @@ $loader = (new TestBootstrapper()) ->bootstrap() ->getClassLoader(); -$loader->addPsr4('{{ .Namespace }}\\Tests\\', __DIR__); +$loader->addPsr4('{{ escapeBackslash .Namespace }}\\Tests\\', __DIR__); From 9718ac74290b22ee69ad7600b38503e45bc61711 Mon Sep 17 00:00:00 2001 From: Anne Hintzpeter Date: Mon, 14 Sep 2026 08:06:24 +0200 Subject: [PATCH 3/5] feat: add create theme flag --- internal/extension/create.go | 12 ++- internal/extension/create_test.go | 62 +++++++++---- internal/extension/scaffolding/scaffolding.go | 88 +++++++++++++++---- .../extension/scaffolding/scaffolding_test.go | 69 ++++++++++++++- .../scaffolding/stubs/theme.json.tmpl | 22 +++++ .../scaffolding/stubs/theme_class.php.tmpl | 10 +++ .../stubs/theme_composer.json.tmpl | 21 +++++ .../stubs/theme_overrides.scss.tmpl | 8 ++ 8 files changed, 255 insertions(+), 37 deletions(-) create mode 100644 internal/extension/scaffolding/stubs/theme.json.tmpl create mode 100644 internal/extension/scaffolding/stubs/theme_class.php.tmpl create mode 100644 internal/extension/scaffolding/stubs/theme_composer.json.tmpl create mode 100644 internal/extension/scaffolding/stubs/theme_overrides.scss.tmpl diff --git a/internal/extension/create.go b/internal/extension/create.go index 4503a5c96..98faf916a 100644 --- a/internal/extension/create.go +++ b/internal/extension/create.go @@ -32,6 +32,16 @@ func Create(ctx context.Context, opts CreateOptions) (err error) { logger.Info("Creating extension...") + var createFiles func(string, string, string) error + switch opts.Type { + case Plugin: + createFiles = scaffolding.CreatePluginFiles + case Theme: + createFiles = scaffolding.CreateThemeFiles + default: + return fmt.Errorf("unsupported extension type %q", opts.Type) + } + projectDir, err := shop.FindClosestShopwareProject(false) if err != nil { return err @@ -56,7 +66,7 @@ func Create(ctx context.Context, opts CreateOptions) (err error) { } }() - if err = scaffolding.CreateExtensionFiles(extensionDir, opts.Name, opts.Vendor); err != nil { + if err = createFiles(extensionDir, opts.Name, opts.Vendor); err != nil { return fmt.Errorf("create extension files: %w", err) } diff --git a/internal/extension/create_test.go b/internal/extension/create_test.go index 686284ab8..746c134f3 100644 --- a/internal/extension/create_test.go +++ b/internal/extension/create_test.go @@ -80,26 +80,54 @@ func TestCreateFailsOutsideShopwareProject(t *testing.T) { } 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")) - }) + for _, extensionType := range []ExtensionType{Plugin, Theme} { + for _, store := range []bool{false, true} { + t.Run(fmt.Sprintf("type=%s/store=%t", extensionType, store), func(t *testing.T) { + projectDir := newProject(t) + opts := validCreateOptions() + opts.Type = extensionType + 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", technicalName+".php")) + + if extensionType == Plugin { + 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, "tests", "TestBootstrap.php")) + assert.NoFileExists(t, filepath.Join(extensionDir, "src", "Resources", "theme.json")) + return + } + + assert.FileExists(t, filepath.Join(extensionDir, "src", "Resources", "theme.json")) + assert.FileExists(t, filepath.Join( + extensionDir, + "src/Resources/app/storefront/src/scss/overrides.scss", + )) + assert.NoFileExists(t, filepath.Join(extensionDir, "phpunit.xml")) + }) + } } } +func TestCreateRejectsUnsupportedTypeWithoutCreatingDirectory(t *testing.T) { + projectDir := newProject(t) + opts := validCreateOptions() + opts.Type = "app" + technicalName := deriveTechnicalName(opts.Name, opts.Vendor) + extensionDir := deriveExtensionDirectoryName(projectDir, opts.Store, technicalName) + + err := Create(t.Context(), opts) + + assert.ErrorContains(t, err, `unsupported extension type "app"`) + assert.NoDirExists(t, extensionDir) +} + func validCreateOptions() CreateOptions { return CreateOptions{ Name: "MyExtension", diff --git a/internal/extension/scaffolding/scaffolding.go b/internal/extension/scaffolding/scaffolding.go index fa60eacc6..4a9b16591 100644 --- a/internal/extension/scaffolding/scaffolding.go +++ b/internal/extension/scaffolding/scaffolding.go @@ -45,8 +45,8 @@ type scaffoldingFile struct { StubPath string } -// scaffoldingFiles returns a list of files with their paths and corresponding stub paths. -func scaffoldingFiles(className string) []scaffoldingFile { +// pluginScaffoldingFiles returns the files in a platform plugin scaffold. +func pluginScaffoldingFiles(className string) []scaffoldingFile { return []scaffoldingFile{ { Path: "composer.json", @@ -75,6 +75,44 @@ func scaffoldingFiles(className string) []scaffoldingFile { } } +// themeScaffoldingFiles returns the default files generated for a storefront theme. +func themeScaffoldingFiles(data scaffoldData) []scaffoldingFile { + return []scaffoldingFile{ + { + Path: "composer.json", + StubPath: "stubs/theme_composer.json.tmpl", + }, + { + Path: filepath.Join("src", data.ClassName+".php"), + StubPath: "stubs/theme_class.php.tmpl", + }, + { + Path: "src/Resources/theme.json", + StubPath: "stubs/theme.json.tmpl", + }, + { + Path: "src/Resources/app/storefront/src/scss/overrides.scss", + StubPath: "stubs/theme_overrides.scss.tmpl", + }, + { + Path: "src/Resources/app/storefront/src/scss/base.scss", + }, + { + Path: "src/Resources/app/storefront/src/assets/.gitkeep", + }, + { + Path: "src/Resources/app/storefront/src/main.js", + }, + { + Path: filepath.Join( + "src/Resources/app/storefront/dist/storefront/js", + data.AssetName, + data.AssetName+".js", + ), + }, + } +} + // CreateExtensionDir creates an empty extension directory. Its parents must already exist. func CreateExtensionDir(extensionDir string) error { info, err := os.Stat(extensionDir) @@ -107,10 +145,20 @@ func CreateExtensionDir(extensionDir string) error { return nil } -// CreateExtensionFiles creates all scaffolding Files that are given back by scaffoldingFiles() -func CreateExtensionFiles(extensionDir, extensionName, vendorName string) error { +// CreatePluginFiles creates the files for a platform plugin. +func CreatePluginFiles(extensionDir, extensionName, vendorName string) error { data := createScaffoldingData(vendorName, extensionName) - for _, file := range scaffoldingFiles(data.ClassName) { + return createExtensionFiles(extensionDir, pluginScaffoldingFiles(data.ClassName), data) +} + +// CreateThemeFiles creates the files for a storefront theme. +func CreateThemeFiles(extensionDir, extensionName, vendorName string) error { + data := createScaffoldingData(vendorName, extensionName) + return createExtensionFiles(extensionDir, themeScaffoldingFiles(data), data) +} + +func createExtensionFiles(extensionDir string, files []scaffoldingFile, data scaffoldData) error { + for _, file := range files { err := createFileWithScaffolding(extensionDir, file, data) if err != nil { return err @@ -120,7 +168,7 @@ func CreateExtensionFiles(extensionDir, extensionName, vendorName string) error return nil } -// createFileWithScaffolding renders one embedded template into an existing extension. +// createFileWithScaffolding renders an embedded template or creates an empty placeholder. 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 { @@ -137,18 +185,20 @@ func createFileWithScaffolding(extensionDir string, file scaffoldingFile, data s } }() - stubBytes, err := stubsFS.ReadFile(file.StubPath) - if err != nil { - return fmt.Errorf("read stub file: %w", err) - } + if file.StubPath != "" { + 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) - } + 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 := 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) @@ -161,13 +211,17 @@ type scaffoldData struct { Namespace string ClassName string ComposerName string + AssetName string } func createScaffoldingData(vendorName string, extensionName string) scaffoldData { + className := DeriveClassName(vendorName, extensionName) + return scaffoldData{ Namespace: DeriveNamespace(vendorName, extensionName), - ClassName: DeriveClassName(vendorName, extensionName), + ClassName: className, ComposerName: DeriveComposerName(vendorName, extensionName), + AssetName: strings.ToLower(strings.Join(splitPascalCase(className), "-")), } } diff --git a/internal/extension/scaffolding/scaffolding_test.go b/internal/extension/scaffolding/scaffolding_test.go index 3a2c0e71c..63b438332 100644 --- a/internal/extension/scaffolding/scaffolding_test.go +++ b/internal/extension/scaffolding/scaffolding_test.go @@ -89,13 +89,13 @@ func TestCreateExtensionDirErrors(t *testing.T) { }) } -func TestCreateExtensionFiles(t *testing.T) { +func TestCreatePluginFiles(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")) + require.NoError(t, CreatePluginFiles(extensionDir, "MyExtension", "MyVendor")) assert.DirExists(t, filepath.Join(extensionDir, "src", "Resources", "config")) assert.DirExists(t, filepath.Join(extensionDir, "tests")) @@ -109,6 +109,71 @@ func TestCreateExtensionFiles(t *testing.T) { assert.FileExists(t, filepath.Join(extensionDir, "tests", "TestBootstrap.php")) } +func TestCreateThemeFiles(t *testing.T) { + projectDir := newProject(t) + technicalName := "MyVendorMyExtension" + assetName := "my-vendor-my-extension" + extensionDir := filepath.Join(projectDir, "custom", "plugins", technicalName) + require.NoError(t, os.MkdirAll(extensionDir, 0o755)) + + require.NoError(t, CreateThemeFiles(extensionDir, "MyExtension", "MyVendor")) + + expectedFiles := []string{ + "composer.json", + filepath.Join("src", technicalName+".php"), + "src/Resources/theme.json", + "src/Resources/app/storefront/src/scss/overrides.scss", + "src/Resources/app/storefront/src/scss/base.scss", + "src/Resources/app/storefront/src/assets/.gitkeep", + "src/Resources/app/storefront/src/main.js", + filepath.Join( + "src/Resources/app/storefront/dist/storefront/js", + assetName, + assetName+".js", + ), + } + for _, file := range expectedFiles { + assert.FileExists(t, filepath.Join(extensionDir, file)) + } + for _, file := range []string{ + "src/Resources/app/storefront/src/scss/base.scss", + "src/Resources/app/storefront/src/assets/.gitkeep", + "src/Resources/app/storefront/src/main.js", + filepath.Join( + "src/Resources/app/storefront/dist/storefront/js", + assetName, + assetName+".js", + ), + } { + info, err := os.Stat(filepath.Join(extensionDir, file)) + require.NoError(t, err) + assert.Zero(t, info.Size()) + } + + assert.NoFileExists(t, filepath.Join(extensionDir, "phpunit.xml")) + assert.NoFileExists(t, filepath.Join(extensionDir, "src", "Resources", "config", "config.xml")) + + composer, err := os.ReadFile(filepath.Join(extensionDir, "composer.json")) + require.NoError(t, err) + assert.Contains(t, string(composer), `"shopware-plugin-class": "MyVendor\\MyExtension\\MyVendorMyExtension"`) + + bootstrap, err := os.ReadFile(filepath.Join(extensionDir, "src", technicalName+".php")) + require.NoError(t, err) + assert.Contains(t, string(bootstrap), `namespace MyVendor\MyExtension;`) + assert.Contains(t, string(bootstrap), "implements ThemeInterface") + + themeConfig, err := os.ReadFile(filepath.Join(extensionDir, "src", "Resources", "theme.json")) + require.NoError(t, err) + assert.JSONEq(t, `{ + "name": "MyVendorMyExtension", + "author": "Shopware AG", + "views": ["@Storefront", "@Plugins", "@MyVendorMyExtension"], + "style": ["app/storefront/src/scss/overrides.scss", "@Storefront", "app/storefront/src/scss/base.scss"], + "script": ["@Storefront", "app/storefront/dist/storefront/js/my-vendor-my-extension/my-vendor-my-extension.js"], + "asset": ["@Storefront", "app/storefront/src/assets"] + }`, string(themeConfig)) +} + func TestCreateFileWithScaffoldingErrors(t *testing.T) { t.Run("destination file already exists", func(t *testing.T) { extensionDir := filepath.Join(t.TempDir(), "MyVendorMyExtension") diff --git a/internal/extension/scaffolding/stubs/theme.json.tmpl b/internal/extension/scaffolding/stubs/theme.json.tmpl new file mode 100644 index 000000000..5b3c556cf --- /dev/null +++ b/internal/extension/scaffolding/stubs/theme.json.tmpl @@ -0,0 +1,22 @@ +{ + "name": "{{ .ClassName }}", + "author": "Shopware AG", + "views": [ + "@Storefront", + "@Plugins", + "@{{ .ClassName }}" + ], + "style": [ + "app/storefront/src/scss/overrides.scss", + "@Storefront", + "app/storefront/src/scss/base.scss" + ], + "script": [ + "@Storefront", + "app/storefront/dist/storefront/js/{{ .AssetName }}/{{ .AssetName }}.js" + ], + "asset": [ + "@Storefront", + "app/storefront/src/assets" + ] +} diff --git a/internal/extension/scaffolding/stubs/theme_class.php.tmpl b/internal/extension/scaffolding/stubs/theme_class.php.tmpl new file mode 100644 index 000000000..41f1194c1 --- /dev/null +++ b/internal/extension/scaffolding/stubs/theme_class.php.tmpl @@ -0,0 +1,10 @@ + Date: Mon, 14 Sep 2026 08:43:28 +0200 Subject: [PATCH 4/5] feat: add plugin template generator for scaffolding that does not belong to the plugin basic structure --- cmd/extension/extension_make.go | 47 +++ internal/extension/make.go | 109 ++++++ internal/extension/scaffolding/generator.go | 235 +++++++++++++ internal/extension/scaffolding/generators.go | 322 ++++++++++++++++++ .../scaffolding/stubs/make/admin_module.js | 37 ++ .../scaffolding/stubs/make/admin_snippet.json | 8 + .../scaffolding/stubs/make/command.php.tmpl | 30 ++ .../scaffolding/stubs/make/custom_fields.xml | 19 ++ .../scaffolding/stubs/make/entity.php.tmpl | 47 +++ .../stubs/make/entity_collection.php.tmpl | 22 ++ .../stubs/make/entity_definition.php.tmpl | 41 +++ .../stubs/make/entity_migration.php.tmpl | 38 +++ .../stubs/make/event_subscriber.php.tmpl | 24 ++ .../stubs/make/javascript_plugin.js | 13 + .../make/javascript_plugin_template.html.twig | 7 + .../stubs/make/scheduled_task.php.tmpl | 18 + .../make/store_api_abstract_route.php.tmpl | 13 + .../stubs/make/store_api_response.php.tmpl | 21 ++ .../stubs/make/store_api_route.php.tmpl | 35 ++ .../stubs/make/storefront_controller.php.tmpl | 27 ++ .../stubs/make/storefront_template.html.twig | 5 + 21 files changed, 1118 insertions(+) create mode 100644 cmd/extension/extension_make.go create mode 100644 internal/extension/make.go create mode 100644 internal/extension/scaffolding/generator.go create mode 100644 internal/extension/scaffolding/generators.go create mode 100644 internal/extension/scaffolding/stubs/make/admin_module.js create mode 100644 internal/extension/scaffolding/stubs/make/admin_snippet.json create mode 100644 internal/extension/scaffolding/stubs/make/command.php.tmpl create mode 100644 internal/extension/scaffolding/stubs/make/custom_fields.xml create mode 100644 internal/extension/scaffolding/stubs/make/entity.php.tmpl create mode 100644 internal/extension/scaffolding/stubs/make/entity_collection.php.tmpl create mode 100644 internal/extension/scaffolding/stubs/make/entity_definition.php.tmpl create mode 100644 internal/extension/scaffolding/stubs/make/entity_migration.php.tmpl create mode 100644 internal/extension/scaffolding/stubs/make/event_subscriber.php.tmpl create mode 100644 internal/extension/scaffolding/stubs/make/javascript_plugin.js create mode 100644 internal/extension/scaffolding/stubs/make/javascript_plugin_template.html.twig create mode 100644 internal/extension/scaffolding/stubs/make/scheduled_task.php.tmpl create mode 100644 internal/extension/scaffolding/stubs/make/store_api_abstract_route.php.tmpl create mode 100644 internal/extension/scaffolding/stubs/make/store_api_response.php.tmpl create mode 100644 internal/extension/scaffolding/stubs/make/store_api_route.php.tmpl create mode 100644 internal/extension/scaffolding/stubs/make/storefront_controller.php.tmpl create mode 100644 internal/extension/scaffolding/stubs/make/storefront_template.html.twig diff --git a/cmd/extension/extension_make.go b/cmd/extension/extension_make.go new file mode 100644 index 000000000..972131ed7 --- /dev/null +++ b/cmd/extension/extension_make.go @@ -0,0 +1,47 @@ +package extension + +import ( + "github.com/spf13/cobra" + + "github.com/shopware/shopware-cli/internal/extension" + "github.com/shopware/shopware-cli/internal/extension/scaffolding" +) + +func newMakeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "make", + Short: "Add an example implementation to a plugin", + Long: `Add an example implementation of a single Shopware feature to the plugin in the +current directory. Generators only create missing files and extend the service +and route configuration, existing code is never changed.`, + } + + for _, generator := range extension.Generators() { + cmd.AddCommand(newGeneratorCmd(generator)) + } + + return cmd +} + +func newGeneratorCmd(generator scaffolding.Generator) *cobra.Command { + use := generator.Name + args := cobra.NoArgs + + if generator.Args != "" { + use += " " + generator.Args + args = cobra.MinimumNArgs(1) + } + + return &cobra.Command{ + Use: use, + Short: generator.Short, + Args: args, + RunE: func(cmd *cobra.Command, args []string) error { + return extension.Make(cmd.Context(), generator, args) + }, + } +} + +func init() { + extensionRootCmd.AddCommand(newMakeCmd()) +} diff --git a/internal/extension/make.go b/internal/extension/make.go new file mode 100644 index 000000000..e47e5eec1 --- /dev/null +++ b/internal/extension/make.go @@ -0,0 +1,109 @@ +package extension + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/shopware/shopware-cli/internal/extension/scaffolding" + "github.com/shopware/shopware-cli/internal/shop" + "github.com/shopware/shopware-cli/logging" +) + +// MinimumMakeShopwareVersion is the oldest Shopware release the generated code +// runs on. 6.7.13.0 replaced the custom field installer with a declarative +// Resources/config/custom-fields.xml, which the generators rely on. +const MinimumMakeShopwareVersion = "6.7.13.0" + +// Generators returns the generators that can be run inside an existing plugin. +func Generators() []scaffolding.Generator { + return scaffolding.Generators() +} + +// Make runs a generator inside the plugin in the current directory. +func Make(ctx context.Context, generator scaffolding.Generator, args []string) error { + logger := logging.FromContext(ctx) + + projectDir, err := shop.FindClosestShopwareProject(false) + if err != nil { + return err + } + + if err := ensureMakeSupported(projectDir); err != nil { + return err + } + + plugin, err := currentPlugin(ctx) + if err != nil { + return err + } + + result, err := generator.Run(plugin, args) + if err != nil { + return err + } + + for _, path := range result.Created { + logger.Infof("✓ created %s", path) + } + for _, path := range result.Updated { + logger.Infof("✓ updated %s", path) + } + for _, path := range result.Skipped { + logger.Infof("• skipped %s, it is already up to date", path) + } + + return nil +} + +// ensureMakeSupported fails when the project runs a Shopware release that does +// not understand the generated code. +func ensureMakeSupported(projectDir string) error { + supported, err := shop.IsShopwareVersion(projectDir, ">="+MinimumMakeShopwareVersion) + if err != nil { + return fmt.Errorf("cannot determine the Shopware version of %s: %w", projectDir, err) + } + + if !supported { + return fmt.Errorf("the generators require Shopware %s or newer, %s uses an older release", MinimumMakeShopwareVersion, projectDir) + } + + return nil +} + +// currentPlugin describes the plugin the generators write into. +func currentPlugin(ctx context.Context) (scaffolding.PluginInfo, error) { + dir, err := os.Getwd() + if err != nil { + return scaffolding.PluginInfo{}, err + } + + ext, err := GetExtensionByFolder(ctx, dir) + if err != nil { + return scaffolding.PluginInfo{}, fmt.Errorf("the current directory is not an extension: %w", err) + } + + plugin, ok := ext.(*PlatformPlugin) + if !ok { + return scaffolding.PluginInfo{}, fmt.Errorf("the generators only support plugins, %s is of type %s", dir, ext.GetType()) + } + + namespace, className, err := splitPluginClass(plugin.Composer.Extra.ShopwarePluginClass) + if err != nil { + return scaffolding.PluginInfo{}, err + } + + return scaffolding.PluginInfo{Dir: dir, Namespace: namespace, ClassName: className}, nil +} + +// splitPluginClass separates the namespace from the class name of the fully +// qualified plugin class, e.g. Swag\BasicExample\SwagBasicExample. +func splitPluginClass(pluginClass string) (namespace string, className string, err error) { + separator := strings.LastIndex(pluginClass, `\`) + if separator <= 0 || separator == len(pluginClass)-1 { + return "", "", fmt.Errorf("composer.json needs a namespaced extra.shopware-plugin-class, got %q", pluginClass) + } + + return pluginClass[:separator], pluginClass[separator+1:], nil +} diff --git a/internal/extension/scaffolding/generator.go b/internal/extension/scaffolding/generator.go new file mode 100644 index 000000000..e6dfe5624 --- /dev/null +++ b/internal/extension/scaffolding/generator.go @@ -0,0 +1,235 @@ +package scaffolding + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" +) + +// PluginInfo describes the existing plugin a generator writes into. +type PluginInfo struct { + // Dir is the absolute path of the plugin. + Dir string + // Namespace is the PHP namespace of the plugin, e.g. Swag\BasicExample. + Namespace string + // ClassName is the plugin class without its namespace, e.g. SwagBasicExample. + ClassName string +} + +// Generator creates an example implementation of a single Shopware feature +// inside an existing plugin. Generators are additive: they create missing files +// and extend the service and route configuration, but never touch code that is +// already there. +type Generator struct { + // Name is the sub command name, e.g. "event-subscriber". + Name string + // Short describes the generator in the command list. + Short string + // Args documents the positional arguments, e.g. "ENTITY...". + // It is empty for generators that take no arguments. + Args string + + build func(plugin PluginInfo, args []string) (output, error) +} + +// Result lists the paths a generator run touched, relative to the plugin. +type Result struct { + // Created are files that did not exist before. + Created []string + // Updated are config files that gained a new block. + Updated []string + // Skipped are files that were already there and stayed untouched. + Skipped []string +} + +// Run executes the generator inside the plugin. +func (g Generator) Run(plugin PluginInfo, args []string) (Result, error) { + var result Result + + out, err := g.build(plugin, args) + if err != nil { + return result, err + } + + for _, f := range out.Files { + state, err := createFile(plugin.Dir, f) + if err != nil { + return result, fmt.Errorf("create %s: %w", f.Path, err) + } + + result.record(f.Path, state) + } + + for _, s := range out.Snippets { + state, err := applySnippet(plugin.Dir, s) + if err != nil { + return result, fmt.Errorf("update %s: %w", s.Path, err) + } + + result.record(s.Path, state) + } + + return result, nil +} + +// templateData holds every value the generator stubs can reference. The entity +// fields are only filled by the entity generator. +type templateData struct { + Namespace string + ClassName string + EntityName string + TableName string + Timestamp string +} + +// file is a single file a generator creates from an embedded stub. +type file struct { + // Path is relative to the plugin directory and always uses forward slashes. + Path string + Stub string + // Raw copies the stub verbatim instead of rendering it. Twig stubs need + // this because Twig and Go templates share the {{ }} delimiters. + Raw bool + Data templateData +} + +// snippet is a block of code added to a config file shared by all generators. +// A missing file is created from Intro and Outro, an existing one only gains +// the block. Content that is already present is never added twice. +type snippet struct { + // Path is relative to the plugin directory and always uses forward slashes. + Path string + Content string + Intro string + Outro string +} + +// output is everything a single generator contributes to a plugin. +type output struct { + Files []file + Snippets []snippet +} + +// fileState tells what happened to a file during a generator run. +type fileState int + +const ( + skipped fileState = iota + created + updated +) + +func (r *Result) record(path string, state fileState) { + switch state { + case created: + r.Created = append(r.Created, path) + case updated: + r.Updated = append(r.Updated, path) + case skipped: + r.Skipped = append(r.Skipped, path) + } +} + +// createFile renders a stub into the plugin, unless the file already exists. +func createFile(pluginDir string, f file) (fileState, error) { + dest := filepath.Join(pluginDir, filepath.FromSlash(f.Path)) + + _, err := os.Stat(dest) + if err == nil { + return skipped, nil + } + if !errors.Is(err, os.ErrNotExist) { + return skipped, fmt.Errorf("stat file: %w", err) + } + + content, err := renderStub(f) + if err != nil { + return skipped, err + } + + if err := writeFile(dest, content); err != nil { + return skipped, err + } + + return created, nil +} + +// applySnippet adds a block to a config file without losing its current content. +func applySnippet(pluginDir string, s snippet) (fileState, error) { + dest := filepath.Join(pluginDir, filepath.FromSlash(s.Path)) + + existing, err := os.ReadFile(dest) + if errors.Is(err, os.ErrNotExist) { + if err := writeFile(dest, s.Intro+s.Content+s.Outro); err != nil { + return skipped, err + } + + return created, nil + } + if err != nil { + return skipped, fmt.Errorf("read file: %w", err) + } + + content := string(existing) + if strings.Contains(content, s.Content) { + return skipped, nil + } + + // The block of a PHP config file belongs inside the returned closure, so it + // goes in front of the closing "};" rather than at the end of the file. + if marker := strings.TrimSpace(s.Outro); marker != "" { + if at := strings.LastIndex(content, marker); at >= 0 { + content = content[:at] + s.Content + content[at:] + + if err := writeFile(dest, content); err != nil { + return skipped, err + } + + return updated, nil + } + } + + if err := writeFile(dest, content+s.Content); err != nil { + return skipped, err + } + + return updated, nil +} + +func renderStub(f file) (string, error) { + stub, err := stubsFS.ReadFile(f.Stub) + if err != nil { + return "", fmt.Errorf("read stub: %w", err) + } + + if f.Raw { + return string(stub), nil + } + + tmpl, err := template.New(f.Path).Parse(string(stub)) + if err != nil { + return "", fmt.Errorf("parse stub: %w", err) + } + + var rendered strings.Builder + if err := tmpl.Execute(&rendered, f.Data); err != nil { + return "", fmt.Errorf("render stub: %w", err) + } + + return rendered.String(), nil +} + +func writeFile(dest, content string) error { + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("create subdirectories: %w", err) + } + + if err := os.WriteFile(dest, []byte(content), 0o644); err != nil { + return fmt.Errorf("write file: %w", err) + } + + return nil +} diff --git a/internal/extension/scaffolding/generators.go b/internal/extension/scaffolding/generators.go new file mode 100644 index 000000000..8090a566b --- /dev/null +++ b/internal/extension/scaffolding/generators.go @@ -0,0 +1,322 @@ +package scaffolding + +import ( + "fmt" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + servicesPath = "src/Resources/config/services.php" + routesPath = "src/Resources/config/routes.php" + adminSrcPath = "src/Resources/app/administration/src/" + storefrontPath = "src/Resources/app/storefront/src/" + viewsPath = "src/Resources/views/storefront/" +) + +// Shopware loads services and routes of a plugin from these two files. Both are +// a PHP closure, so a generator adds its block in front of the closing "};". +const ( + servicesIntro = `services(); +` + + routesIntro = `set(\%s\Command\ExampleCommand::class) + ->tag('console.command'); +` + + return output{ + Files: []file{ + {Path: "src/Command/ExampleCommand.php", Stub: "stubs/make/command.php.tmpl", Data: plugin.data()}, + }, + Snippets: []snippet{servicesSnippet(fmt.Sprintf(service, plugin.Namespace))}, + }, nil +} + +func buildCustomFieldset(_ PluginInfo, _ []string) (output, error) { + return output{ + Files: []file{ + {Path: "src/Resources/config/custom-fields.xml", Stub: "stubs/make/custom_fields.xml", Raw: true}, + }, + }, nil +} + +func buildEventSubscriber(plugin PluginInfo, _ []string) (output, error) { + const service = ` + $services->set(\%s\Subscriber\MySubscriber::class) + ->tag('kernel.event_subscriber'); +` + + return output{ + Files: []file{ + {Path: "src/Subscriber/MySubscriber.php", Stub: "stubs/make/event_subscriber.php.tmpl", Data: plugin.data()}, + }, + Snippets: []snippet{servicesSnippet(fmt.Sprintf(service, plugin.Namespace))}, + }, nil +} + +func buildJavascriptPlugin(_ PluginInfo, _ []string) (output, error) { + const pluginRegistration = `// Import all necessary Storefront plugins +import ExamplePlugin from './example-plugin/example-plugin.plugin'; + +// Register your plugin via the existing PluginManager +const PluginManager = window.PluginManager; + +PluginManager.register('ExamplePlugin', ExamplePlugin, '[data-example-plugin]'); +` + + return output{ + Files: []file{ + {Path: storefrontPath + "example-plugin/example-plugin.plugin.js", Stub: "stubs/make/javascript_plugin.js", Raw: true}, + {Path: viewsPath + "page/content/index.html.twig", Stub: "stubs/make/javascript_plugin_template.html.twig", Raw: true}, + }, + Snippets: []snippet{{Path: storefrontPath + "main.js", Content: pluginRegistration}}, + }, nil +} + +func buildScheduledTask(plugin PluginInfo, _ []string) (output, error) { + const service = ` + $services->set(\%s\ScheduledTask\ExampleTask::class) + ->tag('shopware.scheduled.task'); +` + + return output{ + Files: []file{ + {Path: "src/ScheduledTask/ExampleTask.php", Stub: "stubs/make/scheduled_task.php.tmpl", Data: plugin.data()}, + }, + Snippets: []snippet{servicesSnippet(fmt.Sprintf(service, plugin.Namespace))}, + }, nil +} + +func buildStoreAPIRoute(plugin PluginInfo, _ []string) (output, error) { + const service = ` + $services->set(\%s\Core\Content\Example\SalesChannel\ExampleRoute::class) + ->public() + ->args([ + service('product.repository'), + ]); +` + + const route = ` + $routes->import('../../Core/**/*Route.php', 'attribute'); +` + + const salesChannelPath = "src/Core/Content/Example/SalesChannel/" + + return output{ + Files: []file{ + {Path: salesChannelPath + "AbstractExampleRoute.php", Stub: "stubs/make/store_api_abstract_route.php.tmpl", Data: plugin.data()}, + {Path: salesChannelPath + "ExampleRoute.php", Stub: "stubs/make/store_api_route.php.tmpl", Data: plugin.data()}, + {Path: salesChannelPath + "ExampleRouteResponse.php", Stub: "stubs/make/store_api_response.php.tmpl", Data: plugin.data()}, + }, + Snippets: []snippet{ + servicesSnippet(fmt.Sprintf(service, plugin.Namespace)), + routesSnippet(route), + }, + }, nil +} + +func buildStorefrontController(plugin PluginInfo, _ []string) (output, error) { + const service = ` + $services->set(\%s\Storefront\Controller\ExampleController::class) + ->public() + ->call('setContainer', [service('service_container')]); +` + + const route = ` + $routes->import('../../Storefront/Controller/**/*Controller.php', 'attribute'); +` + + return output{ + Files: []file{ + {Path: "src/Storefront/Controller/ExampleController.php", Stub: "stubs/make/storefront_controller.php.tmpl", Data: plugin.data()}, + {Path: viewsPath + "page/example.html.twig", Stub: "stubs/make/storefront_template.html.twig", Raw: true}, + }, + Snippets: []snippet{ + servicesSnippet(fmt.Sprintf(service, plugin.Namespace)), + routesSnippet(route), + }, + }, nil +} + +func buildEntity(plugin PluginInfo, entities []string) (output, error) { + const service = ` + $services->set(\%s\Core\Content\%s\%sDefinition::class) + ->tag('shopware.entity.definition', ['entity' => '%s']); +` + + // All migrations of one run share a timestamp; the entity name keeps the + // class names unique. + timestamp := strconv.FormatInt(time.Now().Unix(), 10) + + var out output + + for _, entity := range entities { + if !entityNameRegexp.MatchString(entity) { + return output{}, fmt.Errorf("invalid entity name %q: use PascalCase, e.g. ExampleEntity", entity) + } + + data := templateData{ + Namespace: plugin.Namespace, + ClassName: plugin.ClassName, + EntityName: entity, + TableName: tableName(entity), + Timestamp: timestamp, + } + + contentPath := "src/Core/Content/" + entity + "/" + + migration, err := migrationFile(plugin.Dir, data) + if err != nil { + return output{}, err + } + + out.Files = append(out.Files, + file{Path: contentPath + entity + "Entity.php", Stub: "stubs/make/entity.php.tmpl", Data: data}, + file{Path: contentPath + entity + "Definition.php", Stub: "stubs/make/entity_definition.php.tmpl", Data: data}, + file{Path: contentPath + entity + "Collection.php", Stub: "stubs/make/entity_collection.php.tmpl", Data: data}, + migration, + ) + + out.Snippets = append(out.Snippets, servicesSnippet( + fmt.Sprintf(service, plugin.Namespace, entity, entity, data.TableName), + )) + } + + return out, nil +} + +// migrationFile returns the migration that creates the entity table. The file +// name carries the creation timestamp, so an earlier migration for the same +// entity can only be found by a glob. When one exists, its path is reused and +// the migration is reported as skipped instead of being written a second time. +func migrationFile(pluginDir string, data templateData) (file, error) { + f := file{ + Path: fmt.Sprintf("src/Migration/Migration%sCreate%sTable.php", data.Timestamp, data.EntityName), + Stub: "stubs/make/entity_migration.php.tmpl", + Data: data, + } + + pattern := filepath.Join(pluginDir, "src", "Migration", "Migration*Create"+data.EntityName+"Table.php") + + existing, err := filepath.Glob(pattern) + if err != nil { + return file{}, fmt.Errorf("look for existing migrations: %w", err) + } + + if len(existing) > 0 { + f.Path = "src/Migration/" + filepath.Base(existing[0]) + } + + return f, nil +} + +func (p PluginInfo) data() templateData { + return templateData{Namespace: p.Namespace, ClassName: p.ClassName} +} + +func servicesSnippet(content string) snippet { + return snippet{Path: servicesPath, Content: content, Intro: servicesIntro, Outro: configOutro} +} + +func routesSnippet(content string) snippet { + return snippet{Path: routesPath, Content: content, Intro: routesIntro, Outro: configOutro} +} + +// tableName turns a PascalCase entity name into its snake_case table name. +func tableName(entityName string) string { + return strings.ToLower(strings.Join(splitPascalCase(entityName), "_")) +} diff --git a/internal/extension/scaffolding/stubs/make/admin_module.js b/internal/extension/scaffolding/stubs/make/admin_module.js new file mode 100644 index 000000000..b00c6bbee --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/admin_module.js @@ -0,0 +1,37 @@ +Shopware.Module.register('swag-example', { + type: 'plugin', + name: 'Example', + title: 'swag-example.general.mainMenuItemGeneral', + description: 'sw-property.general.descriptionTextModule', + color: '#ff3d58', + icon: 'default-shopping-paper-bag-product', + + routes: { + list: { + component: 'swag-example-list', + path: 'list' + }, + detail: { + component: 'swag-example-detail', + path: 'detail/:id', + meta: { + parentPath: 'swag.example.list' + } + }, + create: { + component: 'swag-example-create', + path: 'create', + meta: { + parentPath: 'swag.example.list' + } + } + }, + + navigation: [{ + label: 'swag-example.general.mainMenuItemGeneral', + color: '#ff3d58', + path: 'swag.example.list', + icon: 'default-shopping-paper-bag-product', + position: 100 + }] +}); diff --git a/internal/extension/scaffolding/stubs/make/admin_snippet.json b/internal/extension/scaffolding/stubs/make/admin_snippet.json new file mode 100644 index 000000000..7e9772ac4 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/admin_snippet.json @@ -0,0 +1,8 @@ +{ + "swag-example": { + "general": { + "mainMenuItemGeneral": "My custom module", + "descriptionTextModule": "Manage this custom module here" + } + } +} diff --git a/internal/extension/scaffolding/stubs/make/command.php.tmpl b/internal/extension/scaffolding/stubs/make/command.php.tmpl new file mode 100644 index 000000000..79e0a865e --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/command.php.tmpl @@ -0,0 +1,30 @@ +setDescription('Does something very special.'); + } + + // Actual code executed in the command + protected function execute(InputInterface $input, OutputInterface $output): int + { + $output->writeln('It works!'); + + // Exit code 0 for success + return 0; + } +} diff --git a/internal/extension/scaffolding/stubs/make/custom_fields.xml b/internal/extension/scaffolding/stubs/make/custom_fields.xml new file mode 100644 index 000000000..7e4df1386 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/custom_fields.xml @@ -0,0 +1,19 @@ + + + + swag_example_set + + + + + + + + + + 1 + + + + diff --git a/internal/extension/scaffolding/stubs/make/entity.php.tmpl b/internal/extension/scaffolding/stubs/make/entity.php.tmpl new file mode 100644 index 000000000..94c1960d4 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/entity.php.tmpl @@ -0,0 +1,47 @@ +name; + } + + public function setName(?string $name): void + { + $this->name = $name; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): void + { + $this->description = $description; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): void + { + $this->active = $active; + } +} diff --git a/internal/extension/scaffolding/stubs/make/entity_collection.php.tmpl b/internal/extension/scaffolding/stubs/make/entity_collection.php.tmpl new file mode 100644 index 000000000..a0205b511 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/entity_collection.php.tmpl @@ -0,0 +1,22 @@ +addFlags(new Required(), new PrimaryKey()), + (new StringField('name', 'name')), + (new StringField('description', 'description')), + (new BoolField('active', 'active')) + ]); + } +} diff --git a/internal/extension/scaffolding/stubs/make/entity_migration.php.tmpl b/internal/extension/scaffolding/stubs/make/entity_migration.php.tmpl new file mode 100644 index 000000000..36ed9762c --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/entity_migration.php.tmpl @@ -0,0 +1,38 @@ +executeStatement($sql); + } + + public function updateDestructive(Connection $connection): void + { + } +} diff --git a/internal/extension/scaffolding/stubs/make/event_subscriber.php.tmpl b/internal/extension/scaffolding/stubs/make/event_subscriber.php.tmpl new file mode 100644 index 000000000..1431ca935 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/event_subscriber.php.tmpl @@ -0,0 +1,24 @@ + => + return [ + ProductEvents::PRODUCT_LOADED_EVENT => 'onProductsLoaded' + ]; + } + + public function onProductsLoaded(EntityLoadedEvent $event) + { + // Do something + // E.g. work with the loaded entities: $event->getEntities() + } +} diff --git a/internal/extension/scaffolding/stubs/make/javascript_plugin.js b/internal/extension/scaffolding/stubs/make/javascript_plugin.js new file mode 100644 index 000000000..c543ef3dc --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/javascript_plugin.js @@ -0,0 +1,13 @@ +import Plugin from 'src/plugin-system/plugin.class'; + +export default class ExamplePlugin extends Plugin { + init() { + window.addEventListener('scroll', this.onScroll.bind(this)); + } + + onScroll() { + if ((window.innerHeight + window.pageYOffset) >= document.body.offsetHeight) { + alert('Seems like there\'s nothing more to see here.'); + } + } +} diff --git a/internal/extension/scaffolding/stubs/make/javascript_plugin_template.html.twig b/internal/extension/scaffolding/stubs/make/javascript_plugin_template.html.twig new file mode 100644 index 000000000..38b9c59fd --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/javascript_plugin_template.html.twig @@ -0,0 +1,7 @@ +{% sw_extends '@Storefront/storefront/page/content/index.html.twig' %} + +{% block base_main_inner %} + {{ parent() }} + + +{% endblock %} diff --git a/internal/extension/scaffolding/stubs/make/scheduled_task.php.tmpl b/internal/extension/scaffolding/stubs/make/scheduled_task.php.tmpl new file mode 100644 index 000000000..aacb45954 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/scheduled_task.php.tmpl @@ -0,0 +1,18 @@ +object->getEntities(); + + return $collection; + } +} diff --git a/internal/extension/scaffolding/stubs/make/store_api_route.php.tmpl b/internal/extension/scaffolding/stubs/make/store_api_route.php.tmpl new file mode 100644 index 000000000..84255bfb0 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/store_api_route.php.tmpl @@ -0,0 +1,35 @@ + [StoreApiRouteScope::ID]])] +class ExampleRoute extends AbstractExampleRoute +{ + public function __construct(private readonly EntityRepository $productRepository) + { + } + + public function getDecorated(): AbstractExampleRoute + { + throw new DecorationPatternException(self::class); + } + + #[Route( + path: '/store-api/example', + name: 'store-api.example.search', + defaults: ['_entity' => 'product'], + methods: ['GET', 'POST'] + )] + public function load(Criteria $criteria, SalesChannelContext $context): ExampleRouteResponse + { + return new ExampleRouteResponse($this->productRepository->search($criteria, $context->getContext())); + } +} diff --git a/internal/extension/scaffolding/stubs/make/storefront_controller.php.tmpl b/internal/extension/scaffolding/stubs/make/storefront_controller.php.tmpl new file mode 100644 index 000000000..9742ec2cf --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/storefront_controller.php.tmpl @@ -0,0 +1,27 @@ + [StorefrontRouteScope::ID]])] +class ExampleController extends StorefrontController +{ + #[Route( + path: '/example', + name: 'frontend.example.example', + methods: ['GET'] + )] + public function showExample(Request $request, SalesChannelContext $context): Response + { + return $this->renderStorefront('@{{ .ClassName }}/storefront/page/example.html.twig', [ + 'example' => 'Hello world' + ]); + } +} diff --git a/internal/extension/scaffolding/stubs/make/storefront_template.html.twig b/internal/extension/scaffolding/stubs/make/storefront_template.html.twig new file mode 100644 index 000000000..c02d97bf2 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/storefront_template.html.twig @@ -0,0 +1,5 @@ +{% sw_extends '@Storefront/storefront/base.html.twig' %} + +{% block base_content %} +

{{ example }}

+{% endblock %} From e5acfae86ebe6d31303f30bef25e18db5f563b67 Mon Sep 17 00:00:00 2001 From: Anne Hintzpeter Date: Thu, 24 Sep 2026 07:03:39 +0200 Subject: [PATCH 5/5] fix: correct template and make plugin config optional --- internal/extension/create_test.go | 1 - internal/extension/scaffolding/generators.go | 27 +++- .../extension/scaffolding/generators_test.go | 121 ++++++++++++++++++ internal/extension/scaffolding/scaffolding.go | 4 - .../extension/scaffolding/scaffolding_test.go | 2 - .../scaffolding/stubs/composer.json.tmpl | 8 +- .../stubs/make/admin_component_index.js | 6 + .../stubs/make/admin_component_styling.scss | 6 + .../make/admin_component_template.html.twig | 13 ++ .../scaffolding/stubs/make/admin_module.js | 17 +-- .../scaffolding/stubs/make/admin_snippet.json | 6 +- .../stubs/make/entity_collection.php.tmpl | 8 +- .../plugin_config.xml} | 5 + .../make/scheduled_task_handler.php.tmpl | 16 +++ .../stubs/make/store_api_route.php.tmpl | 4 + 15 files changed, 210 insertions(+), 34 deletions(-) create mode 100644 internal/extension/scaffolding/generators_test.go create mode 100644 internal/extension/scaffolding/stubs/make/admin_component_index.js create mode 100644 internal/extension/scaffolding/stubs/make/admin_component_styling.scss create mode 100644 internal/extension/scaffolding/stubs/make/admin_component_template.html.twig rename internal/extension/scaffolding/stubs/{config.xml.tmpl => make/plugin_config.xml} (73%) create mode 100644 internal/extension/scaffolding/stubs/make/scheduled_task_handler.php.tmpl diff --git a/internal/extension/create_test.go b/internal/extension/create_test.go index 746c134f3..10c434769 100644 --- a/internal/extension/create_test.go +++ b/internal/extension/create_test.go @@ -96,7 +96,6 @@ func TestCreateGeneratesAnExtension(t *testing.T) { assert.FileExists(t, filepath.Join(extensionDir, "src", technicalName+".php")) if extensionType == Plugin { - 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, "tests", "TestBootstrap.php")) diff --git a/internal/extension/scaffolding/generators.go b/internal/extension/scaffolding/generators.go index 8090a566b..60bfbc765 100644 --- a/internal/extension/scaffolding/generators.go +++ b/internal/extension/scaffolding/generators.go @@ -76,6 +76,11 @@ func Generators() []Generator { Short: "Create an example storefront JavaScript plugin", build: buildJavascriptPlugin, }, + { + Name: "plugin-config", + Short: "Create an example plugin configuration", + build: buildPluginConfig, + }, { Name: "scheduled-task", Short: "Create an example scheduled task", @@ -99,9 +104,14 @@ func buildAdminModule(_ PluginInfo, _ []string) (output, error) { import './module/swag-example'; ` + const listPath = adminSrcPath + "module/swag-example/page/swag-example-list/" + return output{ Files: []file{ {Path: adminSrcPath + "module/swag-example/index.js", Stub: "stubs/make/admin_module.js", Raw: true}, + {Path: listPath + "index.js", Stub: "stubs/make/admin_component_index.js", Raw: true}, + {Path: listPath + "swag-example-list.html.twig", Stub: "stubs/make/admin_component_template.html.twig", Raw: true}, + {Path: listPath + "swag-example-list.scss", Stub: "stubs/make/admin_component_styling.scss", Raw: true}, {Path: adminSrcPath + "snippet/en.json", Stub: "stubs/make/admin_snippet.json", Raw: true}, {Path: adminSrcPath + "snippet/de.json", Stub: "stubs/make/admin_snippet.json", Raw: true}, }, @@ -167,15 +177,30 @@ PluginManager.register('ExamplePlugin', ExamplePlugin, '[data-example-plugin]'); }, nil } +func buildPluginConfig(_ PluginInfo, _ []string) (output, error) { + return output{ + Files: []file{ + {Path: "src/Resources/config/config.xml", Stub: "stubs/make/plugin_config.xml", Raw: true}, + }, + }, nil +} + func buildScheduledTask(plugin PluginInfo, _ []string) (output, error) { const service = ` - $services->set(\%s\ScheduledTask\ExampleTask::class) + $services->set(\%[1]s\ScheduledTask\ExampleTask::class) ->tag('shopware.scheduled.task'); + $services->set(\%[1]s\ScheduledTask\ExampleTaskHandler::class) + ->args([ + service('scheduled_task.repository'), + service('logger'), + ]) + ->tag('messenger.message_handler'); ` return output{ Files: []file{ {Path: "src/ScheduledTask/ExampleTask.php", Stub: "stubs/make/scheduled_task.php.tmpl", Data: plugin.data()}, + {Path: "src/ScheduledTask/ExampleTaskHandler.php", Stub: "stubs/make/scheduled_task_handler.php.tmpl", Data: plugin.data()}, }, Snippets: []snippet{servicesSnippet(fmt.Sprintf(service, plugin.Namespace))}, }, nil diff --git a/internal/extension/scaffolding/generators_test.go b/internal/extension/scaffolding/generators_test.go new file mode 100644 index 000000000..59fad73d3 --- /dev/null +++ b/internal/extension/scaffolding/generators_test.go @@ -0,0 +1,121 @@ +package scaffolding + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Every generator has to render its stubs and may only create files on the +// first run. +func TestGeneratorsCreateFilesOnceRunInAPlugin(t *testing.T) { + for _, generator := range Generators() { + t.Run(generator.Name, func(t *testing.T) { + plugin := newPlugin(t) + + var args []string + if generator.Args != "" { + args = []string{"ExampleEntity"} + } + + result, err := generator.Run(plugin, args) + require.NoError(t, err) + assert.NotEmpty(t, result.Created) + + for _, path := range result.Created { + assert.FileExists(t, filepath.Join(plugin.Dir, filepath.FromSlash(path))) + } + + second, err := generator.Run(plugin, args) + require.NoError(t, err) + assert.Empty(t, second.Created) + assert.Empty(t, second.Updated) + }) + } +} + +func TestAdminModuleGeneratorCreatesTheExampleComponent(t *testing.T) { + plugin := newPlugin(t) + + result, err := generatorByName(t, "admin-module").Run(plugin, nil) + require.NoError(t, err) + + listPath := adminSrcPath + "module/swag-example/page/swag-example-list/" + assert.Subset(t, result.Created, []string{ + adminSrcPath + "module/swag-example/index.js", + listPath + "index.js", + listPath + "swag-example-list.html.twig", + listPath + "swag-example-list.scss", + }) + + // The Twig stub is copied verbatim, its {{ }} are no Go template actions. + assert.Contains(t, readPluginFile(t, plugin, listPath+"swag-example-list.html.twig"), "{{ $t('swag-example.general.list.cardText') }}") + assert.Contains(t, readPluginFile(t, plugin, adminSrcPath+"module/swag-example/index.js"), "import './page/swag-example-list';") +} + +func TestScheduledTaskGeneratorRegistersTaskAndHandler(t *testing.T) { + plugin := newPlugin(t) + + result, err := generatorByName(t, "scheduled-task").Run(plugin, nil) + require.NoError(t, err) + + assert.Subset(t, result.Created, []string{ + "src/ScheduledTask/ExampleTask.php", + "src/ScheduledTask/ExampleTaskHandler.php", + }) + + handler := readPluginFile(t, plugin, "src/ScheduledTask/ExampleTaskHandler.php") + assert.Contains(t, handler, `namespace MyVendor\MyExtension\ScheduledTask;`) + assert.Contains(t, handler, "#[AsMessageHandler(handles: ExampleTask::class)]") + + services := readPluginFile(t, plugin, servicesPath) + assert.Contains(t, services, `$services->set(\MyVendor\MyExtension\ScheduledTask\ExampleTask::class)`) + assert.Contains(t, services, `$services->set(\MyVendor\MyExtension\ScheduledTask\ExampleTaskHandler::class)`) + assert.Contains(t, services, "->tag('messenger.message_handler');") +} + +func TestPluginConfigGeneratorCreatesTheConfigXML(t *testing.T) { + plugin := newPlugin(t) + + result, err := generatorByName(t, "plugin-config").Run(plugin, nil) + require.NoError(t, err) + + assert.Equal(t, []string{"src/Resources/config/config.xml"}, result.Created) + assert.Contains(t, readPluginFile(t, plugin, "src/Resources/config/config.xml"), "") +} + +func generatorByName(t *testing.T, name string) Generator { + t.Helper() + + for _, generator := range Generators() { + if generator.Name == name { + return generator + } + } + + t.Fatalf("unknown generator %q", name) + + return Generator{} +} + +func newPlugin(t *testing.T) PluginInfo { + t.Helper() + + return PluginInfo{ + Dir: t.TempDir(), + Namespace: `MyVendor\MyExtension`, + ClassName: "MyVendorMyExtension", + } +} + +func readPluginFile(t *testing.T, plugin PluginInfo, path string) string { + t.Helper() + + content, err := os.ReadFile(filepath.Join(plugin.Dir, filepath.FromSlash(path))) + require.NoError(t, err) + + return string(content) +} diff --git a/internal/extension/scaffolding/scaffolding.go b/internal/extension/scaffolding/scaffolding.go index 4a9b16591..eac5c8941 100644 --- a/internal/extension/scaffolding/scaffolding.go +++ b/internal/extension/scaffolding/scaffolding.go @@ -64,10 +64,6 @@ func pluginScaffoldingFiles(className string) []scaffoldingFile { 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", diff --git a/internal/extension/scaffolding/scaffolding_test.go b/internal/extension/scaffolding/scaffolding_test.go index 63b438332..fd6c5385d 100644 --- a/internal/extension/scaffolding/scaffolding_test.go +++ b/internal/extension/scaffolding/scaffolding_test.go @@ -97,12 +97,10 @@ func TestCreatePluginFiles(t *testing.T) { require.NoError(t, CreatePluginFiles(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")) diff --git a/internal/extension/scaffolding/stubs/composer.json.tmpl b/internal/extension/scaffolding/stubs/composer.json.tmpl index 7786031f1..6c5967f31 100644 --- a/internal/extension/scaffolding/stubs/composer.json.tmpl +++ b/internal/extension/scaffolding/stubs/composer.json.tmpl @@ -2,16 +2,16 @@ "name": "{{ .ComposerName }}", "description": "{{ .ComposerName }}", "type": "shopware-platform-plugin", - "version": "1.0.0", - "license": "MIT", + "version": "0.1.0", + "license": "proprietary", "require": { "shopware/core": "~6.7.0" }, "extra": { "shopware-plugin-class": "{{ jsonEscape .Namespace }}\\{{ .ClassName }}", "label": { - "de-DE": "Skeleton plugin", - "en-GB": "Skeleton plugin" + "de-DE": "{{ .ClassName }}", + "en-GB": "{{ .ClassName }}" } }, "autoload": { diff --git a/internal/extension/scaffolding/stubs/make/admin_component_index.js b/internal/extension/scaffolding/stubs/make/admin_component_index.js new file mode 100644 index 000000000..8796f7d47 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/admin_component_index.js @@ -0,0 +1,6 @@ +import template from './swag-example-list.html.twig'; +import './swag-example-list.scss'; + +Shopware.Component.register('swag-example-list', { + template, +}); diff --git a/internal/extension/scaffolding/stubs/make/admin_component_styling.scss b/internal/extension/scaffolding/stubs/make/admin_component_styling.scss new file mode 100644 index 000000000..9d58bca94 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/admin_component_styling.scss @@ -0,0 +1,6 @@ +.swag-example-list { + &__text { + // docs on design tokens: https://meteor.shopware.com/documentation/design/tokens + color: var(--color-icon-attention-default); + } +} diff --git a/internal/extension/scaffolding/stubs/make/admin_component_template.html.twig b/internal/extension/scaffolding/stubs/make/admin_component_template.html.twig new file mode 100644 index 000000000..f7cdee9f3 --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/admin_component_template.html.twig @@ -0,0 +1,13 @@ +{% block swag_example_list %} + + + +{% endblock %} diff --git a/internal/extension/scaffolding/stubs/make/admin_module.js b/internal/extension/scaffolding/stubs/make/admin_module.js index b00c6bbee..3007a6e36 100644 --- a/internal/extension/scaffolding/stubs/make/admin_module.js +++ b/internal/extension/scaffolding/stubs/make/admin_module.js @@ -1,3 +1,5 @@ +import './page/swag-example-list'; + Shopware.Module.register('swag-example', { type: 'plugin', name: 'Example', @@ -11,23 +13,10 @@ Shopware.Module.register('swag-example', { component: 'swag-example-list', path: 'list' }, - detail: { - component: 'swag-example-detail', - path: 'detail/:id', - meta: { - parentPath: 'swag.example.list' - } - }, - create: { - component: 'swag-example-create', - path: 'create', - meta: { - parentPath: 'swag.example.list' - } - } }, navigation: [{ + parent: 'sw-catalogue', label: 'swag-example.general.mainMenuItemGeneral', color: '#ff3d58', path: 'swag.example.list', diff --git a/internal/extension/scaffolding/stubs/make/admin_snippet.json b/internal/extension/scaffolding/stubs/make/admin_snippet.json index 7e9772ac4..e9781dd43 100644 --- a/internal/extension/scaffolding/stubs/make/admin_snippet.json +++ b/internal/extension/scaffolding/stubs/make/admin_snippet.json @@ -2,7 +2,11 @@ "swag-example": { "general": { "mainMenuItemGeneral": "My custom module", - "descriptionTextModule": "Manage this custom module here" + "descriptionTextModule": "Manage this custom module here", + "list": { + "cardTitle": "My Custom Admin Component", + "cardText": "Hello Admin!" + } } } } diff --git a/internal/extension/scaffolding/stubs/make/entity_collection.php.tmpl b/internal/extension/scaffolding/stubs/make/entity_collection.php.tmpl index a0205b511..14ead22cb 100644 --- a/internal/extension/scaffolding/stubs/make/entity_collection.php.tmpl +++ b/internal/extension/scaffolding/stubs/make/entity_collection.php.tmpl @@ -5,13 +5,7 @@ namespace {{ .Namespace }}\Core\Content\{{ .EntityName }}; use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection; /** - * @method void add({{ .EntityName }}Entity $entity) - * @method void set(string $key, {{ .EntityName }}Entity $entity) - * @method {{ .EntityName }}Entity[] getIterator() - * @method {{ .EntityName }}Entity[] getElements() - * @method {{ .EntityName }}Entity|null get(string $key) - * @method {{ .EntityName }}Entity|null first() - * @method {{ .EntityName }}Entity|null last() +* @extends EntityCollection<{{ .EntityName }}Entity> */ class {{ .EntityName }}Collection extends EntityCollection { diff --git a/internal/extension/scaffolding/stubs/config.xml.tmpl b/internal/extension/scaffolding/stubs/make/plugin_config.xml similarity index 73% rename from internal/extension/scaffolding/stubs/config.xml.tmpl rename to internal/extension/scaffolding/stubs/make/plugin_config.xml index b45fed585..7505c062e 100644 --- a/internal/extension/scaffolding/stubs/config.xml.tmpl +++ b/internal/extension/scaffolding/stubs/make/plugin_config.xml @@ -3,6 +3,11 @@ + + Minimal configuration diff --git a/internal/extension/scaffolding/stubs/make/scheduled_task_handler.php.tmpl b/internal/extension/scaffolding/stubs/make/scheduled_task_handler.php.tmpl new file mode 100644 index 000000000..8d28c668f --- /dev/null +++ b/internal/extension/scaffolding/stubs/make/scheduled_task_handler.php.tmpl @@ -0,0 +1,16 @@ + [StoreApiRouteScope::ID]])] class ExampleRoute extends AbstractExampleRoute { + /** + * @param EntityRepository $productRepository + */ public function __construct(private readonly EntityRepository $productRepository) { }