Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions cmd/extension/extension_create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package extension

import (
"errors"
"fmt"

"github.com/spf13/cobra"
"github.com/spf13/pflag"

"github.com/shopware/shopware-cli/internal/extension"
"github.com/shopware/shopware-cli/internal/system"
)

// To ensure consistent naming the flag names are provided as constants
const NameFlagName = "name"
const TypeFlagName = "type"
const VendorFlagName = "vendor" // the user can choose to provide a vendor even if he did not enable --store
const StoreFlagName = "store"

func newCreateCmd() *cobra.Command {
Comment thread
shyim marked this conversation as resolved.
opts := &extension.CreateOptions{}

isProvided := make(map[string]bool)

cmd := &cobra.Command{
Use: "create",
Short: "Create a new extension",
Long: `Create a new plugin or theme with scaffolding inside a Shopware project.`,
Args: cobra.NoArgs,
PreRunE: func(cmd *cobra.Command, args []string) error {
// Collect provided flags
cmd.Flags().VisitAll(func(f *pflag.Flag) {
isProvided[f.Name] = cmd.Flags().Changed(f.Name)
})

interactive := system.IsInteractionEnabled(cmd.Context())

var errs error

// Validate the relationships of provided flags
err := validateFlagRelations(isProvided, opts.Store, interactive)
if err != nil {
errs = errors.Join(errs, fmt.Errorf("\n%w", err))
}

// Validate the values of provided flags
err = validateFlagValues(opts, isProvided)
if err != nil {
errs = errors.Join(errs, fmt.Errorf("\n%w", err))
}

if errs != nil {
return errs
}

return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
shouldRunForm := missing(isProvided, opts.Store) && system.IsInteractionEnabled(cmd.Context())

if shouldRunForm {
if err := runInteractiveCreateFormWithValidation(opts, isProvided); err != nil {
return fmt.Errorf("running create form: %w", err)
}
}

return extension.Create(cmd.Context(), *opts)
},
}

flags := cmd.Flags()
flags.StringVar(&opts.Name, NameFlagName, "", "Extension name (PascalCase)")
flags.StringVar(&opts.Vendor, VendorFlagName, "", "Vendor prefix (PascalCase) for the extension name and namespace. Required if --store is enabled.")
flags.BoolVar(&opts.Store, StoreFlagName, false, "Enable if you plan to publish the extension on the Shopware Community Store.")
flags.StringVarP((*string)(&opts.Type), TypeFlagName, "t", string(extension.Plugin), "Extension type (plugin|theme)")

_ = cmd.RegisterFlagCompletionFunc("type", cobra.FixedCompletions(
[]string{string(extension.Plugin), string(extension.Theme)},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
cobra.ShellCompDirectiveNoFileComp,
))

return cmd
}

func init() {
extensionRootCmd.AddCommand(newCreateCmd())
}

func missing(isProvided map[string]bool, store bool) bool {
required := []string{NameFlagName, TypeFlagName}

if isProvided[StoreFlagName] && store {
required = append(required, VendorFlagName)
}

for _, flagName := range required {
if !isProvided[flagName] {
return true
}
}

return false
}

func validateFlagRelations(isProvided map[string]bool, store bool, interactive bool) error {
if !interactive {
var errs error
requiredFlags := []string{NameFlagName, TypeFlagName}
for _, flagName := range requiredFlags {
if !isProvided[flagName] {
errs = errors.Join(errs, fmt.Errorf("required flag missing: --%s is required in non-interactive mode", flagName))
}
}

if store && !isProvided[VendorFlagName] {
errs = errors.Join(errs, errors.New("required flag missing: --vendor is required when --store is enabled"))
}

if errs != nil {
return errs
}
}
return nil
}

func validateFlagValues(opts *extension.CreateOptions, isProvided map[string]bool) error {
var errs []error

if isProvided[VendorFlagName] {
if err := extension.ValidateVendor(opts.Vendor); err != nil {
errs = append(errs, err)
}
}

if isProvided[NameFlagName] {
if err := extension.ValidateName(opts.Name); err != nil {
errs = append(errs, err)
}
}

if isProvided[TypeFlagName] {
if err := extension.ValidateType(opts.Type); err != nil {
errs = append(errs, err)
}
}

if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
88 changes: 88 additions & 0 deletions cmd/extension/extension_create_form.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package extension

import (
"charm.land/huh/v2"

"github.com/shopware/shopware-cli/internal/extension"
"github.com/shopware/shopware-cli/internal/tui"
)

func runInteractiveCreateFormWithValidation(opts *extension.CreateOptions, isProvided map[string]bool) error {
// Print the shopware banner
tui.PrintBanner()

// Define the theme for the interactive form.
theme := huh.ThemeFunc(func(isDark bool) *huh.Styles {
s := huh.ThemeCharm(isDark)
s.Focused.Title = s.Focused.Title.Foreground(tui.BlueColor)
s.Blurred.Title = s.Blurred.Title.Foreground(tui.BlueColor)
return s
})

// Create the form dynamically based on required input.
var groups []*huh.Group

if !isProvided[TypeFlagName] {
groups = append(groups,
huh.NewGroup(
huh.NewSelect[extension.ExtensionType]().
Title("Extension Type").
Description("Choose the type of extension you want to create.").
Options(
huh.NewOption("Plugin", extension.Plugin),
huh.NewOption("Theme", extension.Theme),
).
Value(&opts.Type),
),
)
}

if !isProvided[StoreFlagName] {
groups = append(groups,
huh.NewGroup(
huh.NewSelect[bool]().
Title("Do you plan to publish this extension in the Community Store?").
Description("This affects where the extension is created. Store extensions require a vendor-prefixed name.").
Options(
huh.NewOption("No, it's only for this project.", false),
huh.NewOption("Yes, I plan to publish it.", true),
).
Value(&opts.Store),
),
)
}

if !isProvided[VendorFlagName] {
groups = append(groups,
huh.NewGroup(
huh.NewInput().
Title("Vendor Prefix").
Description("Provide a vendor prefix in PascalCase.").
Placeholder("MyVendor").
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("Provide a name in PascalCase, e.g. MyExtension.").
Placeholder("MyExtension").
Value(&opts.Name).
Validate(extension.ValidateName),
),
)
}

if len(groups) == 0 {
return nil
}

return huh.NewForm(groups...).WithTheme(theme).Run()
}
44 changes: 44 additions & 0 deletions cmd/extension/extension_create_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package extension

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestValidateFlagRelations(t *testing.T) {
t.Run("non-interactive mode with missing flag", func(t *testing.T) {
err := validateFlagRelations(map[string]bool{
NameFlagName: false,
TypeFlagName: true,
}, false, false)

require.Error(t, err)
assert.ErrorContains(t, err, "--name")
})

t.Run("non-interactive mode with store enabled and vendor missing", func(t *testing.T) {
err := validateFlagRelations(map[string]bool{
NameFlagName: true,
TypeFlagName: true,
StoreFlagName: true,
}, true, false)

require.Error(t, err)
assert.ErrorContains(t, err, "--vendor")
})

t.Run("non-interactive mode with all required flags", func(t *testing.T) {
require.NoError(t, validateFlagRelations(map[string]bool{
NameFlagName: true,
TypeFlagName: true,
StoreFlagName: true,
VendorFlagName: true,
}, true, false))
})

t.Run("interactive mode does not require any flag", func(t *testing.T) {
require.NoError(t, validateFlagRelations(map[string]bool{}, true, true))
})
}
82 changes: 82 additions & 0 deletions internal/extension/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package extension

import (
"context"
"errors"
"fmt"
"path/filepath"

"github.com/shopware/shopware-cli/internal/extension/scaffolding"
"github.com/shopware/shopware-cli/internal/shop"
"github.com/shopware/shopware-cli/logging"
)

type ExtensionType string

const (
Plugin ExtensionType = "plugin"
Theme ExtensionType = "theme"
)

// CreateOptions contains the choices used to create extension scaffolding.
type CreateOptions struct {
Name string
Vendor string
Type ExtensionType
Store bool
}

// Create writes extension scaffolding in the closest Shopware project.
func Create(ctx context.Context, opts CreateOptions) (err error) {
logger := logging.FromContext(ctx)

logger.Info("Creating extension...")

projectDir, err := shop.FindClosestShopwareProject(false)
if err != nil {
return err
}

technicalName := deriveTechnicalName(opts.Name, opts.Vendor)
Comment thread
Ant1gua marked this conversation as resolved.
extensionDir := deriveExtensionDirectoryName(projectDir, opts.Store, technicalName)

err = scaffolding.CreateExtensionDir(extensionDir)
if err != nil {
return err
}

// Remove only the directory created above if a later step fails.
defer func() {
if err == nil {
return
}
logger.Debugf("Rollback of %s", extensionDir)
if cleanupErr := scaffolding.RemoveCreatedExtensionDir(extensionDir); cleanupErr != nil {
err = errors.Join(err, fmt.Errorf("rollback failed: %w", cleanupErr))
}
}()

if err = scaffolding.CreateExtensionFiles(extensionDir, opts.Name, opts.Vendor); err != nil {
Comment thread
Ant1gua marked this conversation as resolved.
return fmt.Errorf("create extension files: %w", err)
}

logger.Infof("✓ Extension successfully created in %s", extensionDir)

return nil
}

func deriveTechnicalName(name, vendor string) string {
if vendor == "" {
return name
}
return vendor + name
}

func deriveExtensionDirectoryName(projectDir string, store bool, technicalName string) string {
pluginDir := "static-plugins"
if store {
pluginDir = "plugins"
}

return filepath.Join(projectDir, "custom", pluginDir, technicalName)
}
Loading
Loading