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
56 changes: 55 additions & 1 deletion cmd/project/project_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
Expand Down Expand Up @@ -44,6 +45,50 @@ var gitlabCITemplate string

const versionLatest = "latest"

const (
// projectNameHelp is the help text shown under the project name input.
projectNameHelp = "The name of the project directory to create"
// projectNameRule describes which characters are allowed in a project name.
// It is shared between the up-front validation error and the live form hint
// so both stay in sync.
projectNameRule = "only lowercase letters, digits, dashes (-) and underscores (_) are allowed, and it must start with a lowercase letter or digit"
)

// composeProjectNameRegexp matches names that are valid as a Docker Compose
// project name. Docker Compose only allows lowercase letters, digits, dashes
// and underscores, and the name must start with a lowercase letter or digit.
// Anything else (uppercase letters, umlauts, spaces, dots, …) is rejected by
// Docker Compose once the generated Docker setup runs from the project
// directory, so we reject such project names up front.
var composeProjectNameRegexp = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)

// validateProjectName ensures the project folder name can be used as a Docker
// Compose project name. Only the final path element is relevant, as that is
// what Docker Compose uses to derive the project name.
func validateProjectName(name string) error {
base := filepath.Base(name)

if !composeProjectNameRegexp.MatchString(base) {
return fmt.Errorf("invalid project name %q: %s, so it can be used as a Docker Compose project name", base, projectNameRule)
}

return nil
}

// projectNameFieldDescription returns the description shown under the project
// name input in the interactive form. While the typed name is invalid it
// returns the rule highlighted in red, validating the input live; otherwise it
// returns the regular help text.
func projectNameFieldDescription(name string) string {
if name != "" {
if err := validateProjectName(name); err != nil {
return tui.RedText.Render(projectNameRule)
}
}

return projectNameHelp
}

var projectCreateCmd = &cobra.Command{
Use: "create [name] [version]",
Short: "Create a new Shopware 6 project",
Expand Down Expand Up @@ -220,13 +265,18 @@ var projectCreateCmd = &cobra.Command{
formGroups = append(formGroups, huh.NewGroup(
huh.NewInput().
Title("Project Name").
Description("The name of the project directory to create").
DescriptionFunc(func() string {
return projectNameFieldDescription(projectFolder)
}, &projectFolder).
Placeholder("my-shopware-project").
Value(&projectFolder).
Validate(func(s string) error {
if s == "" {
return fmt.Errorf("project name is required")
}
if err := validateProjectName(s); err != nil {
return err
}
if info, err := os.Stat(s); err == nil && info.IsDir() {
empty, err := system.IsDirEmpty(s)
if err != nil {
Expand Down Expand Up @@ -402,6 +452,10 @@ var projectCreateCmd = &cobra.Command{
}
}

if err := validateProjectName(projectFolder); err != nil {
return err
}

missingDeps := system.CheckProjectDependencies(cmd.Context(), useDocker)

validDeployments := map[string]bool{
Expand Down
78 changes: 78 additions & 0 deletions cmd/project/project_create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,84 @@ func TestResolveVersion(t *testing.T) {
})
}

func TestValidateProjectName(t *testing.T) {
t.Parallel()

validNames := []string{
"my-shopware-project",
"myshop",
"my_shop",
"shop123",
"123shop",
"a",
"path/to/my-shop",
}

for _, name := range validNames {
t.Run("valid: "+name, func(t *testing.T) {
t.Parallel()
assert.NoError(t, validateProjectName(name))
})
}

invalidNames := []string{
"MyShop",
"myShop",
"SHOP",
"müller",
"über-shop",
"Müller-Shop",
"café",
"straße",
"my shop",
"my.shop",
"shop!",
"-shop",
"_shop",
"ä",
"",
"path/to/müller",
"path/to/MyShop",
}

for _, name := range invalidNames {
t.Run("invalid: "+name, func(t *testing.T) {
t.Parallel()
err := validateProjectName(name)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid project name")
})
}
}

func TestProjectNameFieldDescription(t *testing.T) {
t.Parallel()

t.Run("empty shows help text", func(t *testing.T) {
t.Parallel()
assert.Equal(t, projectNameHelp, projectNameFieldDescription(""))
})

t.Run("valid name shows help text", func(t *testing.T) {
t.Parallel()
assert.Equal(t, projectNameHelp, projectNameFieldDescription("my-shop"))
})

t.Run("uppercase name shows the rule", func(t *testing.T) {
t.Parallel()
desc := projectNameFieldDescription("MyShop")
assert.NotEqual(t, projectNameHelp, desc)
assert.Contains(t, desc, projectNameRule)
})

t.Run("umlaut name shows the rule", func(t *testing.T) {
t.Parallel()
desc := projectNameFieldDescription("müller")
assert.NotEqual(t, projectNameHelp, desc)
assert.Contains(t, desc, projectNameRule)
})
}

func TestSetupDeployment(t *testing.T) {
t.Parallel()
t.Run("none creates no files", func(t *testing.T) {
Expand Down