diff --git a/.github/workflows/go_test.yml b/.github/workflows/go_test.yml
index f18383c7..f0dfed44 100644
--- a/.github/workflows/go_test.yml
+++ b/.github/workflows/go_test.yml
@@ -21,6 +21,16 @@ jobs:
runs-on: ubuntu-latest
steps:
+ - name: Harden Runner
+ uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # ratchet:step-security/harden-runner@v2.14.1
+ with:
+ egress-policy: block
+ allowed-endpoints: >
+ api.github.com:443
+ github.com:443
+ proxy.golang.org:443
+ release-assets.githubusercontent.com:443
+
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v4
@@ -31,8 +41,11 @@ jobs:
check-latest: true
cache: true
+ - name: Download modules
+ run: go mod download
+
- name: Build
run: go build -v ./...
- name: Test
- run: go test ./...
+ run: go test -v ./...
diff --git a/cmd/project/project_create.go b/cmd/project/project_create.go
index c970d87c..c3e4da88 100644
--- a/cmd/project/project_create.go
+++ b/cmd/project/project_create.go
@@ -1,7 +1,9 @@
package project
import (
+ "bytes"
"context"
+ _ "embed"
"encoding/json"
"fmt"
"io"
@@ -11,124 +13,298 @@ import (
"path/filepath"
"sort"
"strings"
+ "text/template"
"github.com/charmbracelet/huh"
"github.com/shyim/go-version"
"github.com/spf13/cobra"
+ "github.com/shopware/shopware-cli/internal/git"
"github.com/shopware/shopware-cli/internal/packagist"
"github.com/shopware/shopware-cli/internal/system"
"github.com/shopware/shopware-cli/logging"
)
+//go:embed static/deploy.php
+var deployerTemplate string
+
+//go:embed static/github-ci.yml
+var githubCITemplate string
+
+//go:embed static/github-deploy.yml
+var githubDeployTemplate string
+
+//go:embed static/gitlab-ci.yml.tmpl
+var gitlabCITemplate string
+
+const versionLatest = "latest"
+
var projectCreateCmd = &cobra.Command{
Use: "create [name] [version]",
Short: "Create a new Shopware 6 project",
- Args: cobra.MinimumNArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ Args: cobra.MaximumNArgs(2),
+ ValidArgsFunction: func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
+ if len(args) == 0 {
+ return []string{}, cobra.ShellCompDirectiveFilterDirs
+ }
+
if len(args) == 1 {
filteredVersions, err := getFilteredInstallVersions(cmd.Context())
if err != nil {
return []string{}, cobra.ShellCompDirectiveNoFileComp
}
- versions := make([]string, 0)
-
- for i, v := range filteredVersions {
- versions[i] = v.String()
+ versions := make([]string, 0, len(filteredVersions)+1)
+ versions = append(versions, versionLatest)
+ for _, v := range filteredVersions {
+ versions = append(versions, v.String())
}
-
- versions = append(versions, "latest")
-
return versions, cobra.ShellCompDirectiveNoFileComp
}
- return []string{}, cobra.ShellCompDirectiveFilterDirs
+ return []string{}, cobra.ShellCompDirectiveNoFileComp
},
RunE: func(cmd *cobra.Command, args []string) error {
- projectFolder := args[0]
-
useDocker, _ := cmd.PersistentFlags().GetBool("docker")
+ withElasticsearch, _ := cmd.PersistentFlags().GetBool("with-elasticsearch")
withoutElasticsearch, _ := cmd.PersistentFlags().GetBool("without-elasticsearch")
+ withAMQP, _ := cmd.PersistentFlags().GetBool("with-amqp")
noAudit, _ := cmd.PersistentFlags().GetBool("no-audit")
+ initGit, _ := cmd.PersistentFlags().GetBool("git")
+ versionFlag, _ := cmd.PersistentFlags().GetString("version")
+ deploymentMethod, _ := cmd.PersistentFlags().GetString("deployment")
+ ciSystem, _ := cmd.PersistentFlags().GetString("ci")
+
+ if cmd.PersistentFlags().Changed("without-elasticsearch") {
+ logging.FromContext(cmd.Context()).Warnf("Flag --without-elasticsearch is deprecated, use --with-elasticsearch instead")
+ withElasticsearch = !withoutElasticsearch
+ }
- if _, err := os.Stat(projectFolder); err == nil {
- empty, err := system.IsDirEmpty(projectFolder)
- if err != nil {
- return err
- }
+ interactive := system.IsInteractionEnabled(cmd.Context())
- if !empty {
- return fmt.Errorf("the folder %s exists already and is not empty", projectFolder)
- }
- }
+ const (
+ optionDocker = "docker"
+ optionGit = "git"
+ optionElasticsearch = "elasticsearch"
+ optionAMQP = "amqp"
- logging.FromContext(cmd.Context()).Infof("Using Symfony Flex to create a new Shopware 6 project")
+ ciNone = "none"
+ ciGitHub = "github"
+ ciGitLab = "gitlab"
+ )
filteredVersions, err := getFilteredInstallVersions(cmd.Context())
if err != nil {
return err
}
- var result string
+ versionOptions := make([]huh.Option[string], 0, len(filteredVersions)+1)
+ versionOptions = append(versionOptions, huh.NewOption(versionLatest, versionLatest))
+ for _, v := range filteredVersions {
+ versionStr := v.String()
+ versionOptions = append(versionOptions, huh.NewOption(versionStr, versionStr))
+ }
+
+ deploymentOptions := []huh.Option[string]{
+ huh.NewOption("None", packagist.DeploymentNone),
+ huh.NewOption("PaaS powered by Shopware", packagist.DeploymentShopwarePaaS),
+ huh.NewOption("DeployerPHP", packagist.DeploymentDeployer),
+ huh.NewOption("PaaS powered by Platform.sh", packagist.DeploymentPlatformSH),
+ }
+
+ ciOptions := []huh.Option[string]{
+ huh.NewOption("None", ciNone),
+ huh.NewOption("GitHub Actions", ciGitHub),
+ huh.NewOption("GitLab CI", ciGitLab),
+ }
- if len(args) == 2 {
- result = args[1]
- } else if !system.IsInteractionEnabled(cmd.Context()) {
- result = "latest"
+ var projectFolder string
+ selectedVersion := versionFlag
+ selectedDeployment := deploymentMethod
+ selectedCI := ciSystem
+ var selectedOptions []string
+
+ if len(args) > 0 {
+ projectFolder = args[0]
+ }
+
+ if len(args) > 1 && selectedVersion == "" {
+ selectedVersion = args[1]
+ }
+
+ if !interactive {
+ if projectFolder == "" {
+ return fmt.Errorf("project name is required in non-interactive mode")
+ }
+ if selectedVersion == "" {
+ selectedVersion = versionLatest
+ }
+ if selectedDeployment == "" {
+ selectedDeployment = packagist.DeploymentNone
+ }
+ if selectedCI == "" {
+ selectedCI = ciNone
+ }
+ if !cmd.PersistentFlags().Changed("with-elasticsearch") {
+ withElasticsearch = true
+ }
} else {
- options := make([]huh.Option[string], 0)
- for _, v := range filteredVersions {
- versionStr := v.String()
- options = append(options, huh.NewOption(versionStr, versionStr))
+ var formFields []huh.Field
+
+ if projectFolder == "" {
+ formFields = append(formFields,
+ huh.NewInput().
+ Title("Project Name").
+ Description("The name of the project directory to create").
+ Placeholder("my-shopware-project").
+ Value(&projectFolder).
+ Validate(func(s string) error {
+ if s == "" {
+ return fmt.Errorf("project name is required")
+ }
+ if info, err := os.Stat(s); err == nil && info.IsDir() {
+ empty, err := system.IsDirEmpty(s)
+ if err != nil {
+ return err
+ }
+ if !empty {
+ return fmt.Errorf("folder already exists and is not empty")
+ }
+ }
+ return nil
+ }),
+ )
}
- // Add "latest" option
- options = append(options, huh.NewOption("latest", "latest"))
-
- // Create and run the select form
- form := huh.NewForm(
- huh.NewGroup(
+ if selectedVersion == "" {
+ selectedVersion = versionLatest
+ formFields = append(formFields,
huh.NewSelect[string]().
+ Title("Shopware Version").
+ Description("Select the Shopware version to install").
+ Options(versionOptions...).
Height(10).
- Title("Select Version").
- Options(options...).
- Value(&result),
- ),
- )
+ Value(&selectedVersion),
+ )
+ }
- if err := form.Run(); err != nil {
- return err
+ if selectedDeployment == "" {
+ selectedDeployment = packagist.DeploymentNone
+ formFields = append(formFields,
+ huh.NewSelect[string]().
+ Title("Deployment Method").
+ Description("Select how you want to deploy your project").
+ Options(deploymentOptions...).
+ Value(&selectedDeployment),
+ )
}
- }
- chooseVersion := ""
+ if selectedCI == "" {
+ selectedCI = ciNone
+ formFields = append(formFields,
+ huh.NewSelect[string]().
+ Title("CI/CD System").
+ Description("Select your CI/CD platform for automated testing and deployment").
+ Options(ciOptions...).
+ Value(&selectedCI),
+ )
+ }
- if result == "latest" {
- // pick the most recent stable (non-RC) version
- for _, v := range filteredVersions {
- vs := v.String()
- if !strings.Contains(strings.ToLower(vs), "rc") {
- chooseVersion = vs
- break
- }
+ var optionalOptions []huh.Option[string]
+ if !cmd.PersistentFlags().Changed("git") {
+ optionalOptions = append(optionalOptions, huh.NewOption("Initialize Git Repository", optionGit).Selected(true))
}
- // if no stable found, fall back to top entry
- if chooseVersion == "" && len(filteredVersions) > 0 {
- chooseVersion = filteredVersions[0].String()
+ if !cmd.PersistentFlags().Changed("docker") {
+ optionalOptions = append(optionalOptions, huh.NewOption("Local Docker Setup", optionDocker).Selected(true))
}
- } else if strings.HasPrefix(result, "dev-") {
- chooseVersion = result
- } else {
- for _, release := range filteredVersions {
- if release.String() == result {
- chooseVersion = release.String()
- break
+ if !cmd.PersistentFlags().Changed("with-amqp") {
+ optionalOptions = append(optionalOptions, huh.NewOption("AMQP Queue Support", optionAMQP).Selected(true))
+ }
+ if !cmd.PersistentFlags().Changed("with-elasticsearch") {
+ optionalOptions = append(optionalOptions, huh.NewOption("Setup Elasticsearch/OpenSearch support", optionElasticsearch))
+ }
+
+ if len(optionalOptions) > 0 {
+ formFields = append(formFields,
+ huh.NewMultiSelect[string]().
+ Title("Optional").
+ Description("Select additional features to enable").
+ Options(optionalOptions...).
+ Value(&selectedOptions),
+ )
+ }
+
+ if len(formFields) > 0 {
+ form := huh.NewForm(huh.NewGroup(formFields...))
+ if err := form.Run(); err != nil {
+ return err
}
}
+
+ for _, opt := range selectedOptions {
+ switch opt {
+ case optionDocker:
+ useDocker = true
+ case optionGit:
+ initGit = true
+ case optionElasticsearch:
+ withElasticsearch = true
+ case optionAMQP:
+ withAMQP = true
+ }
+ }
+ }
+
+ if !useDocker {
+ phpOk, err := system.IsPHPVersionAtLeast(cmd.Context(), "8.2")
+ if err != nil {
+ return fmt.Errorf("PHP 8.2 or higher is required: %w", err)
+ }
+ if !phpOk {
+ return fmt.Errorf("PHP 8.2 or higher is required for Shopware 6")
+ }
+ }
+
+ validDeployments := map[string]bool{
+ packagist.DeploymentNone: true,
+ packagist.DeploymentDeployer: true,
+ packagist.DeploymentPlatformSH: true,
+ packagist.DeploymentShopwarePaaS: true,
+ }
+ if !validDeployments[selectedDeployment] {
+ return fmt.Errorf("invalid deployment method: %s. Valid options: none, deployer, platformsh, shopware-paas", selectedDeployment)
+ }
+
+ validCISystems := map[string]bool{
+ ciNone: true,
+ ciGitHub: true,
+ ciGitLab: true,
+ }
+ if !validCISystems[selectedCI] {
+ return fmt.Errorf("invalid CI system: %s. Valid options: none, github, gitlab", selectedCI)
+ }
+
+ if !useDocker {
+ if _, err := exec.LookPath("composer"); err != nil {
+ return fmt.Errorf("composer is not installed. Please install Composer (https://getcomposer.org/) or use the --docker flag")
+ }
+ }
+
+ if _, err := os.Stat(projectFolder); err == nil {
+ empty, err := system.IsDirEmpty(projectFolder)
+ if err != nil {
+ return err
+ }
+
+ if !empty {
+ return fmt.Errorf("the folder %s exists already and is not empty", projectFolder)
+ }
}
+ logging.FromContext(cmd.Context()).Infof("Using Symfony Flex to create a new Shopware 6 project")
+
+ chooseVersion := resolveVersion(selectedVersion, filteredVersions)
if chooseVersion == "" {
- return fmt.Errorf("cannot find version %s", result)
+ return fmt.Errorf("cannot find version %s", selectedVersion)
}
if err := os.MkdirAll(projectFolder, os.ModePerm); err != nil {
@@ -137,7 +313,20 @@ var projectCreateCmd = &cobra.Command{
logging.FromContext(cmd.Context()).Infof("Setting up Shopware %s", chooseVersion)
- composerJson, err := packagist.GenerateComposerJson(cmd.Context(), chooseVersion, strings.Contains(chooseVersion, "rc"), useDocker, withoutElasticsearch, noAudit)
+ // @todo: it's broken in paas deployments, the paas recipe configures Elasticsearch and it's difficult to do it only when elasticsearch is available.
+ if selectedDeployment == packagist.DeploymentShopwarePaaS {
+ withElasticsearch = true
+ }
+
+ composerJson, err := packagist.GenerateComposerJson(cmd.Context(), packagist.ComposerJsonOptions{
+ Version: chooseVersion,
+ RC: strings.Contains(chooseVersion, "rc"),
+ UseDocker: useDocker,
+ UseElasticsearch: withElasticsearch,
+ UseAMQP: withAMQP,
+ NoAudit: noAudit,
+ DeploymentMethod: selectedDeployment,
+ })
if err != nil {
return err
}
@@ -166,55 +355,167 @@ var projectCreateCmd = &cobra.Command{
return err
}
- if err := os.WriteFile(filepath.Join(projectFolder, "php.ini"), []byte("memory_limit=512M"), os.ModePerm); err != nil {
+ if !useDocker && system.IsSymfonyCliInstalled() {
+ if err := os.WriteFile(filepath.Join(projectFolder, "php.ini"), []byte("memory_limit=512M"), os.ModePerm); err != nil {
+ return err
+ }
+ }
+
+ if err := setupDeployment(projectFolder, selectedDeployment); err != nil {
+ return err
+ }
+
+ if err := setupCI(projectFolder, selectedCI, selectedDeployment); err != nil {
return err
}
logging.FromContext(cmd.Context()).Infof("Installing dependencies")
- var cmdInstall *exec.Cmd
+ if err := runComposerInstall(cmd.Context(), projectFolder, useDocker); err != nil {
+ return err
+ }
- if useDocker {
- // Use Docker to run composer
- absProjectFolder, err := filepath.Abs(projectFolder)
- if err != nil {
- return err
+ if initGit {
+ logging.FromContext(cmd.Context()).Infof("Initializing Git repository")
+ if err := git.Init(cmd.Context(), projectFolder); err != nil {
+ return fmt.Errorf("failed to initialize git repository: %w", err)
}
+ }
- dockerArgs := []string{"run", "--rm",
- "-v", fmt.Sprintf("%s:/app", absProjectFolder),
- "-w", "/app",
- "ghcr.io/shopware/docker-dev:php8.3-node22-caddy",
- "composer", "install", "--no-interaction"}
+ logging.FromContext(cmd.Context()).Infof("Project created successfully in %s", projectFolder)
- cmdInstall = exec.CommandContext(cmd.Context(), "docker", dockerArgs...)
- cmdInstall.Stdout = os.Stdout
- cmdInstall.Stderr = os.Stderr
+ return nil
+ },
+}
- return cmdInstall.Run()
- } else {
- // Use local composer
- composerBinary, err := exec.LookPath("composer")
- if err != nil {
- return err
+func resolveVersion(selectedVersion string, filteredVersions []*version.Version) string {
+ if selectedVersion == versionLatest {
+ // pick the most recent stable (non-RC) version
+ for _, v := range filteredVersions {
+ vs := v.String()
+ if !strings.Contains(strings.ToLower(vs), "rc") {
+ return vs
}
+ }
+ // if no stable found, fall back to top entry
+ if len(filteredVersions) > 0 {
+ return filteredVersions[0].String()
+ }
+ return ""
+ }
+
+ if strings.HasPrefix(selectedVersion, "dev-") {
+ return selectedVersion
+ }
+
+ for _, release := range filteredVersions {
+ if release.String() == selectedVersion {
+ return release.String()
+ }
+ }
+
+ return ""
+}
+
+func setupDeployment(projectFolder, deploymentMethod string) error {
+ switch deploymentMethod {
+ case packagist.DeploymentDeployer:
+ if err := os.WriteFile(filepath.Join(projectFolder, "deploy.php"), []byte(deployerTemplate), os.ModePerm); err != nil {
+ return err
+ }
+
+ case packagist.DeploymentShopwarePaaS:
+ shopwarePaasApp := `app:
+ php:
+ version: "8.4"
+services:
+ mysql:
+ version: "8.0"
+`
- phpBinary := os.Getenv("PHP_BINARY")
+ if err := os.WriteFile(filepath.Join(projectFolder, "application.yaml"), []byte(shopwarePaasApp), os.ModePerm); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
- if phpBinary != "" {
- cmdInstall = exec.CommandContext(cmd.Context(), phpBinary, composerBinary, "install", "--no-interaction")
- } else {
- cmdInstall = exec.CommandContext(cmd.Context(), "composer", "install", "--no-interaction")
+func setupCI(projectFolder, ciSystem, deploymentMethod string) error {
+ switch ciSystem {
+ case "github":
+ if err := os.MkdirAll(filepath.Join(projectFolder, ".github", "workflows"), os.ModePerm); err != nil {
+ return err
+ }
+ if err := os.WriteFile(filepath.Join(projectFolder, ".github", "workflows", "ci.yml"), []byte(githubCITemplate), os.ModePerm); err != nil {
+ return err
+ }
+ if deploymentMethod == packagist.DeploymentDeployer {
+ if err := os.WriteFile(filepath.Join(projectFolder, ".github", "workflows", "deploy.yml"), []byte(githubDeployTemplate), os.ModePerm); err != nil {
+ return err
}
+ }
+
+ case "gitlab":
+ tmpl, err := template.New("gitlab-ci").Parse(gitlabCITemplate)
+ if err != nil {
+ return err
+ }
- cmdInstall.Dir = projectFolder
- cmdInstall.Stdin = os.Stdin
- cmdInstall.Stdout = os.Stdout
- cmdInstall.Stderr = os.Stderr
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, struct{ Deployer bool }{Deployer: deploymentMethod == packagist.DeploymentDeployer}); err != nil {
+ return err
+ }
- return cmdInstall.Run()
+ if err := os.WriteFile(filepath.Join(projectFolder, ".gitlab-ci.yml"), buf.Bytes(), os.ModePerm); err != nil {
+ return err
}
- },
+ }
+
+ return nil
+}
+
+func runComposerInstall(ctx context.Context, projectFolder string, useDocker bool) error {
+ var cmdInstall *exec.Cmd
+
+ if useDocker && !system.IsInsideContainer() {
+ absProjectFolder, err := filepath.Abs(projectFolder)
+ if err != nil {
+ return err
+ }
+
+ dockerArgs := []string{"run", "--rm", "--pull=always",
+ "-v", fmt.Sprintf("%s:/app", absProjectFolder),
+ "-w", "/app",
+ "ghcr.io/shopware/docker-dev:php8.3-node22-caddy",
+ "composer", "install", "--no-interaction"}
+
+ cmdInstall = exec.CommandContext(ctx, "docker", dockerArgs...)
+ cmdInstall.Stdout = os.Stdout
+ cmdInstall.Stderr = os.Stderr
+
+ return cmdInstall.Run()
+ }
+
+ composerBinary, err := exec.LookPath("composer")
+ if err != nil {
+ return err
+ }
+
+ phpBinary := os.Getenv("PHP_BINARY")
+
+ if phpBinary != "" {
+ cmdInstall = exec.CommandContext(ctx, phpBinary, composerBinary, "install", "--no-interaction")
+ } else {
+ cmdInstall = exec.CommandContext(ctx, "composer", "install", "--no-interaction")
+ }
+
+ cmdInstall.Dir = projectFolder
+ cmdInstall.Stdin = os.Stdin
+ cmdInstall.Stdout = os.Stdout
+ cmdInstall.Stderr = os.Stderr
+
+ return cmdInstall.Run()
}
func getFilteredInstallVersions(ctx context.Context) ([]*version.Version, error) {
@@ -242,8 +543,14 @@ func getFilteredInstallVersions(ctx context.Context) ([]*version.Version, error)
func init() {
projectRootCmd.AddCommand(projectCreateCmd)
projectCreateCmd.PersistentFlags().Bool("docker", false, "Use Docker to run Composer instead of local installation")
- projectCreateCmd.PersistentFlags().Bool("without-elasticsearch", false, "Remove Elasticsearch from the installation")
+ projectCreateCmd.PersistentFlags().Bool("with-elasticsearch", false, "Include Elasticsearch/OpenSearch support")
+ projectCreateCmd.PersistentFlags().Bool("without-elasticsearch", false, "Remove Elasticsearch from the installation (deprecated: use --with-elasticsearch)")
+ projectCreateCmd.PersistentFlags().Bool("with-amqp", false, "Include AMQP queue support (symfony/amqp-messenger)")
projectCreateCmd.PersistentFlags().Bool("no-audit", false, "Disable composer audit blocking insecure packages")
+ projectCreateCmd.PersistentFlags().Bool("git", false, "Initialize a Git repository")
+ projectCreateCmd.PersistentFlags().String("version", "", "Shopware version to install (e.g., 6.6.0.0, latest)")
+ projectCreateCmd.PersistentFlags().String("deployment", "", "Deployment method: none, deployer, platformsh, shopware-paas")
+ projectCreateCmd.PersistentFlags().String("ci", "", "CI/CD system: none, github, gitlab")
}
func fetchAvailableShopwareVersions(ctx context.Context) ([]string, error) {
diff --git a/cmd/project/project_create_test.go b/cmd/project/project_create_test.go
new file mode 100644
index 00000000..a9d12932
--- /dev/null
+++ b/cmd/project/project_create_test.go
@@ -0,0 +1,228 @@
+package project
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/shyim/go-version"
+ "github.com/stretchr/testify/assert"
+
+ "github.com/shopware/shopware-cli/internal/packagist"
+)
+
+func TestResolveVersion(t *testing.T) {
+ t.Parallel()
+ versions := []*version.Version{
+ version.Must(version.NewVersion("6.6.1.0-rc1")),
+ version.Must(version.NewVersion("6.6.0.0")),
+ version.Must(version.NewVersion("6.5.8.0")),
+ version.Must(version.NewVersion("6.5.7.0")),
+ }
+
+ t.Run("latest selects most recent stable version", func(t *testing.T) {
+ t.Parallel()
+ result := resolveVersion(versionLatest, versions)
+ assert.Equal(t, "6.6.0.0", result)
+ })
+
+ t.Run("latest falls back to RC if no stable", func(t *testing.T) {
+ t.Parallel()
+ rcOnly := []*version.Version{
+ version.Must(version.NewVersion("6.7.0.0-rc2")),
+ version.Must(version.NewVersion("6.7.0.0-rc1")),
+ }
+ result := resolveVersion(versionLatest, rcOnly)
+ assert.Equal(t, "6.7.0.0-rc2", result)
+ })
+
+ t.Run("latest returns empty for empty list", func(t *testing.T) {
+ t.Parallel()
+ result := resolveVersion(versionLatest, []*version.Version{})
+ assert.Equal(t, "", result)
+ })
+
+ t.Run("exact version match", func(t *testing.T) {
+ t.Parallel()
+ result := resolveVersion("6.5.8.0", versions)
+ assert.Equal(t, "6.5.8.0", result)
+ })
+
+ t.Run("version not found returns empty", func(t *testing.T) {
+ t.Parallel()
+ result := resolveVersion("6.4.0.0", versions)
+ assert.Equal(t, "", result)
+ })
+
+ t.Run("dev version passes through", func(t *testing.T) {
+ t.Parallel()
+ result := resolveVersion("dev-trunk", versions)
+ assert.Equal(t, "dev-trunk", result)
+ })
+
+ t.Run("dev version with branch name", func(t *testing.T) {
+ t.Parallel()
+ result := resolveVersion("dev-6.6", versions)
+ assert.Equal(t, "dev-6.6", result)
+ })
+}
+
+func TestSetupDeployment(t *testing.T) {
+ t.Parallel()
+ t.Run("none creates no files", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ err := setupDeployment(tmpDir, packagist.DeploymentNone)
+ assert.NoError(t, err)
+
+ entries, err := os.ReadDir(tmpDir)
+ assert.NoError(t, err)
+ assert.Empty(t, entries)
+ })
+
+ t.Run("deployer creates deploy.php", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ err := setupDeployment(tmpDir, packagist.DeploymentDeployer)
+ assert.NoError(t, err)
+
+ assert.FileExists(t, filepath.Join(tmpDir, "deploy.php"))
+ content, err := os.ReadFile(filepath.Join(tmpDir, "deploy.php"))
+ assert.NoError(t, err)
+ assert.Equal(t, deployerTemplate, string(content))
+ })
+
+ t.Run("shopware-paas creates application.yaml", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ err := setupDeployment(tmpDir, packagist.DeploymentShopwarePaaS)
+ assert.NoError(t, err)
+
+ assert.FileExists(t, filepath.Join(tmpDir, "application.yaml"))
+ content, err := os.ReadFile(filepath.Join(tmpDir, "application.yaml"))
+ assert.NoError(t, err)
+ assert.Contains(t, string(content), "php:")
+ assert.Contains(t, string(content), "mysql:")
+ })
+
+ t.Run("platformsh creates no files", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ err := setupDeployment(tmpDir, packagist.DeploymentPlatformSH)
+ assert.NoError(t, err)
+
+ entries, err := os.ReadDir(tmpDir)
+ assert.NoError(t, err)
+ assert.Empty(t, entries)
+ })
+}
+
+func TestSetupCI(t *testing.T) {
+ t.Parallel()
+ t.Run("none creates no files", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ err := setupCI(tmpDir, "none", packagist.DeploymentNone)
+ assert.NoError(t, err)
+
+ entries, err := os.ReadDir(tmpDir)
+ assert.NoError(t, err)
+ assert.Empty(t, entries)
+ })
+
+ t.Run("github creates workflow directory and ci.yml", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ err := setupCI(tmpDir, "github", packagist.DeploymentNone)
+ assert.NoError(t, err)
+
+ assert.DirExists(t, filepath.Join(tmpDir, ".github", "workflows"))
+ assert.FileExists(t, filepath.Join(tmpDir, ".github", "workflows", "ci.yml"))
+ assert.NoFileExists(t, filepath.Join(tmpDir, ".github", "workflows", "deploy.yml"))
+ })
+
+ t.Run("github with deployer creates deploy.yml", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ err := setupCI(tmpDir, "github", packagist.DeploymentDeployer)
+ assert.NoError(t, err)
+
+ assert.FileExists(t, filepath.Join(tmpDir, ".github", "workflows", "ci.yml"))
+ assert.FileExists(t, filepath.Join(tmpDir, ".github", "workflows", "deploy.yml"))
+ })
+
+ t.Run("gitlab creates .gitlab-ci.yml", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ err := setupCI(tmpDir, "gitlab", packagist.DeploymentNone)
+ assert.NoError(t, err)
+
+ assert.FileExists(t, filepath.Join(tmpDir, ".gitlab-ci.yml"))
+ })
+
+ t.Run("gitlab with deployer includes deploy config", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ err := setupCI(tmpDir, "gitlab", packagist.DeploymentDeployer)
+ assert.NoError(t, err)
+
+ content, err := os.ReadFile(filepath.Join(tmpDir, ".gitlab-ci.yml"))
+ assert.NoError(t, err)
+ assert.Contains(t, string(content), "deploy")
+ })
+}
+
+func TestValidDeploymentMethods(t *testing.T) {
+ t.Parallel()
+ validDeployments := map[string]bool{
+ packagist.DeploymentNone: true,
+ packagist.DeploymentDeployer: true,
+ packagist.DeploymentPlatformSH: true,
+ packagist.DeploymentShopwarePaaS: true,
+ }
+
+ t.Run("all deployment constants are valid", func(t *testing.T) {
+ t.Parallel()
+ assert.True(t, validDeployments[packagist.DeploymentNone])
+ assert.True(t, validDeployments[packagist.DeploymentDeployer])
+ assert.True(t, validDeployments[packagist.DeploymentPlatformSH])
+ assert.True(t, validDeployments[packagist.DeploymentShopwarePaaS])
+ })
+
+ t.Run("invalid deployment is rejected", func(t *testing.T) {
+ t.Parallel()
+ assert.False(t, validDeployments["invalid"])
+ assert.False(t, validDeployments[""])
+ })
+}
+
+func TestValidCISystems(t *testing.T) {
+ t.Parallel()
+ validCISystems := map[string]bool{
+ "none": true,
+ "github": true,
+ "gitlab": true,
+ }
+
+ t.Run("all CI constants are valid", func(t *testing.T) {
+ t.Parallel()
+ assert.True(t, validCISystems["none"])
+ assert.True(t, validCISystems["github"])
+ assert.True(t, validCISystems["gitlab"])
+ })
+
+ t.Run("invalid CI system is rejected", func(t *testing.T) {
+ t.Parallel()
+ assert.False(t, validCISystems["jenkins"])
+ assert.False(t, validCISystems[""])
+ })
+}
diff --git a/cmd/project/static/deploy.php b/cmd/project/static/deploy.php
new file mode 100644
index 00000000..3ee8cdfc
--- /dev/null
+++ b/cmd/project/static/deploy.php
@@ -0,0 +1,98 @@
+setLabels([
+ 'type' => 'web',
+ 'env' => 'production',
+ ])
+ ->setRemoteUser('www-data')
+ ->set('deploy_path', '/var/www/shopware')
+ ->set('http_user', 'www-data') // Not needed, if the `user` is the same, the webserver is running with
+ ->set('writable_mode', 'chmod')
+ ->set('keep_releases', 3); // Keeps 3 old releases for rollbacks (if no DB migrations were executed)
+
+// These files are shared among all releases.
+set('shared_files', [
+ '.env.local',
+ 'install.lock',
+ 'public/.htaccess',
+ 'public/.user.ini',
+]);
+
+// These directories are shared among all releases.
+set('shared_dirs', [
+ 'config/jwt',
+ 'files',
+ 'var/log',
+ 'public/media',
+ 'public/plugins',
+ 'public/thumbnail',
+ 'public/sitemap',
+]);
+
+// These directories are made writable (the definition of "writable" requires attention).
+// Please note that the files in `config/jwt/*` receive special attention in the `sw:writable:jwt` task.
+set('writable_dirs', [
+ 'config/jwt',
+ 'custom/plugins',
+ 'files',
+ 'public/bundles',
+ 'public/css',
+ 'public/fonts',
+ 'public/js',
+ 'public/media',
+ 'public/sitemap',
+ 'public/theme',
+ 'public/thumbnail',
+ 'var',
+]);
+
+task('sw:deployment:helper', static function() {
+ run('cd {{release_path}} && vendor/bin/shopware-deployment-helper run');
+});
+
+task('sw:touch_install_lock', static function () {
+ run('cd {{release_path}} && touch install.lock');
+});
+
+task('sw:health_checks', static function () {
+ run('cd {{release_path}} && bin/console system:check --context=pre_rollout');
+});
+
+desc('Deploys your project');
+task('deploy', [
+ 'deploy:prepare',
+ 'deploy:clear_paths',
+ 'sw:deployment:helper',
+ "sw:touch_install_lock",
+ 'sw:health_checks',
+ 'deploy:publish',
+]);
+
+task('deploy:update_code')->setCallback(static function () {
+ upload('.', '{{release_path}}', [
+ 'options' => [
+ '--exclude=.git',
+ '--exclude=deploy.php',
+ '--exclude=node_modules',
+ ],
+ ]);
+});
+
+// Hooks
+after('deploy:failed', 'deploy:unlock');
+after('deploy:symlink', 'cachetool:clear:opcache');
diff --git a/cmd/project/static/github-ci.yml b/cmd/project/static/github-ci.yml
new file mode 100644
index 00000000..12e4d002
--- /dev/null
+++ b/cmd/project/static/github-ci.yml
@@ -0,0 +1,17 @@
+name: Check
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Project Validate
+ uses: shopware/github-actions/project-validate@main
+ with:
+ phpVersion: '8.4'
diff --git a/cmd/project/static/github-deploy.yml b/cmd/project/static/github-deploy.yml
new file mode 100644
index 00000000..6f98fe70
--- /dev/null
+++ b/cmd/project/static/github-deploy.yml
@@ -0,0 +1,14 @@
+name: Deployment
+
+on:
+ workflow_dispatch:
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Deploy
+ uses: shopware/github-actions/project-deployer@main
+ with:
+ sshPrivateKey: ${{ secrets.SSH_PRIVATE_KEY }}
diff --git a/cmd/project/static/gitlab-ci.yml.tmpl b/cmd/project/static/gitlab-ci.yml.tmpl
new file mode 100644
index 00000000..a6f47061
--- /dev/null
+++ b/cmd/project/static/gitlab-ci.yml.tmpl
@@ -0,0 +1,15 @@
+stages:
+ - check
+{{- if .Deployer }}
+ - deploy
+{{- end }}
+
+include:
+ - component: gitlab.com/shopware/ci-components/project-validate@main
+ inputs:
+ php_version: "8.4"
+{{- if .Deployer }}
+ - component: gitlab.com/shopware/ci-components/project-deploy@main
+ inputs:
+ php_version: "8.4"
+{{- end }}
diff --git a/cmd/root.go b/cmd/root.go
index 01989969..836dc0c8 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -5,6 +5,7 @@ import (
"os"
"slices"
+ "github.com/mattn/go-isatty"
"github.com/spf13/cobra"
"github.com/shopware/shopware-cli/cmd/account"
@@ -30,7 +31,7 @@ var rootCmd = &cobra.Command{
func Execute(ctx context.Context) {
ctx = logging.WithLogger(ctx, logging.NewLogger(slices.Contains(os.Args, "--verbose")))
- ctx = system.WithInteraction(ctx, !slices.Contains(os.Args, "--no-interaction") && !slices.Contains(os.Args, "-n"))
+ ctx = system.WithInteraction(ctx, !slices.Contains(os.Args, "--no-interaction") && !slices.Contains(os.Args, "-n") && isatty.IsTerminal(os.Stdin.Fd()))
accountApi.SetUserAgent("shopware-cli/" + version)
if err := rootCmd.ExecuteContext(ctx); err != nil {
diff --git a/go.mod b/go.mod
index 38badc2c..d0a07fe5 100644
--- a/go.mod
+++ b/go.mod
@@ -19,6 +19,7 @@ require (
github.com/invopop/jsonschema v0.13.0
github.com/jaswdr/faker/v2 v2.9.1
github.com/joho/godotenv v1.5.1
+ github.com/mattn/go-isatty v0.0.20
github.com/microcosm-cc/bluemonday v1.0.27
github.com/otiai10/copy v1.14.1
github.com/shyim/go-version v0.0.0-20250828113848-97ec77491b32
@@ -72,7 +73,6 @@ require (
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
- github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
diff --git a/internal/changelog/changelog_test.go b/internal/changelog/changelog_test.go
index f45e08c8..0c2bfd66 100644
--- a/internal/changelog/changelog_test.go
+++ b/internal/changelog/changelog_test.go
@@ -9,6 +9,7 @@ import (
)
func TestGenerateWithoutConfig(t *testing.T) {
+ t.Parallel()
commits := []git.GitCommit{
{
Message: "feat: add new feature",
@@ -27,6 +28,7 @@ func TestGenerateWithoutConfig(t *testing.T) {
}
func TestTicketParsing(t *testing.T) {
+ t.Parallel()
commits := []git.GitCommit{
{
Message: "NEXT-1234 - Fooo",
@@ -48,6 +50,7 @@ func TestTicketParsing(t *testing.T) {
}
func TestIncludeFilters(t *testing.T) {
+ t.Parallel()
commits := []git.GitCommit{
{
Message: "NEXT-1234 - Fooo",
diff --git a/internal/color/color.go b/internal/color/color.go
index b525b1cf..0e7b727e 100644
--- a/internal/color/color.go
+++ b/internal/color/color.go
@@ -2,4 +2,22 @@ package color
import "github.com/charmbracelet/lipgloss"
-var GreenText = lipgloss.NewStyle().Foreground(lipgloss.Color("#04B575"))
+var GreenText = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
+ Light: "#047857",
+ Dark: "#04B575",
+})
+
+var RecommendedText = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
+ Light: "#047857",
+ Dark: "#04B575",
+}).Bold(true)
+
+var SecondaryText = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
+ Light: "#B8860B",
+ Dark: "#FFD700",
+}).Bold(true)
+
+var NeutralText = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
+ Light: "#1F2937",
+ Dark: "#FFFFFF",
+})
diff --git a/internal/curl/curl_wrapper_test.go b/internal/curl/curl_wrapper_test.go
index 7f5a6481..5bc555cd 100644
--- a/internal/curl/curl_wrapper_test.go
+++ b/internal/curl/curl_wrapper_test.go
@@ -8,13 +8,16 @@ import (
)
func TestInitCurlCommand(t *testing.T) {
+ t.Parallel()
t.Run("empty command", func(t *testing.T) {
+ t.Parallel()
cmd := InitCurlCommand()
assert.Empty(t, cmd.options)
assert.Empty(t, cmd.args)
})
t.Run("with method", func(t *testing.T) {
+ t.Parallel()
cmd := InitCurlCommand(Method("POST"))
assert.Len(t, cmd.options, 1)
assert.Equal(t, "-X", cmd.options[0].flag)
@@ -22,6 +25,7 @@ func TestInitCurlCommand(t *testing.T) {
})
t.Run("with bearer token", func(t *testing.T) {
+ t.Parallel()
cmd := InitCurlCommand(BearerToken("test-token"))
assert.Len(t, cmd.options, 1)
assert.Equal(t, "--header", cmd.options[0].flag)
@@ -29,12 +33,14 @@ func TestInitCurlCommand(t *testing.T) {
})
t.Run("with custom args", func(t *testing.T) {
+ t.Parallel()
cmd := InitCurlCommand(Args([]string{"-v"}))
assert.Empty(t, cmd.options)
assert.Equal(t, []string{"-v"}, cmd.args)
})
t.Run("with URL", func(t *testing.T) {
+ t.Parallel()
u, _ := url.Parse("https://example.com")
cmd := InitCurlCommand(Url(u))
assert.Empty(t, cmd.options)
@@ -42,6 +48,7 @@ func TestInitCurlCommand(t *testing.T) {
})
t.Run("with header", func(t *testing.T) {
+ t.Parallel()
cmd := InitCurlCommand(Header("Content-Type", "application/json"))
assert.Len(t, cmd.options, 1)
assert.Equal(t, "--header", cmd.options[0].flag)
@@ -49,6 +56,7 @@ func TestInitCurlCommand(t *testing.T) {
})
t.Run("with multiple options", func(t *testing.T) {
+ t.Parallel()
u, _ := url.Parse("https://example.com")
cmd := InitCurlCommand(
Method("POST"),
@@ -72,12 +80,15 @@ func TestInitCurlCommand(t *testing.T) {
}
func TestCommand_getCmdOptions(t *testing.T) {
+ t.Parallel()
t.Run("empty command", func(t *testing.T) {
+ t.Parallel()
cmd := &Command{}
assert.Empty(t, cmd.getCmdOptions())
})
t.Run("with options and args", func(t *testing.T) {
+ t.Parallel()
cmd := &Command{
options: []curlOption{
{flag: "-X", value: "POST"},
@@ -96,12 +107,14 @@ func TestCommand_getCmdOptions(t *testing.T) {
}
func TestArgs_WithExistingArgs(t *testing.T) {
+ t.Parallel()
cmd := &Command{args: []string{"existing"}}
Args([]string{"new"})(cmd)
assert.Equal(t, []string{"existing", "new"}, cmd.args)
}
func TestUrl_WithExistingArgs(t *testing.T) {
+ t.Parallel()
cmd := &Command{args: []string{"existing"}}
u, _ := url.Parse("https://example.com")
Url(u)(cmd)
diff --git a/internal/esbuild/util_test.go b/internal/esbuild/util_test.go
index 92462050..dc0c7fdf 100644
--- a/internal/esbuild/util_test.go
+++ b/internal/esbuild/util_test.go
@@ -7,6 +7,7 @@ import (
)
func TestKebabCase(t *testing.T) {
+ t.Parallel()
assert.Equal(t, "foo-bar", ToKebabCase("FooBar"))
assert.Equal(t, "f-o-o-bar-baz", ToKebabCase("FOOBarBaz"))
assert.Equal(t, "frosh-tools", ToKebabCase("FroshTools"))
@@ -18,6 +19,7 @@ func TestKebabCase(t *testing.T) {
}
func TestBundleFolderName(t *testing.T) {
+ t.Parallel()
assert.Equal(t, "myplugin", toBundleFolderName("MyPluginBundle"))
assert.Equal(t, "anotherplugin", toBundleFolderName("AnotherPluginBundle"))
assert.Equal(t, "simpleplugin", toBundleFolderName("SimplePlugin"))
diff --git a/internal/extension/platform.go b/internal/extension/platform.go
index 7ebb7864..44976a1b 100644
--- a/internal/extension/platform.go
+++ b/internal/extension/platform.go
@@ -382,8 +382,11 @@ func validatePHPFiles(c context.Context, ext Extension, check validation.Check)
}
}
+// phpVersionURL can be overridden in tests to use a mock server
+var phpVersionURL = "https://raw.githubusercontent.com/FriendsOfShopware/shopware-static-data/main/data/php-version.json"
+
func GetPhpVersion(ctx context.Context, constraint *version.Constraints) (string, error) {
- r, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://raw.githubusercontent.com/FriendsOfShopware/shopware-static-data/main/data/php-version.json", http.NoBody)
+ r, _ := http.NewRequestWithContext(ctx, http.MethodGet, phpVersionURL, http.NoBody)
resp, err := http.DefaultClient.Do(r)
if err != nil {
diff --git a/internal/extension/platform_test.go b/internal/extension/platform_test.go
index d980bde9..a4cfd95f 100644
--- a/internal/extension/platform_test.go
+++ b/internal/extension/platform_test.go
@@ -1,6 +1,8 @@
package extension
import (
+ "net/http"
+ "net/http/httptest"
"os"
"path/filepath"
"testing"
@@ -8,6 +10,21 @@ import (
"github.com/stretchr/testify/assert"
)
+func setupMockPHPVersionServer(t *testing.T) {
+ t.Helper()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"6.4.0.0": "7.4", "6.5.0.0": "8.1", "6.6.0.0": "8.2"}`))
+ }))
+ t.Cleanup(server.Close)
+
+ original := phpVersionURL
+ phpVersionURL = server.URL
+ t.Cleanup(func() {
+ phpVersionURL = original
+ })
+}
+
func getTestPlugin(tempDir string) PlatformPlugin {
return PlatformPlugin{
path: tempDir,
@@ -55,6 +72,7 @@ func getTestPlugin(tempDir string) PlatformPlugin {
}
func TestPluginIconNotExists(t *testing.T) {
+ setupMockPHPVersionServer(t)
dir := t.TempDir()
plugin := getTestPlugin(dir)
@@ -68,6 +86,7 @@ func TestPluginIconNotExists(t *testing.T) {
}
func TestPluginIconExists(t *testing.T) {
+ setupMockPHPVersionServer(t)
dir := t.TempDir()
plugin := getTestPlugin(dir)
@@ -83,6 +102,7 @@ func TestPluginIconExists(t *testing.T) {
}
func TestPluginIconDifferntPathExists(t *testing.T) {
+ setupMockPHPVersionServer(t)
dir := t.TempDir()
plugin := getTestPlugin(dir)
@@ -98,6 +118,7 @@ func TestPluginIconDifferntPathExists(t *testing.T) {
}
func TestPluginIconIsTooBig(t *testing.T) {
+ setupMockPHPVersionServer(t)
dir := t.TempDir()
plugin := getTestPlugin(dir)
@@ -114,6 +135,7 @@ func TestPluginIconIsTooBig(t *testing.T) {
}
func TestPluginGermanDescriptionMissing(t *testing.T) {
+ setupMockPHPVersionServer(t)
dir := t.TempDir()
plugin := getTestPlugin(dir)
@@ -132,6 +154,7 @@ func TestPluginGermanDescriptionMissing(t *testing.T) {
}
func TestPluginGermanDescriptionMissingOnlyEnglishMarket(t *testing.T) {
+ setupMockPHPVersionServer(t)
dir := t.TempDir()
plugin := getTestPlugin(dir)
diff --git a/internal/flexmigrator/cleanup_test.go b/internal/flexmigrator/cleanup_test.go
index 3a0c8367..4746af9e 100644
--- a/internal/flexmigrator/cleanup_test.go
+++ b/internal/flexmigrator/cleanup_test.go
@@ -12,7 +12,9 @@ import (
)
func TestCleanup(t *testing.T) {
+ t.Parallel()
t.Run("remove existing files", func(t *testing.T) {
+ t.Parallel()
// Create a temporary directory for the test
tempDir := t.TempDir()
@@ -52,6 +54,7 @@ func TestCleanup(t *testing.T) {
})
t.Run("remove existing directories", func(t *testing.T) {
+ t.Parallel()
// Create a temporary directory for the test
tempDir := t.TempDir()
@@ -95,6 +98,7 @@ func TestCleanup(t *testing.T) {
})
t.Run("remove files by MD5", func(t *testing.T) {
+ t.Parallel()
// Create a temporary directory for the test
tempDir := t.TempDir()
@@ -152,6 +156,7 @@ func TestCleanup(t *testing.T) {
})
t.Run("handle non-existent files and directories", func(t *testing.T) {
+ t.Parallel()
// Create a temporary directory for the test
tempDir := t.TempDir()
@@ -161,6 +166,7 @@ func TestCleanup(t *testing.T) {
})
t.Run("handle files with non-matching MD5", func(t *testing.T) {
+ t.Parallel()
// Create a temporary directory for the test
tempDir := t.TempDir()
diff --git a/internal/flexmigrator/composer_test.go b/internal/flexmigrator/composer_test.go
index 89f0ea4d..e6bdf38d 100644
--- a/internal/flexmigrator/composer_test.go
+++ b/internal/flexmigrator/composer_test.go
@@ -13,7 +13,9 @@ import (
)
func TestMigrateComposerJson(t *testing.T) {
+ t.Parallel()
t.Run("successful migration", func(t *testing.T) {
+ t.Parallel()
// Create a temporary directory for the test
tempDir := t.TempDir()
@@ -106,12 +108,14 @@ func TestMigrateComposerJson(t *testing.T) {
})
t.Run("non-existent composer.json", func(t *testing.T) {
+ t.Parallel()
tempDir := t.TempDir()
err := MigrateComposerJson(tempDir)
assert.Error(t, err)
})
t.Run("invalid composer.json", func(t *testing.T) {
+ t.Parallel()
tempDir := t.TempDir()
composerFile := filepath.Join(tempDir, "composer.json")
err := os.WriteFile(composerFile, []byte("invalid json"), 0o644)
diff --git a/internal/flexmigrator/env_test.go b/internal/flexmigrator/env_test.go
index e7674f72..4af4d6b4 100644
--- a/internal/flexmigrator/env_test.go
+++ b/internal/flexmigrator/env_test.go
@@ -10,7 +10,9 @@ import (
)
func TestMigrateEnv(t *testing.T) {
+ t.Parallel()
t.Run("successful migration with only .env", func(t *testing.T) {
+ t.Parallel()
// Create a temporary directory for the test
tempDir := t.TempDir()
@@ -35,6 +37,7 @@ func TestMigrateEnv(t *testing.T) {
})
t.Run("no migration needed when .env.local exists", func(t *testing.T) {
+ t.Parallel()
// Create a temporary directory for the test
tempDir := t.TempDir()
@@ -62,6 +65,7 @@ func TestMigrateEnv(t *testing.T) {
})
t.Run("no migration needed when no files exist", func(t *testing.T) {
+ t.Parallel()
// Create a temporary directory for the test
tempDir := t.TempDir()
@@ -77,6 +81,7 @@ func TestMigrateEnv(t *testing.T) {
})
t.Run("no migration needed when only .env.local exists", func(t *testing.T) {
+ t.Parallel()
// Create a temporary directory for the test
tempDir := t.TempDir()
diff --git a/internal/git/git.go b/internal/git/git.go
index 56c45053..d6e96883 100644
--- a/internal/git/git.go
+++ b/internal/git/git.go
@@ -195,3 +195,8 @@ func unshallowRepository(ctx context.Context, repo string) error {
return err
}
+
+func Init(ctx context.Context, repo string) error {
+ _, err := runGit(ctx, repo, "init")
+ return err
+}
diff --git a/internal/git/git_test.go b/internal/git/git_test.go
index 4476c1d4..379c7548 100644
--- a/internal/git/git_test.go
+++ b/internal/git/git_test.go
@@ -10,6 +10,7 @@ import (
)
func TestInvalidGitRepository(t *testing.T) {
+ t.Parallel()
repo := "invalid"
ctx := t.Context()
@@ -19,6 +20,7 @@ func TestInvalidGitRepository(t *testing.T) {
}
func TestNoTags(t *testing.T) {
+ t.Parallel()
tmpDir := t.TempDir()
prepareRepository(t, tmpDir)
_ = os.WriteFile(filepath.Join(tmpDir, "a"), []byte(""), os.ModePerm)
@@ -39,6 +41,7 @@ func TestNoTags(t *testing.T) {
}
func TestWithOneTagAndCommit(t *testing.T) {
+ t.Parallel()
tmpDir := t.TempDir()
prepareRepository(t, tmpDir)
_ = os.WriteFile(filepath.Join(tmpDir, "a"), []byte(""), os.ModePerm)
diff --git a/internal/html/parser_test.go b/internal/html/parser_test.go
index fec0adaa..d643e432 100644
--- a/internal/html/parser_test.go
+++ b/internal/html/parser_test.go
@@ -10,6 +10,7 @@ import (
)
func TestFormattingOfHTML(t *testing.T) {
+ t.Parallel()
swBlock := &ElementNode{
Tag: "sw-button",
Attributes: NodeList{
@@ -110,6 +111,7 @@ func TestFormatting(t *testing.T) {
}
func TestChangeElement(t *testing.T) {
+ t.Parallel()
node, err := NewParser(``)
assert.NoError(t, err)
TraverseNode(node, func(n *ElementNode) {
@@ -131,6 +133,7 @@ func TestChangeElement(t *testing.T) {
}
func TestBlockParsing(t *testing.T) {
+ t.Parallel()
input := `{% block name %}{% endblock %}`
node, err := NewParser(input)
diff --git a/internal/packagist/project_composer_json.go b/internal/packagist/project_composer_json.go
index acf8639c..646f3ff6 100644
--- a/internal/packagist/project_composer_json.go
+++ b/internal/packagist/project_composer_json.go
@@ -1,135 +1,208 @@
package packagist
import (
- "bytes"
"context"
+ "encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
- "text/template"
"github.com/shopware/shopware-cli/logging"
)
-func GenerateComposerJson(ctx context.Context, version string, rc bool, useDocker bool, withoutElasticsearch bool, noAudit bool) (string, error) {
- tplContent, err := template.New("composer.json").Parse(`{
- "name": "shopware/production",
- "license": "MIT",
- "type": "project",
- "require": {
- "composer-runtime-api": "^2.0",
- "shopware/administration": "{{ .DependingVersions }}",
- "shopware/core": "{{ .Version }}",
- {{if .UseElasticsearch}}
- "shopware/elasticsearch": "{{ .DependingVersions }}",
- {{end}}
- "shopware/storefront": "{{ .DependingVersions }}",
- {{if .UseDocker}}
- "shopware/docker-dev": "*",
- {{end}}
- "symfony/flex": "~2"
- },
- "repositories": [
- {
- "type": "path",
- "url": "custom/plugins/*",
- "options": {
- "symlink": true
- }
- },
- {
- "type": "path",
- "url": "custom/plugins/*/packages/*",
- "options": {
- "symlink": true
- }
- },
- {
- "type": "path",
- "url": "custom/static-plugins/*",
- "options": {
- "symlink": true
- }
- }
- ],
- "autoload": {
- "psr-4": {
- "App\\": "src/"
- }
- },
- {{if .RC}}
- "minimum-stability": "RC",
- {{end}}
- "prefer-stable": true,
- "config": {
- "allow-plugins": {
- "symfony/flex": true,
- "symfony/runtime": true
- },
- "optimize-autoloader": true,
- "sort-packages": true{{if .NoAudit}},
- "audit": {
- "block-insecure": false
- }
- {{end}}
- },
- "scripts": {
- "auto-scripts": [
- ],
- "post-install-cmd": [
- "@auto-scripts"
- ],
- "post-update-cmd": [
- "@auto-scripts"
- ]
- },
- "extra": {
- "symfony": {
- "allow-contrib": true,
- "endpoint": [
- "https://raw.githubusercontent.com/shopware/recipes/flex/main/index.json",
- "flex://defaults"
- ]
- }
- }
-}`)
- if err != nil {
- return "", err
- }
+const (
+ DeploymentNone = "none"
+ DeploymentDeployer = "deployer"
+ DeploymentPlatformSH = "platformsh"
+ DeploymentShopwarePaaS = "shopware-paas"
+)
+
+type ComposerJsonOptions struct {
+ Version string
+ DependingVersion string
+ RC bool
+ UseDocker bool
+ UseElasticsearch bool
+ UseAMQP bool
+ NoAudit bool
+ DeploymentMethod string
+}
- buf := new(bytes.Buffer)
+func (o ComposerJsonOptions) IsShopwarePaaS() bool {
+ return o.DeploymentMethod == DeploymentShopwarePaaS
+}
- dependingVersions := "*"
+func (o ComposerJsonOptions) IsPlatformSH() bool {
+ return o.DeploymentMethod == DeploymentPlatformSH
+}
+
+func (o ComposerJsonOptions) IsDeployer() bool {
+ return o.DeploymentMethod == DeploymentDeployer
+}
- if strings.HasPrefix(version, "dev-") {
- fallbackVersion, err := getLatestFallbackVersion(ctx, strings.TrimPrefix(version, "dev-"))
+func GenerateComposerJson(ctx context.Context, opts ComposerJsonOptions) (string, error) {
+ opts.DependingVersion = "*"
+
+ if strings.HasPrefix(opts.Version, "dev-") {
+ fallbackVersion, err := getLatestFallbackVersion(ctx, strings.TrimPrefix(opts.Version, "dev-"))
if err != nil {
return "", err
}
- if strings.HasPrefix(version, "dev-6") {
- version = strings.TrimPrefix(version, "dev-") + "-dev"
+ if strings.HasPrefix(opts.Version, "dev-6") {
+ opts.Version = strings.TrimPrefix(opts.Version, "dev-") + "-dev"
}
- version = fmt.Sprintf("%s as %s.9999999-dev", version, fallbackVersion)
- dependingVersions = version
+ opts.Version = fmt.Sprintf("%s as %s.9999999-dev", opts.Version, fallbackVersion)
+ opts.DependingVersion = opts.Version
+ }
+
+ require := newOrderedMap()
+ require.set("composer-runtime-api", "^2.0")
+ require.set("shopware/administration", opts.DependingVersion)
+ require.set("shopware/core", opts.Version)
+ if opts.UseElasticsearch {
+ require.set("shopware/elasticsearch", opts.DependingVersion)
+ }
+ require.set("shopware/storefront", opts.DependingVersion)
+ if opts.UseAMQP {
+ require.set("symfony/amqp-messenger", "*")
+ }
+ if opts.UseDocker {
+ require.set("shopware/docker-dev", "*")
+ }
+ if opts.IsDeployer() {
+ require.set("deployer/deployer", "*")
+ }
+ if opts.IsPlatformSH() {
+ require.set("shopware/paas-meta", "*")
+ }
+ if opts.IsShopwarePaaS() {
+ require.set("shopware/k8s-meta", "*")
+ }
+ require.set("symfony/flex", "~2")
+
+ allowPlugins := newOrderedMap()
+ allowPlugins.set("symfony/flex", true)
+ allowPlugins.set("symfony/runtime", true)
+
+ config := newOrderedMap()
+ config.set("allow-plugins", allowPlugins)
+ config.set("optimize-autoloader", true)
+ config.set("sort-packages", true)
+ if opts.NoAudit {
+ audit := newOrderedMap()
+ audit.set("block-insecure", false)
+ config.set("audit", audit)
+ }
+ if opts.IsShopwarePaaS() {
+ platform := newOrderedMap()
+ platform.set("ext-grpc", "1.44.0")
+ platform.set("ext-opentelemetry", "3.21.0")
+ config.set("platform", platform)
}
- err = tplContent.Execute(buf, map[string]interface{}{
- "Version": version,
- "DependingVersions": dependingVersions,
- "RC": rc,
- "UseDocker": useDocker,
- "UseElasticsearch": !withoutElasticsearch,
- "NoAudit": noAudit,
+ composer := newOrderedMap()
+ composer.set("name", "shopware/production")
+ composer.set("license", "MIT")
+ composer.set("type", "project")
+ composer.set("require", require)
+ symlinkOptions := newOrderedMap()
+ symlinkOptions.set("symlink", true)
+
+ repo1 := newOrderedMap()
+ repo1.set("type", "path")
+ repo1.set("url", "custom/plugins/*")
+ repo1.set("options", symlinkOptions)
+
+ repo2 := newOrderedMap()
+ repo2.set("type", "path")
+ repo2.set("url", "custom/plugins/*/packages/*")
+ repo2.set("options", symlinkOptions)
+
+ repo3 := newOrderedMap()
+ repo3.set("type", "path")
+ repo3.set("url", "custom/static-plugins/*")
+ repo3.set("options", symlinkOptions)
+
+ composer.set("repositories", []*orderedMap{repo1, repo2, repo3})
+ psr4 := newOrderedMap()
+ psr4.set("App\\", "src/")
+ autoload := newOrderedMap()
+ autoload.set("psr-4", psr4)
+ composer.set("autoload", autoload)
+ if opts.RC {
+ composer.set("minimum-stability", "RC")
+ }
+ composer.set("prefer-stable", true)
+ composer.set("config", config)
+ scripts := newOrderedMap()
+ scripts.set("auto-scripts", []string{})
+ scripts.set("post-install-cmd", []string{"@auto-scripts"})
+ scripts.set("post-update-cmd", []string{"@auto-scripts"})
+ composer.set("scripts", scripts)
+ symfony := newOrderedMap()
+ symfony.set("allow-contrib", true)
+ symfony.set("docker", opts.UseDocker)
+ symfony.set("endpoint", []string{
+ "https://raw.githubusercontent.com/shopware/recipes/flex/main/index.json",
+ "flex://defaults",
})
+ extra := newOrderedMap()
+ extra.set("symfony", symfony)
+ composer.set("extra", extra)
+
+ result, err := json.MarshalIndent(composer, "", " ")
if err != nil {
return "", err
}
- return buf.String(), nil
+ return string(result) + "\n", nil
+}
+
+// orderedMap preserves insertion order for JSON marshaling.
+type orderedMap struct {
+ keys []string
+ values map[string]any
+}
+
+func newOrderedMap() *orderedMap {
+ return &orderedMap{
+ keys: []string{},
+ values: make(map[string]any),
+ }
+}
+
+func (o *orderedMap) set(key string, value any) {
+ if _, exists := o.values[key]; !exists {
+ o.keys = append(o.keys, key)
+ }
+ o.values[key] = value
+}
+
+func (o *orderedMap) MarshalJSON() ([]byte, error) {
+ var buf strings.Builder
+ buf.WriteString("{")
+ for i, key := range o.keys {
+ if i > 0 {
+ buf.WriteString(",")
+ }
+ keyJSON, err := json.Marshal(key)
+ if err != nil {
+ return nil, err
+ }
+ valueJSON, err := json.Marshal(o.values[key])
+ if err != nil {
+ return nil, err
+ }
+ buf.Write(keyJSON)
+ buf.WriteString(":")
+ buf.Write(valueJSON)
+ }
+ buf.WriteString("}")
+ return []byte(buf.String()), nil
}
var kernelFallbackRegExp = regexp.MustCompile(`(?m)SHOPWARE_FALLBACK_VERSION\s*=\s*'?"?(\d+\.\d+)`)
diff --git a/internal/packagist/project_composer_json_test.go b/internal/packagist/project_composer_json_test.go
index 19d2368b..ca89d90d 100644
--- a/internal/packagist/project_composer_json_test.go
+++ b/internal/packagist/project_composer_json_test.go
@@ -8,10 +8,12 @@ import (
)
func TestGenerateComposerJson(t *testing.T) {
- ctx := t.Context()
+ t.Parallel()
t.Run("without audit", func(t *testing.T) {
- jsonStr, err := GenerateComposerJson(ctx, "6.4.18.0", false, false, false, false)
+ t.Parallel()
+ ctx := t.Context()
+ jsonStr, err := GenerateComposerJson(ctx, ComposerJsonOptions{Version: "6.4.18.0"})
assert.NoError(t, err)
assert.Contains(t, jsonStr, `"sort-packages": true`)
assert.NotContains(t, jsonStr, `"audit": {`)
@@ -21,8 +23,10 @@ func TestGenerateComposerJson(t *testing.T) {
assert.NoError(t, err, "Generated JSON should be valid")
})
- t.Run("with audit", func(t *testing.T) {
- jsonStr, err := GenerateComposerJson(ctx, "6.4.18.0", false, false, false, true)
+ t.Run("with audit disabled", func(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+ jsonStr, err := GenerateComposerJson(ctx, ComposerJsonOptions{Version: "6.4.18.0", NoAudit: true})
assert.NoError(t, err)
assert.Contains(t, jsonStr, `"sort-packages": true`)
assert.Contains(t, jsonStr, `"audit": {`)
@@ -31,4 +35,99 @@ func TestGenerateComposerJson(t *testing.T) {
err = json.Unmarshal([]byte(jsonStr), &data)
assert.NoError(t, err, "Generated JSON should be valid")
})
+
+ t.Run("with elasticsearch", func(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+ jsonStr, err := GenerateComposerJson(ctx, ComposerJsonOptions{Version: "6.4.18.0", UseElasticsearch: true})
+ assert.NoError(t, err)
+ assert.Contains(t, jsonStr, `"shopware/elasticsearch"`)
+
+ var data map[string]interface{}
+ err = json.Unmarshal([]byte(jsonStr), &data)
+ assert.NoError(t, err, "Generated JSON should be valid")
+ })
+
+ t.Run("without elasticsearch", func(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+ jsonStr, err := GenerateComposerJson(ctx, ComposerJsonOptions{Version: "6.4.18.0", UseElasticsearch: false})
+ assert.NoError(t, err)
+ assert.NotContains(t, jsonStr, `"shopware/elasticsearch"`)
+
+ var data map[string]interface{}
+ err = json.Unmarshal([]byte(jsonStr), &data)
+ assert.NoError(t, err, "Generated JSON should be valid")
+ })
+
+ t.Run("with shopware paas deployment", func(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+ jsonStr, err := GenerateComposerJson(ctx, ComposerJsonOptions{Version: "6.4.18.0", DeploymentMethod: DeploymentShopwarePaaS})
+ assert.NoError(t, err)
+ assert.Contains(t, jsonStr, `"shopware/k8s-meta": "*"`)
+ assert.NotContains(t, jsonStr, `"shopware/paas-meta"`)
+ assert.Contains(t, jsonStr, `"platform": {`)
+ assert.Contains(t, jsonStr, `"ext-grpc": "1.44.0"`)
+ assert.Contains(t, jsonStr, `"ext-opentelemetry": "3.21.0"`)
+
+ var data map[string]interface{}
+ err = json.Unmarshal([]byte(jsonStr), &data)
+ assert.NoError(t, err, "Generated JSON should be valid")
+ })
+
+ t.Run("with platformsh deployment", func(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+ jsonStr, err := GenerateComposerJson(ctx, ComposerJsonOptions{Version: "6.4.18.0", DeploymentMethod: DeploymentPlatformSH})
+ assert.NoError(t, err)
+ assert.Contains(t, jsonStr, `"shopware/paas-meta": "*"`)
+ assert.NotContains(t, jsonStr, `"shopware/k8s-meta"`)
+ assert.NotContains(t, jsonStr, `"ext-grpc"`)
+ assert.NotContains(t, jsonStr, `"ext-opentelemetry"`)
+
+ var data map[string]interface{}
+ err = json.Unmarshal([]byte(jsonStr), &data)
+ assert.NoError(t, err, "Generated JSON should be valid")
+ })
+
+ t.Run("with deployer deployment", func(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+ jsonStr, err := GenerateComposerJson(ctx, ComposerJsonOptions{Version: "6.4.18.0", DeploymentMethod: DeploymentDeployer})
+ assert.NoError(t, err)
+ assert.Contains(t, jsonStr, `"deployer/deployer": "*"`)
+ assert.NotContains(t, jsonStr, `"shopware/paas-meta"`)
+ assert.NotContains(t, jsonStr, `"shopware/k8s-meta"`)
+ assert.NotContains(t, jsonStr, `"ext-grpc"`)
+ assert.NotContains(t, jsonStr, `"ext-opentelemetry"`)
+
+ var data map[string]interface{}
+ err = json.Unmarshal([]byte(jsonStr), &data)
+ assert.NoError(t, err, "Generated JSON should be valid")
+ })
+
+ t.Run("with amqp", func(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+ jsonStr, err := GenerateComposerJson(ctx, ComposerJsonOptions{Version: "6.4.18.0", UseAMQP: true})
+ assert.NoError(t, err)
+ assert.Contains(t, jsonStr, `"symfony/amqp-messenger": "*"`)
+
+ var data map[string]interface{}
+ err = json.Unmarshal([]byte(jsonStr), &data)
+ assert.NoError(t, err, "Generated JSON should be valid")
+ })
+
+ t.Run("without amqp", func(t *testing.T) {
+ t.Parallel()
+ ctx := t.Context()
+ jsonStr, err := GenerateComposerJson(ctx, ComposerJsonOptions{Version: "6.4.18.0", UseAMQP: false})
+ assert.NoError(t, err)
+ assert.NotContains(t, jsonStr, `"symfony/amqp-messenger"`)
+
+ var data map[string]interface{}
+ err = json.Unmarshal([]byte(jsonStr), &data)
+ assert.NoError(t, err, "Generated JSON should be valid")
+ })
}
diff --git a/internal/spdx/license_test.go b/internal/spdx/license_test.go
index 29f331e8..2da24125 100644
--- a/internal/spdx/license_test.go
+++ b/internal/spdx/license_test.go
@@ -7,11 +7,13 @@ import (
)
func TestNewSpdxLicenses(t *testing.T) {
+ t.Parallel()
_, err := NewSpdxLicenses()
assert.NoError(t, err)
}
func TestSpdxLicenses_Validate(t *testing.T) {
+ t.Parallel()
s, _ := NewSpdxLicenses()
tests := []struct {
@@ -26,6 +28,7 @@ func TestSpdxLicenses_Validate(t *testing.T) {
for _, tt := range tests {
t.Run(tt.identifier, func(t *testing.T) {
+ t.Parallel()
bl, err := s.Validate(tt.identifier)
assert.NoError(t, err)
diff --git a/internal/system/cache_test.go b/internal/system/cache_test.go
index 6d0cff0b..a83f62ab 100644
--- a/internal/system/cache_test.go
+++ b/internal/system/cache_test.go
@@ -15,6 +15,7 @@ import (
)
func TestDiskCache(t *testing.T) {
+ t.Parallel()
// Create temporary directory for testing
tmpDir := t.TempDir()
@@ -68,6 +69,7 @@ func TestDiskCache(t *testing.T) {
}
func TestDiskCacheFilePath(t *testing.T) {
+ t.Parallel()
tmpDir := t.TempDir()
cache := NewDiskCache(tmpDir)
@@ -102,6 +104,7 @@ func TestDiskCacheFilePath(t *testing.T) {
}
func TestCacheFactory(t *testing.T) {
+ t.Parallel()
factory := NewCacheFactory()
// Test default cache creation
@@ -147,6 +150,7 @@ func TestIsGitHubActions(t *testing.T) {
}
func TestCacheInterfaceCompliance(t *testing.T) {
+ t.Parallel()
// Test that both implementations satisfy the Cache interface
tmpDir := t.TempDir()
@@ -178,6 +182,7 @@ func TestCacheInterfaceCompliance(t *testing.T) {
}
func TestDiskCacheFolderOperations(t *testing.T) {
+ t.Parallel()
// Create temporary directories for testing
tmpDir := t.TempDir()
sourceDir := t.TempDir()
@@ -243,6 +248,7 @@ func TestDiskCacheFolderOperations(t *testing.T) {
}
func TestDiskCacheStoreFolderCreatesParentDirectory(t *testing.T) {
+ t.Parallel()
// Create temporary directory for testing
tmpDir := t.TempDir()
@@ -278,6 +284,7 @@ func TestDiskCacheStoreFolderCreatesParentDirectory(t *testing.T) {
}
func TestGitHubActionsCacheSymlinksAndPermissions(t *testing.T) {
+ t.Parallel()
// This test would only work in GitHub Actions, but we can test the tar creation logic
sourceDir := t.TempDir()
diff --git a/internal/system/container.go b/internal/system/container.go
new file mode 100644
index 00000000..0eb05c56
--- /dev/null
+++ b/internal/system/container.go
@@ -0,0 +1,15 @@
+package system
+
+import "os"
+
+func IsInsideContainer() bool {
+ if _, err := os.Stat("/.dockerenv"); err == nil {
+ return true
+ }
+
+ if os.Getenv("container") != "" {
+ return true
+ }
+
+ return false
+}
diff --git a/internal/system/fs_test.go b/internal/system/fs_test.go
index 6e73def4..037d1449 100644
--- a/internal/system/fs_test.go
+++ b/internal/system/fs_test.go
@@ -9,6 +9,7 @@ import (
)
func TestCopyFiles(t *testing.T) {
+ t.Parallel()
// Create a temporary directory for testing
tempDir := t.TempDir()
defer func() {
@@ -79,6 +80,7 @@ func TestCopyFiles(t *testing.T) {
}
func TestIsDirEmpty(t *testing.T) {
+ t.Parallel()
// Test empty directory
tmpDir := t.TempDir()
empty, err := IsDirEmpty(tmpDir)
diff --git a/internal/system/symfony.go b/internal/system/symfony.go
new file mode 100644
index 00000000..f8b8a304
--- /dev/null
+++ b/internal/system/symfony.go
@@ -0,0 +1,8 @@
+package system
+
+import "os/exec"
+
+func IsSymfonyCliInstalled() bool {
+ _, err := exec.LookPath("symfony")
+ return err == nil
+}
diff --git a/internal/twigparser/nodes.go b/internal/twigparser/nodes.go
index f257dd99..f81620d2 100644
--- a/internal/twigparser/nodes.go
+++ b/internal/twigparser/nodes.go
@@ -51,7 +51,7 @@ type BlockNode struct {
func (b *BlockNode) String(indent string) string {
var sb strings.Builder
- sb.WriteString(fmt.Sprintf("%sBlockNode(Name: %s)\n", indent, b.Name))
+ fmt.Fprintf(&sb, "%sBlockNode(Name: %s)\n", indent, b.Name)
for _, child := range b.Children {
sb.WriteString(child.String(indent + " "))
sb.WriteString("\n")
diff --git a/internal/twigparser/twig_test.go b/internal/twigparser/twig_test.go
index 6b79572a..ac3c80b4 100644
--- a/internal/twigparser/twig_test.go
+++ b/internal/twigparser/twig_test.go
@@ -8,6 +8,7 @@ import (
)
func TestBlockParsing(t *testing.T) {
+ t.Parallel()
template := `{% block content %}
{% block page_account_address_form_create_personal %}
{{ parent() }}
@@ -28,6 +29,7 @@ func TestBlockParsing(t *testing.T) {
}
func TestTraversing(t *testing.T) {
+ t.Parallel()
template := `{% block content %}
{{ parent() }}
{% endblock %}`
@@ -52,6 +54,7 @@ func TestTraversing(t *testing.T) {
}
func TestSwExtendsParsing(t *testing.T) {
+ t.Parallel()
testcases := []struct {
template string
path string
@@ -87,6 +90,7 @@ func TestSwExtendsParsing(t *testing.T) {
}
func TestPrintNodeParsing(t *testing.T) {
+ t.Parallel()
template := `{{ a_variable }}`
nodes, err := ParseTemplate(template)
assert.NoError(t, err)
@@ -104,6 +108,7 @@ func TestPrintNodeParsing(t *testing.T) {
}
func TestDeprecatedNodeParsing(t *testing.T) {
+ t.Parallel()
template := `{% deprecated 'The "base.html.twig" template is deprecated, use "layout.html.twig" instead.' %}`
nodes, err := ParseTemplate(template)
assert.NoError(t, err)
@@ -122,6 +127,7 @@ func TestDeprecatedNodeParsing(t *testing.T) {
}
func TestSetNodeParsing(t *testing.T) {
+ t.Parallel()
// Inline set assignment.
inlineTemplate := `{% set name = 'Fabien' %}`
nodes, err := ParseTemplate(inlineTemplate)
@@ -162,6 +168,7 @@ func TestSetNodeParsing(t *testing.T) {
}
func TestAutoescapeNodeParsing(t *testing.T) {
+ t.Parallel()
template := `{% autoescape %}
Everything will be automatically escaped.
{% endautoescape %}`
diff --git a/internal/validation/reporter.go b/internal/validation/reporter.go
index b9aa29fe..eeb1e092 100644
--- a/internal/validation/reporter.go
+++ b/internal/validation/reporter.go
@@ -193,7 +193,7 @@ type GitLabCodeQualityLines struct {
}
func doGitLabReport(result Check) error {
- var issues []GitLabCodeQualityIssue
+ issues := make([]GitLabCodeQualityIssue, 0)
// Sort results for deterministic output
results := result.GetResults()
diff --git a/internal/verifier/result_test.go b/internal/verifier/result_test.go
index 9978514e..29e5637c 100644
--- a/internal/verifier/result_test.go
+++ b/internal/verifier/result_test.go
@@ -9,12 +9,14 @@ import (
)
func TestNewCheck(t *testing.T) {
+ t.Parallel()
check := NewCheck()
assert.NotNil(t, check)
assert.Empty(t, check.Results)
}
func TestAddResult(t *testing.T) {
+ t.Parallel()
check := NewCheck()
result := validation.CheckResult{
Path: "test.go",
@@ -30,6 +32,7 @@ func TestAddResult(t *testing.T) {
}
func TestHasErrors(t *testing.T) {
+ t.Parallel()
tests := []struct {
name string
results []validation.CheckResult
@@ -61,6 +64,7 @@ func TestHasErrors(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
check := NewCheck()
for _, result := range tt.results {
check.AddResult(result)
@@ -71,6 +75,7 @@ func TestHasErrors(t *testing.T) {
}
func TestRemoveByIdentifier(t *testing.T) {
+ t.Parallel()
tests := []struct {
name string
initialResults []validation.CheckResult
@@ -162,6 +167,7 @@ func TestRemoveByIdentifier(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
check := NewCheck()
for _, result := range tt.initialResults {
check.AddResult(result)
@@ -174,6 +180,7 @@ func TestRemoveByIdentifier(t *testing.T) {
}
func TestRemoveByMessage(t *testing.T) {
+ t.Parallel()
tests := []struct {
name string
initialResults []validation.CheckResult
@@ -248,6 +255,7 @@ func TestRemoveByMessage(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
check := NewCheck()
for _, result := range tt.initialResults {
check.AddResult(result)
diff --git a/internal/verifier/tool_test.go b/internal/verifier/tool_test.go
index dd93fc5a..36973ab8 100644
--- a/internal/verifier/tool_test.go
+++ b/internal/verifier/tool_test.go
@@ -23,6 +23,7 @@ func toolNames(list ToolList) []string {
}
func TestExclude_EmptyString_NoChange(t *testing.T) {
+ t.Parallel()
base := ToolList{testTool{"phpstan"}, testTool{"eslint"}, testTool{"sw-cli"}}
res, err := base.Exclude("")
assert.NoError(t, err)
@@ -30,6 +31,7 @@ func TestExclude_EmptyString_NoChange(t *testing.T) {
}
func TestExclude_SingleTool(t *testing.T) {
+ t.Parallel()
base := ToolList{testTool{"phpstan"}, testTool{"eslint"}, testTool{"sw-cli"}}
res, err := base.Exclude("eslint")
assert.NoError(t, err)
@@ -37,6 +39,7 @@ func TestExclude_SingleTool(t *testing.T) {
}
func TestExclude_MultipleTools(t *testing.T) {
+ t.Parallel()
base := ToolList{testTool{"phpstan"}, testTool{"eslint"}, testTool{"sw-cli"}, testTool{"stylelint"}}
res, err := base.Exclude("eslint, stylelint")
assert.NoError(t, err)
@@ -44,6 +47,7 @@ func TestExclude_MultipleTools(t *testing.T) {
}
func TestExclude_AllTools_ReturnsEmpty(t *testing.T) {
+ t.Parallel()
base := ToolList{testTool{"phpstan"}, testTool{"eslint"}}
res, err := base.Exclude("phpstan,eslint")
assert.NoError(t, err)
@@ -51,6 +55,7 @@ func TestExclude_AllTools_ReturnsEmpty(t *testing.T) {
}
func TestExclude_UnknownTool_Error(t *testing.T) {
+ t.Parallel()
base := ToolList{testTool{"phpstan"}, testTool{"eslint"}}
res, err := base.Exclude("rector")
assert.Error(t, err)
@@ -58,6 +63,7 @@ func TestExclude_UnknownTool_Error(t *testing.T) {
}
func TestExclude_TrimsAndIgnoresDuplicates(t *testing.T) {
+ t.Parallel()
base := ToolList{testTool{"phpstan"}, testTool{"eslint"}, testTool{"sw-cli"}}
res, err := base.Exclude(" eslint , eslint , \teslint\t ")
assert.NoError(t, err)
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_alert_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_alert_test.go
index b1b47243..9d994a89 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_alert_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_alert_test.go
@@ -9,6 +9,7 @@ import (
)
func TestAlertFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_button_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_button_test.go
index f0959242..ea9ff230 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_button_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_button_test.go
@@ -9,6 +9,7 @@ import (
)
func TestButtonFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_card_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_card_test.go
index 49017809..3abc9002 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_card_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_card_test.go
@@ -9,6 +9,7 @@ import (
)
func TestCardFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_checkbox_field_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_checkbox_field_test.go
index 501544d0..c757a0de 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_checkbox_field_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_checkbox_field_test.go
@@ -9,6 +9,7 @@ import (
)
func TestCheckboxFieldFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_colorpicker_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_colorpicker_test.go
index 0ff28c59..50b8b3b3 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_colorpicker_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_colorpicker_test.go
@@ -9,6 +9,7 @@ import (
)
func TestColorpickerFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_datepicker_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_datepicker_test.go
index 9218e46d..b16cdd66 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_datepicker_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_datepicker_test.go
@@ -9,6 +9,7 @@ import (
)
func TestDatepickerFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_email_field_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_email_field_test.go
index 423b2713..ee55717e 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_email_field_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_email_field_test.go
@@ -9,6 +9,7 @@ import (
)
func TestEmailFieldFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_external_link_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_external_link_test.go
index 423162ec..6fcd4f58 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_external_link_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_external_link_test.go
@@ -9,6 +9,7 @@ import (
)
func TestExternalLinkFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_icon_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_icon_test.go
index 44f5ede9..3b179b87 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_icon_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_icon_test.go
@@ -9,6 +9,7 @@ import (
)
func TestIconFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_loader_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_loader_test.go
index d71838c7..0c4f0291 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_loader_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_loader_test.go
@@ -9,6 +9,7 @@ import (
)
func TestLoaderFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
before string
after string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_number_field_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_number_field_test.go
index 3a79c84f..329d62d1 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_number_field_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_number_field_test.go
@@ -9,6 +9,7 @@ import (
)
func TestNumberFieldFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_passwordfield_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_passwordfield_test.go
index 95806bf1..dd7bb8d9 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_passwordfield_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_passwordfield_test.go
@@ -9,6 +9,7 @@ import (
)
func TestPasswordFieldFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_progress_bar_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_progress_bar_test.go
index 6cf5d736..9a7bdfaf 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_progress_bar_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_progress_bar_test.go
@@ -9,6 +9,7 @@ import (
)
func TestProgressBarFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_select_field_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_select_field_test.go
index cc100993..2c230b91 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_select_field_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_select_field_test.go
@@ -9,6 +9,7 @@ import (
)
func TestSelectFieldFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_skeleton_bar_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_skeleton_bar_test.go
index 200aee07..f4d82695 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_skeleton_bar_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_skeleton_bar_test.go
@@ -9,6 +9,7 @@ import (
)
func TestSkeletonBarFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_switch_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_switch_test.go
index 7106aa11..84e40244 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_switch_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_switch_test.go
@@ -9,6 +9,7 @@ import (
)
func TestSwitchFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_text_field_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_text_field_test.go
index abf77d75..567c69db 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_text_field_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_text_field_test.go
@@ -9,6 +9,7 @@ import (
)
func TestTextFieldFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_textareafield_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_textareafield_test.go
index 991d79cf..b6b2e441 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_textareafield_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_textareafield_test.go
@@ -9,6 +9,7 @@ import (
)
func TestTextarea(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fix_url_field_test.go b/internal/verifier/twiglinter/admintwiglinter/fix_url_field_test.go
index 6baa0720..020c90d6 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fix_url_field_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fix_url_field_test.go
@@ -9,6 +9,7 @@ import (
)
func TestUrlFieldFixer(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/admintwiglinter/fixer_popover_test.go b/internal/verifier/twiglinter/admintwiglinter/fixer_popover_test.go
index d8308aaf..b4215a0a 100644
--- a/internal/verifier/twiglinter/admintwiglinter/fixer_popover_test.go
+++ b/internal/verifier/twiglinter/admintwiglinter/fixer_popover_test.go
@@ -9,6 +9,7 @@ import (
)
func TestPopover(t *testing.T) {
+ t.Parallel()
cases := []struct {
description string
before string
diff --git a/internal/verifier/twiglinter/storefronttwiglinter/image_test.go b/internal/verifier/twiglinter/storefronttwiglinter/image_test.go
index ad4434f5..ce56b612 100644
--- a/internal/verifier/twiglinter/storefronttwiglinter/image_test.go
+++ b/internal/verifier/twiglinter/storefronttwiglinter/image_test.go
@@ -9,6 +9,7 @@ import (
)
func TestImageAltDetection(t *testing.T) {
+ t.Parallel()
cases := []struct {
name string
content string
@@ -97,6 +98,7 @@ func TestImageAltDetection(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
checks, err := twiglinter.RunCheckerOnString(ImageAltCheck{}, tc.content)
assert.NoError(t, err)
assert.Len(t, checks, tc.expectedCount, "Expected %d validation errors but got %d", tc.expectedCount, len(checks))
@@ -105,6 +107,7 @@ func TestImageAltDetection(t *testing.T) {
}
func TestImageAltCheckIdentifiers(t *testing.T) {
+ t.Parallel()
// Test that correct identifiers are used for different types of errors
missingAltChecks, err := twiglinter.RunCheckerOnString(ImageAltCheck{}, `
`)
assert.NoError(t, err)
diff --git a/internal/verifier/twiglinter/storefronttwiglinter/link_test.go b/internal/verifier/twiglinter/storefronttwiglinter/link_test.go
index 3871ba30..3ee34c60 100644
--- a/internal/verifier/twiglinter/storefronttwiglinter/link_test.go
+++ b/internal/verifier/twiglinter/storefronttwiglinter/link_test.go
@@ -9,6 +9,7 @@ import (
)
func TestLinkDetection(t *testing.T) {
+ t.Parallel()
cases := []struct {
name string
content string
@@ -58,6 +59,7 @@ func TestLinkDetection(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
checks, err := twiglinter.RunCheckerOnString(LinkCheck{}, tc.content)
assert.NoError(t, err)
assert.Len(t, checks, tc.expectedCount)
diff --git a/internal/verifier/twiglinter/storefronttwiglinter/style_test.go b/internal/verifier/twiglinter/storefronttwiglinter/style_test.go
index eee2828a..2db8d6c2 100644
--- a/internal/verifier/twiglinter/storefronttwiglinter/style_test.go
+++ b/internal/verifier/twiglinter/storefronttwiglinter/style_test.go
@@ -9,6 +9,7 @@ import (
)
func TestStyleDetection(t *testing.T) {
+ t.Parallel()
cases := []struct {
name string
content string
@@ -43,6 +44,7 @@ func TestStyleDetection(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
checks, err := twiglinter.RunCheckerOnString(StyleFixer{}, tc.content)
assert.NoError(t, err)
if tc.expected {
diff --git a/sandbox-no-network.sb b/sandbox-no-network.sb
new file mode 100644
index 00000000..b4f5bc44
--- /dev/null
+++ b/sandbox-no-network.sb
@@ -0,0 +1,5 @@
+(version 1)
+(allow default)
+(allow network*)
+(deny network-outbound (remote tcp "*:80"))
+(deny network-outbound (remote tcp "*:443"))