diff --git a/cmd/account/account_producer_extension_info_pull.go b/cmd/account/account_producer_extension_info_pull.go index 455259ad..1bb70c6a 100644 --- a/cmd/account/account_producer_extension_info_pull.go +++ b/cmd/account/account_producer_extension_info_pull.go @@ -28,7 +28,7 @@ var accountCompanyProducerExtensionInfoPullCmd = &cobra.Command{ return fmt.Errorf("cannot open file: %w", err) } - zipExt, err := extension.GetExtensionByFolder(absolutePath) + zipExt, err := extension.GetExtensionByFolder(cmd.Context(), absolutePath) if err != nil { return fmt.Errorf("cannot open extension: %w", err) } diff --git a/cmd/account/account_producer_extension_info_push.go b/cmd/account/account_producer_extension_info_push.go index d56883cf..82566d96 100644 --- a/cmd/account/account_producer_extension_info_push.go +++ b/cmd/account/account_producer_extension_info_push.go @@ -36,9 +36,9 @@ var accountCompanyProducerExtensionInfoPushCmd = &cobra.Command{ var zipExt extension.Extension if stat.IsDir() { - zipExt, err = extension.GetExtensionByFolder(absolutePath) + zipExt, err = extension.GetExtensionByFolder(cmd.Context(), absolutePath) } else { - zipExt, err = extension.GetExtensionByZip(absolutePath) + zipExt, err = extension.GetExtensionByZip(cmd.Context(), absolutePath) } if err != nil { diff --git a/cmd/account/account_producer_extension_upload.go b/cmd/account/account_producer_extension_upload.go index 9181eb2c..35f8eb1d 100644 --- a/cmd/account/account_producer_extension_upload.go +++ b/cmd/account/account_producer_extension_upload.go @@ -31,7 +31,7 @@ var accountCompanyProducerExtensionUploadCmd = &cobra.Command{ return err } - zipExt, err := extension.GetExtensionByZip(path) + zipExt, err := extension.GetExtensionByZip(cmd.Context(), path) if err != nil { logging.FromContext(cmd.Context()).Debugf("Failed to read extension from zip: %v", err) return err diff --git a/cmd/extension/extension_admin_watch.go b/cmd/extension/extension_admin_watch.go index 38985913..5cccbe7f 100644 --- a/cmd/extension/extension_admin_watch.go +++ b/cmd/extension/extension_admin_watch.go @@ -56,9 +56,9 @@ var extensionAdminWatchCmd = &cobra.Command{ var sources []asset.Source for _, extensionPath := range args[:len(args)-1] { - ext, err := extension.GetExtensionByFolder(extensionPath) + ext, err := extension.GetExtensionByFolder(cmd.Context(), extensionPath) if err != nil { - shopCfg, err := shop.ReadConfig(path.Join(extensionPath, shop.DefaultConfigFileName()), true) + shopCfg, err := shop.ReadConfig(cmd.Context(), path.Join(extensionPath, shop.DefaultConfigFileName()), true) if err != nil { return err } diff --git a/cmd/extension/extension_ai_twig_upgrade.go b/cmd/extension/extension_ai_twig_upgrade.go index ab2ed597..e781cad8 100644 --- a/cmd/extension/extension_ai_twig_upgrade.go +++ b/cmd/extension/extension_ai_twig_upgrade.go @@ -33,7 +33,7 @@ var extensionAiTwigUpgradeCmd = &cobra.Command{ Short: "Upgrade Twig templates using AI", Args: cobra.ExactArgs(3), RunE: func(cmd *cobra.Command, args []string) error { - ext, err := extension.GetExtensionByFolder(args[0]) + ext, err := extension.GetExtensionByFolder(cmd.Context(), args[0]) if err != nil { return err } diff --git a/cmd/extension/extension_build.go b/cmd/extension/extension_build.go index 5fc8989c..691d8ece 100644 --- a/cmd/extension/extension_build.go +++ b/cmd/extension/extension_build.go @@ -26,7 +26,7 @@ var extensionAssetBundleCmd = &cobra.Command{ return fmt.Errorf("cannot open file: %w", err) } - ext, err := extension.GetExtensionByFolder(path) + ext, err := extension.GetExtensionByFolder(cmd.Context(), path) if err != nil { return fmt.Errorf("cannot open extension: %w", err) } diff --git a/cmd/extension/extension_changelog.go b/cmd/extension/extension_changelog.go index 1d332282..80d28ba7 100644 --- a/cmd/extension/extension_changelog.go +++ b/cmd/extension/extension_changelog.go @@ -29,9 +29,9 @@ var extensionChangelogCmd = &cobra.Command{ var ext extension.Extension if stat.IsDir() { - ext, err = extension.GetExtensionByFolder(path) + ext, err = extension.GetExtensionByFolder(cmd.Context(), path) } else { - ext, err = extension.GetExtensionByZip(path) + ext, err = extension.GetExtensionByZip(cmd.Context(), path) } if err != nil { diff --git a/cmd/extension/extension_fix.go b/cmd/extension/extension_fix.go index 901e90ec..07ceda0c 100644 --- a/cmd/extension/extension_fix.go +++ b/cmd/extension/extension_fix.go @@ -34,7 +34,7 @@ var extensionFixCmd = &cobra.Command{ return fmt.Errorf("cannot find path: %w", err) } - ext, err := extension.GetExtensionByFolder(path) + ext, err := extension.GetExtensionByFolder(cmd.Context(), path) if err != nil { return err } diff --git a/cmd/extension/extension_format.go b/cmd/extension/extension_format.go index a7c94a70..04cb0e2f 100644 --- a/cmd/extension/extension_format.go +++ b/cmd/extension/extension_format.go @@ -26,7 +26,7 @@ var extensionFormat = &cobra.Command{ return fmt.Errorf("cannot find path: %w", err) } - ext, err := extension.GetExtensionByFolder(path) + ext, err := extension.GetExtensionByFolder(cmd.Context(), path) if err != nil { return err } diff --git a/cmd/extension/extension_name.go b/cmd/extension/extension_name.go index fc013e62..3b923070 100644 --- a/cmd/extension/extension_name.go +++ b/cmd/extension/extension_name.go @@ -28,9 +28,9 @@ var extensionNameCmd = &cobra.Command{ var ext extension.Extension if stat.IsDir() { - ext, err = extension.GetExtensionByFolder(path) + ext, err = extension.GetExtensionByFolder(cmd.Context(), path) } else { - ext, err = extension.GetExtensionByZip(path) + ext, err = extension.GetExtensionByZip(cmd.Context(), path) } if err != nil { diff --git a/cmd/extension/extension_prepare.go b/cmd/extension/extension_prepare.go index aa5bb4ef..75693aea 100644 --- a/cmd/extension/extension_prepare.go +++ b/cmd/extension/extension_prepare.go @@ -19,7 +19,7 @@ var extensionPrepareCmd = &cobra.Command{ return fmt.Errorf("path not found: %w", err) } - ext, err := extension.GetExtensionByFolder(path) + ext, err := extension.GetExtensionByFolder(cmd.Context(), path) if err != nil { return fmt.Errorf("detect extension type: %w", err) } diff --git a/cmd/extension/extension_validate.go b/cmd/extension/extension_validate.go index 3c07e61d..f12191d4 100644 --- a/cmd/extension/extension_validate.go +++ b/cmd/extension/extension_validate.go @@ -77,7 +77,7 @@ var extensionValidateCmd = &cobra.Command{ tmpDir = args[0] } - ext, err := extension.GetExtensionByFolder(tmpDir) + ext, err := extension.GetExtensionByFolder(cmd.Context(), tmpDir) if err != nil { return err } @@ -89,7 +89,7 @@ var extensionValidateCmd = &cobra.Command{ toolCfg.InputWasDirectory = true } else { - ext, err := extension.GetExtensionByZip(args[0]) + ext, err := extension.GetExtensionByZip(cmd.Context(), args[0]) if err != nil { return err } diff --git a/cmd/extension/extension_version.go b/cmd/extension/extension_version.go index 68705761..425603b7 100644 --- a/cmd/extension/extension_version.go +++ b/cmd/extension/extension_version.go @@ -28,9 +28,9 @@ var extensionVersionCmd = &cobra.Command{ var ext extension.Extension if stat.IsDir() { - ext, err = extension.GetExtensionByFolder(path) + ext, err = extension.GetExtensionByFolder(cmd.Context(), path) } else { - ext, err = extension.GetExtensionByZip(path) + ext, err = extension.GetExtensionByZip(cmd.Context(), path) } if err != nil { diff --git a/cmd/extension/extension_zip.go b/cmd/extension/extension_zip.go index b7b3cb94..cc0213cc 100644 --- a/cmd/extension/extension_zip.go +++ b/cmd/extension/extension_zip.go @@ -37,7 +37,7 @@ var extensionZipCmd = &cobra.Command{ branch = args[1] } - ext, err := extension.GetExtensionByFolder(extPath) + ext, err := extension.GetExtensionByFolder(cmd.Context(), extPath) if err != nil { return fmt.Errorf("detect extension type: %w", err) } @@ -124,7 +124,7 @@ var extensionZipCmd = &cobra.Command{ } } var tempExt extension.Extension - if tempExt, err = extension.GetExtensionByFolder(extDir); err != nil { + if tempExt, err = extension.GetExtensionByFolder(cmd.Context(), extDir); err != nil { return err } diff --git a/cmd/project/ci.go b/cmd/project/ci.go index d9553899..673c9eea 100644 --- a/cmd/project/ci.go +++ b/cmd/project/ci.go @@ -62,7 +62,7 @@ var projectCI = &cobra.Command{ // Remove annoying cache invalidation errors while asset install _ = os.Setenv("SHOPWARE_SKIP_ASSET_INSTALL_CACHE_INVALIDATION", "1") - shopCfg, err := shop.ReadConfig(projectConfigPath, true) + shopCfg, err := shop.ReadConfig(cmd.Context(), projectConfigPath, true) if err != nil { return err } diff --git a/cmd/project/project_admin_api.go b/cmd/project/project_admin_api.go index e53569cf..92f833a1 100644 --- a/cmd/project/project_admin_api.go +++ b/cmd/project/project_admin_api.go @@ -21,7 +21,7 @@ var projectAdminApiCmd = &cobra.Command{ var cfg *shop.Config var err error - if cfg, err = shop.ReadConfig(projectConfigPath, false); err != nil { + if cfg, err = shop.ReadConfig(cobraCmd.Context(), projectConfigPath, false); err != nil { return err } diff --git a/cmd/project/project_admin_build.go b/cmd/project/project_admin_build.go index f3ddffbd..05b71083 100644 --- a/cmd/project/project_admin_build.go +++ b/cmd/project/project_admin_build.go @@ -29,7 +29,7 @@ var projectAdminBuildCmd = &cobra.Command{ return err } - shopCfg, err := shop.ReadConfig(projectConfigPath, true) + shopCfg, err := shop.ReadConfig(cmd.Context(), projectConfigPath, true) if err != nil { return err } diff --git a/cmd/project/project_admin_watch.go b/cmd/project/project_admin_watch.go index 80b23ec6..be9f63bf 100644 --- a/cmd/project/project_admin_watch.go +++ b/cmd/project/project_admin_watch.go @@ -31,7 +31,7 @@ var projectAdminWatchCmd = &cobra.Command{ return err } - shopCfg, err := shop.ReadConfig(projectConfigPath, true) + shopCfg, err := shop.ReadConfig(cmd.Context(), projectConfigPath, true) if err != nil { return err } diff --git a/cmd/project/project_clear_cache.go b/cmd/project/project_clear_cache.go index a00cb03d..b92adec3 100644 --- a/cmd/project/project_clear_cache.go +++ b/cmd/project/project_clear_cache.go @@ -18,7 +18,7 @@ var projectClearCacheCmd = &cobra.Command{ var cfg *shop.Config var err error - if cfg, err = shop.ReadConfig(projectConfigPath, false); err != nil { + if cfg, err = shop.ReadConfig(cmd.Context(), projectConfigPath, false); err != nil { return err } diff --git a/cmd/project/project_config_init.go b/cmd/project/project_config_init.go index afdd1702..bef7c757 100644 --- a/cmd/project/project_config_init.go +++ b/cmd/project/project_config_init.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "gopkg.in/yaml.v3" + "github.com/shopware/shopware-cli/internal/compatibility" "github.com/shopware/shopware-cli/internal/shop" "github.com/shopware/shopware-cli/internal/system" "github.com/shopware/shopware-cli/logging" @@ -21,7 +22,9 @@ var projectConfigInitCmd = &cobra.Command{ return fmt.Errorf("this command requires interaction, but interaction is disabled") } - config := &shop.Config{} + config := &shop.Config{ + CompatibilityDate: compatibility.DefaultDate(), + } if err := askProjectConfig(config); err != nil { return err diff --git a/cmd/project/project_debug.go b/cmd/project/project_debug.go index f67519fa..6c78dcaf 100644 --- a/cmd/project/project_debug.go +++ b/cmd/project/project_debug.go @@ -24,7 +24,7 @@ var projectDebug = &cobra.Command{ return err } - shopCfg, err := shop.ReadConfig(projectConfigPath, true) + shopCfg, err := shop.ReadConfig(cmd.Context(), projectConfigPath, true) if err != nil { return err } diff --git a/cmd/project/project_dump.go b/cmd/project/project_dump.go index c569b3bf..8be81bca 100644 --- a/cmd/project/project_dump.go +++ b/cmd/project/project_dump.go @@ -57,7 +57,7 @@ var projectDatabaseDumpCmd = &cobra.Command{ dumper.InsertIntoLimit = insertIntoLimit var projectCfg *shop.Config - if projectCfg, err = shop.ReadConfig(projectConfigPath, true); err != nil { + if projectCfg, err = shop.ReadConfig(cmd.Context(), projectConfigPath, true); err != nil { return err } diff --git a/cmd/project/project_extension_activate.go b/cmd/project/project_extension_activate.go index 559128e0..d012801f 100644 --- a/cmd/project/project_extension_activate.go +++ b/cmd/project/project_extension_activate.go @@ -18,7 +18,7 @@ var projectExtensionActivateCmd = &cobra.Command{ var cfg *shop.Config var err error - if cfg, err = shop.ReadConfig(projectConfigPath, false); err != nil { + if cfg, err = shop.ReadConfig(cmd.Context(), projectConfigPath, false); err != nil { return err } diff --git a/cmd/project/project_extension_deactivate.go b/cmd/project/project_extension_deactivate.go index ba8897f1..1c5287e8 100644 --- a/cmd/project/project_extension_deactivate.go +++ b/cmd/project/project_extension_deactivate.go @@ -18,7 +18,7 @@ var projectExtensionDeactivateCmd = &cobra.Command{ var cfg *shop.Config var err error - if cfg, err = shop.ReadConfig(projectConfigPath, true); err != nil { + if cfg, err = shop.ReadConfig(cmd.Context(), projectConfigPath, true); err != nil { return err } diff --git a/cmd/project/project_extension_delete.go b/cmd/project/project_extension_delete.go index f5c53255..bd868e5e 100644 --- a/cmd/project/project_extension_delete.go +++ b/cmd/project/project_extension_delete.go @@ -18,7 +18,7 @@ var projectExtensionDeleteCmd = &cobra.Command{ var cfg *shop.Config var err error - if cfg, err = shop.ReadConfig(projectConfigPath, true); err != nil { + if cfg, err = shop.ReadConfig(cmd.Context(), projectConfigPath, true); err != nil { return err } diff --git a/cmd/project/project_extension_install.go b/cmd/project/project_extension_install.go index ca8c0864..2e8f2a7a 100644 --- a/cmd/project/project_extension_install.go +++ b/cmd/project/project_extension_install.go @@ -18,7 +18,7 @@ var projectExtensionInstallCmd = &cobra.Command{ var cfg *shop.Config var err error - if cfg, err = shop.ReadConfig(projectConfigPath, true); err != nil { + if cfg, err = shop.ReadConfig(cmd.Context(), projectConfigPath, true); err != nil { return err } diff --git a/cmd/project/project_extension_list.go b/cmd/project/project_extension_list.go index 898b1b54..a9e095b8 100644 --- a/cmd/project/project_extension_list.go +++ b/cmd/project/project_extension_list.go @@ -22,7 +22,7 @@ var projectExtensionListCmd = &cobra.Command{ outputAsJson, _ := cmd.PersistentFlags().GetBool("json") - if cfg, err = shop.ReadConfig(projectConfigPath, true); err != nil { + if cfg, err = shop.ReadConfig(cmd.Context(), projectConfigPath, true); err != nil { return err } diff --git a/cmd/project/project_extension_outdated.go b/cmd/project/project_extension_outdated.go index 029d8e8d..2a3d4a19 100644 --- a/cmd/project/project_extension_outdated.go +++ b/cmd/project/project_extension_outdated.go @@ -22,7 +22,7 @@ var projectExtensionOutdatedCmd = &cobra.Command{ outputAsJson, _ := cmd.PersistentFlags().GetBool("json") - if cfg, err = shop.ReadConfig(projectConfigPath, true); err != nil { + if cfg, err = shop.ReadConfig(cmd.Context(), projectConfigPath, true); err != nil { return err } diff --git a/cmd/project/project_extension_uninstall.go b/cmd/project/project_extension_uninstall.go index 3e75f8e6..88ea2bbc 100644 --- a/cmd/project/project_extension_uninstall.go +++ b/cmd/project/project_extension_uninstall.go @@ -18,7 +18,7 @@ var projectExtensionUninstallCmd = &cobra.Command{ var cfg *shop.Config var err error - if cfg, err = shop.ReadConfig(projectConfigPath, true); err != nil { + if cfg, err = shop.ReadConfig(cmd.Context(), projectConfigPath, true); err != nil { return err } diff --git a/cmd/project/project_extension_update.go b/cmd/project/project_extension_update.go index 4d4e2a65..964acda1 100644 --- a/cmd/project/project_extension_update.go +++ b/cmd/project/project_extension_update.go @@ -18,7 +18,7 @@ var projectExtensionUpdateCmd = &cobra.Command{ var cfg *shop.Config var err error - if cfg, err = shop.ReadConfig(projectConfigPath, true); err != nil { + if cfg, err = shop.ReadConfig(cmd.Context(), projectConfigPath, true); err != nil { return err } diff --git a/cmd/project/project_extension_upload.go b/cmd/project/project_extension_upload.go index 7f34e4a9..39a6ba8a 100644 --- a/cmd/project/project_extension_upload.go +++ b/cmd/project/project_extension_upload.go @@ -51,9 +51,9 @@ var projectExtensionUploadCmd = &cobra.Command{ isFolder := true if stat.IsDir() { - ext, err = extension.GetExtensionByFolder(path) + ext, err = extension.GetExtensionByFolder(cmd.Context(), path) } else { - ext, err = extension.GetExtensionByZip(path) + ext, err = extension.GetExtensionByZip(cmd.Context(), path) isFolder = false } @@ -71,7 +71,7 @@ var projectExtensionUploadCmd = &cobra.Command{ return err } - ext, err = extension.GetExtensionByFolder(ext.GetPath()) + ext, err = extension.GetExtensionByFolder(cmd.Context(), ext.GetPath()) if err != nil { return err } @@ -107,7 +107,7 @@ var projectExtensionUploadCmd = &cobra.Command{ return fmt.Errorf("copy files: %w", err) } - ext, err = extension.GetExtensionByFolder(extDir) + ext, err = extension.GetExtensionByFolder(cmd.Context(), extDir) if err != nil { return err } @@ -118,7 +118,7 @@ var projectExtensionUploadCmd = &cobra.Command{ } } - if cfg, err = shop.ReadConfig(projectConfigPath, true); err != nil { + if cfg, err = shop.ReadConfig(cmd.Context(), projectConfigPath, true); err != nil { return err } diff --git a/cmd/project/project_image_proxy.go b/cmd/project/project_image_proxy.go index 929d974b..26a6255d 100644 --- a/cmd/project/project_image_proxy.go +++ b/cmd/project/project_image_proxy.go @@ -66,7 +66,7 @@ If a file is not found locally, it proxies the request to the upstream server.`, return err } - cfg, err := shop.ReadConfig(projectConfigPath, true) + cfg, err := shop.ReadConfig(cmd.Context(), projectConfigPath, true) if err != nil { return err } diff --git a/cmd/project/project_storefront_build.go b/cmd/project/project_storefront_build.go index 7903d6ea..ce8e9317 100644 --- a/cmd/project/project_storefront_build.go +++ b/cmd/project/project_storefront_build.go @@ -29,7 +29,7 @@ var projectStorefrontBuildCmd = &cobra.Command{ return err } - shopCfg, err := shop.ReadConfig(projectConfigPath, true) + shopCfg, err := shop.ReadConfig(cmd.Context(), projectConfigPath, true) if err != nil { return err } diff --git a/cmd/project/project_storefront_watch.go b/cmd/project/project_storefront_watch.go index 70d32098..916f3af3 100644 --- a/cmd/project/project_storefront_watch.go +++ b/cmd/project/project_storefront_watch.go @@ -31,7 +31,7 @@ var projectStorefrontWatchCmd = &cobra.Command{ return err } - shopCfg, err := shop.ReadConfig(projectConfigPath, true) + shopCfg, err := shop.ReadConfig(cmd.Context(), projectConfigPath, true) if err != nil { return err } diff --git a/cmd/project/project_upgrade_check.go b/cmd/project/project_upgrade_check.go index edc5e729..349902b1 100644 --- a/cmd/project/project_upgrade_check.go +++ b/cmd/project/project_upgrade_check.go @@ -30,7 +30,7 @@ var projectUpgradeCheckCmd = &cobra.Command{ var shopwareVersion *version.Version var extensions map[string]string - if cfg, err = shop.ReadConfig(projectConfigPath, true); err != nil { + if cfg, err = shop.ReadConfig(cmd.Context(), projectConfigPath, true); err != nil { return err } diff --git a/internal/compatibility/date.go b/internal/compatibility/date.go new file mode 100644 index 00000000..3f20bb64 --- /dev/null +++ b/internal/compatibility/date.go @@ -0,0 +1,56 @@ +package compatibility + +import ( + "fmt" + "time" +) + +const dateLayout = "2006-01-02" +const defaultCompatibilityDate = "2026-02-11" + +var now = time.Now + +// ValidateDate validates a compatibility date in YYYY-MM-DD format. +func ValidateDate(value string) error { + if value == "" { + return nil + } + + if _, err := parseDate(value); err != nil { + return fmt.Errorf("invalid compatibility_date %q: expected format YYYY-MM-DD", value) + } + + return nil +} + +// IsAtLeast checks whether compatibilityDate is equal to or after requiredDate. +// An empty compatibilityDate falls back to the default compatibility date. +func IsAtLeast(compatibilityDate, requiredDate string) (bool, error) { + if compatibilityDate == "" { + compatibilityDate = DefaultDate() + } + + currentDate, err := parseDate(compatibilityDate) + if err != nil { + return false, fmt.Errorf("invalid compatibility_date %q: expected format YYYY-MM-DD", compatibilityDate) + } + + minDate, err := parseDate(requiredDate) + if err != nil { + return false, fmt.Errorf("invalid required compatibility date %q: expected format YYYY-MM-DD", requiredDate) + } + + return !currentDate.Before(minDate), nil +} + +func parseDate(value string) (time.Time, error) { + return time.Parse(dateLayout, value) +} + +func DefaultDate() string { + return defaultCompatibilityDate +} + +func TodayDate() string { + return now().Format(dateLayout) +} diff --git a/internal/compatibility/date_test.go b/internal/compatibility/date_test.go new file mode 100644 index 00000000..53b00afe --- /dev/null +++ b/internal/compatibility/date_test.go @@ -0,0 +1,49 @@ +package compatibility + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestValidateDate(t *testing.T) { + assert.NoError(t, ValidateDate("")) + assert.NoError(t, ValidateDate("2026-02-11")) + assert.Error(t, ValidateDate("11-02-2026")) +} + +func TestIsAtLeast(t *testing.T) { + result, err := IsAtLeast("", "2026-01-01") + assert.NoError(t, err) + assert.True(t, result) + + result, err = IsAtLeast("2026-02-11", "2026-02-11") + assert.NoError(t, err) + assert.True(t, result) + + result, err = IsAtLeast("2026-02-11", "2026-03-01") + assert.NoError(t, err) + assert.False(t, result) + + _, err = IsAtLeast("invalid", "2026-03-01") + assert.Error(t, err) + + _, err = IsAtLeast("2026-02-11", "invalid") + assert.Error(t, err) +} + +func TestTodayDate(t *testing.T) { + now = func() time.Time { + return time.Date(2026, 2, 11, 12, 0, 0, 0, time.UTC) + } + t.Cleanup(func() { + now = time.Now + }) + + assert.Equal(t, "2026-02-11", TodayDate()) +} + +func TestDefaultDate(t *testing.T) { + assert.Equal(t, "2026-02-11", DefaultDate()) +} diff --git a/internal/extension/app.go b/internal/extension/app.go index ce314fc0..12655e3c 100644 --- a/internal/extension/app.go +++ b/internal/extension/app.go @@ -39,7 +39,7 @@ func (a App) GetComposerName() (string, error) { return "", fmt.Errorf("app does not have a composer name") } -func newApp(path string) (*App, error) { +func newApp(ctx context.Context, path string) (*App, error) { appFileName := fmt.Sprintf("%s/manifest.xml", path) if _, err := os.Stat(appFileName); err != nil { @@ -57,7 +57,7 @@ func newApp(path string) (*App, error) { return nil, fmt.Errorf("newApp: %v", err) } - cfg, err := readExtensionConfig(path) + cfg, err := readExtensionConfig(ctx, path) if err != nil { return nil, fmt.Errorf("newApp: %v", err) } diff --git a/internal/extension/app_test.go b/internal/extension/app_test.go index 39fcb82c..d5a0d3b6 100644 --- a/internal/extension/app_test.go +++ b/internal/extension/app_test.go @@ -122,7 +122,7 @@ func TestIconNotExists(t *testing.T) { assert.NoError(t, os.WriteFile(filepath.Join(appPath, "manifest.xml"), []byte(testAppManifest), os.ModePerm)) - app, err := newApp(appPath) + app, err := newApp(t.Context(), appPath) assert.NoError(t, err) @@ -143,7 +143,7 @@ func TestAppNoLicense(t *testing.T) { assert.NoError(t, os.MkdirAll(filepath.Join(appPath, "Resources/config"), os.ModePerm)) assert.NoError(t, createTestImage(filepath.Join(appPath, "Resources/config/plugin.png"))) - app, err := newApp(appPath) + app, err := newApp(t.Context(), appPath) assert.NoError(t, err) @@ -161,7 +161,7 @@ func TestAppNoCopyright(t *testing.T) { assert.NoError(t, os.MkdirAll(filepath.Join(appPath, "Resources/config"), os.ModePerm)) assert.NoError(t, createTestImage(filepath.Join(appPath, "Resources/config/plugin.png"))) - app, err := newApp(appPath) + app, err := newApp(t.Context(), appPath) assert.NoError(t, err) @@ -179,7 +179,7 @@ func TestAppNoAuthor(t *testing.T) { assert.NoError(t, os.MkdirAll(filepath.Join(appPath, "Resources/config"), os.ModePerm)) assert.NoError(t, createTestImage(filepath.Join(appPath, "Resources/config/plugin.png"))) - app, err := newApp(appPath) + app, err := newApp(t.Context(), appPath) assert.NoError(t, err) @@ -197,7 +197,7 @@ func TestAppHasSecret(t *testing.T) { assert.NoError(t, os.MkdirAll(filepath.Join(appPath, "Resources/config"), os.ModePerm)) assert.NoError(t, createTestImage(filepath.Join(appPath, "Resources/config/plugin.png"))) - app, err := newApp(appPath) + app, err := newApp(t.Context(), appPath) assert.NoError(t, err) @@ -216,7 +216,7 @@ func TestIconExistsDefaultsPath(t *testing.T) { assert.NoError(t, os.WriteFile(filepath.Join(appPath, "manifest.xml"), []byte(testAppManifest), os.ModePerm)) - app, err := newApp(appPath) + app, err := newApp(t.Context(), appPath) assert.NoError(t, err) @@ -235,7 +235,7 @@ func TestIconExistsDifferentPath(t *testing.T) { assert.NoError(t, os.WriteFile(filepath.Join(appPath, "manifest.xml"), []byte(testAppManifestIcon), os.ModePerm)) assert.NoError(t, createTestImageWithSize(filepath.Join(appPath, "app.png"), 120, 120)) - app, err := newApp(appPath) + app, err := newApp(t.Context(), appPath) assert.NoError(t, err) @@ -253,7 +253,7 @@ func TestNoCompatibilityGiven(t *testing.T) { assert.NoError(t, os.WriteFile(filepath.Join(appPath, "manifest.xml"), []byte(testAppManifest), os.ModePerm)) - app, err := newApp(appPath) + app, err := newApp(t.Context(), appPath) assert.NoError(t, err) @@ -268,7 +268,7 @@ func TestCompatibilityGiven(t *testing.T) { assert.NoError(t, os.WriteFile(filepath.Join(appPath, "manifest.xml"), []byte(testAppManifestCompatibility), os.ModePerm)) - app, err := newApp(appPath) + app, err := newApp(t.Context(), appPath) assert.NoError(t, err) @@ -287,7 +287,7 @@ func TestAppWithPHPFiles(t *testing.T) { assert.NoError(t, createTestImage(filepath.Join(appPath, "Resources/config/plugin.png"))) assert.NoError(t, os.WriteFile(filepath.Join(appPath, "test.php"), []byte(" 5 { return fmt.Errorf("store.info.tags.en can contain maximal 5 items") } diff --git a/internal/extension/config_test.go b/internal/extension/config_test.go index 84234830..9eecb485 100644 --- a/internal/extension/config_test.go +++ b/internal/extension/config_test.go @@ -7,6 +7,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/shopware/shopware-cli/internal/compatibility" ) func TestConfigValidationStringListDecode(t *testing.T) { @@ -21,7 +23,7 @@ validation: assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".shopware-extension.yaml"), []byte(cfg), 0o644)) - ext, err := readExtensionConfig(tmpDir) + ext, err := readExtensionConfig(t.Context(), tmpDir) assert.NoError(t, err) assert.Equal(t, 2, len(ext.Validation.Ignore)) assert.Equal(t, "metadata.setup", ext.Validation.Ignore[0].Identifier) @@ -41,7 +43,7 @@ validation: assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".shopware-extension.yaml"), []byte(cfg), 0o644)) - ext, err := readExtensionConfig(tmpDir) + ext, err := readExtensionConfig(t.Context(), tmpDir) assert.NoError(t, err) assert.Equal(t, 2, len(ext.Validation.Ignore)) assert.Equal(t, "metadata.setup", ext.Validation.Ignore[0].Identifier) @@ -187,11 +189,13 @@ func TestReadExtensionConfig(t *testing.T) { t.Run("returns default config when no file exists", func(t *testing.T) { tmpDir := t.TempDir() - config, err := readExtensionConfig(tmpDir) + config, err := readExtensionConfig(t.Context(), tmpDir) require.NoError(t, err) assert.NotNil(t, config) assert.True(t, config.Build.Zip.Assets.Enabled) assert.True(t, config.Build.Zip.Composer.Enabled) + assert.Equal(t, compatibility.DefaultDate(), config.CompatibilityDate) + assert.NoError(t, compatibility.ValidateDate(config.CompatibilityDate)) assert.Equal(t, ".shopware-extension.yml", config.FileName) }) @@ -199,6 +203,7 @@ func TestReadExtensionConfig(t *testing.T) { tmpDir := t.TempDir() configContent := ` +compatibility_date: "2026-02-11" store: default_locale: en_GB build: @@ -206,9 +211,10 @@ build: ` require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".shopware-extension.yml"), []byte(configContent), 0644)) - config, err := readExtensionConfig(tmpDir) + config, err := readExtensionConfig(t.Context(), tmpDir) require.NoError(t, err) assert.Equal(t, "~6.5.0", config.Build.ShopwareVersionConstraint) + assert.Equal(t, "2026-02-11", config.CompatibilityDate) assert.Equal(t, ".shopware-extension.yml", config.FileName) }) @@ -226,7 +232,7 @@ build: require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".shopware-extension.yml"), []byte(ymlContent), 0644)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".shopware-extension.yaml"), []byte(yamlContent), 0644)) - config, err := readExtensionConfig(tmpDir) + config, err := readExtensionConfig(t.Context(), tmpDir) require.NoError(t, err) assert.Equal(t, "from-yml", config.Build.ShopwareVersionConstraint) }) @@ -240,9 +246,45 @@ store: ` require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".shopware-extension.yml"), []byte(invalidContent), 0644)) - _, err := readExtensionConfig(tmpDir) + _, err := readExtensionConfig(t.Context(), tmpDir) assert.Error(t, err) }) + + t.Run("returns error for invalid compatibility date", func(t *testing.T) { + tmpDir := t.TempDir() + + content := ` +compatibility_date: "11-02-2026" +` + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".shopware-extension.yml"), []byte(content), 0o644)) + + _, err := readExtensionConfig(t.Context(), tmpDir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid compatibility_date") + }) +} + +func TestConfigCompatibilityDateHelpers(t *testing.T) { + cfg := &Config{CompatibilityDate: "2026-02-11"} + assert.True(t, cfg.HasCompatibilityDate()) + + ok, err := cfg.IsCompatibilityDateAtLeast("2026-02-01") + assert.NoError(t, err) + assert.True(t, ok) + + ok, err = cfg.IsCompatibilityDateAtLeast("2026-03-01") + assert.NoError(t, err) + assert.False(t, ok) + + _, err = cfg.IsCompatibilityDateAtLeast("invalid") + assert.Error(t, err) + + emptyCfg := &Config{} + assert.False(t, emptyCfg.HasCompatibilityDate()) + + ok, err = emptyCfg.IsCompatibilityDateAtLeast("2000-01-01") + assert.NoError(t, err) + assert.True(t, ok) } func TestValidateExtensionConfig(t *testing.T) { diff --git a/internal/extension/platform.go b/internal/extension/platform.go index b3b9608a..7ebb7864 100644 --- a/internal/extension/platform.go +++ b/internal/extension/platform.go @@ -55,7 +55,7 @@ func (p PlatformPlugin) GetResourcesDirs() []string { return result } -func newPlatformPlugin(path string) (*PlatformPlugin, error) { +func newPlatformPlugin(ctx context.Context, path string) (*PlatformPlugin, error) { composerJsonFile := fmt.Sprintf("%s/composer.json", path) if _, err := os.Stat(composerJsonFile); err != nil { return nil, err @@ -75,7 +75,7 @@ func newPlatformPlugin(path string) (*PlatformPlugin, error) { return nil, ErrPlatformInvalidType } - cfg, err := readExtensionConfig(path) + cfg, err := readExtensionConfig(ctx, path) if err != nil { return nil, fmt.Errorf("newPlatformPlugin: %v", err) } diff --git a/internal/extension/project.go b/internal/extension/project.go index f36a8273..6ab2a53d 100644 --- a/internal/extension/project.go +++ b/internal/extension/project.go @@ -123,7 +123,7 @@ func FindAssetSourcesOfProject(ctx context.Context, project string, shopCfg *sho logging.FromContext(ctx).Infof("Found bundle in project: %s (path: %s)", name, bundlePath) - bundleConfig, err := readExtensionConfig(bundlePath) + bundleConfig, err := readExtensionConfig(ctx, bundlePath) if err != nil { logging.FromContext(ctx).Errorf("Cannot read bundle config: %s", err.Error()) continue @@ -182,7 +182,7 @@ func DumpAndLoadAssetSourcesOfProject(ctx context.Context, project string, shopC Path: pluginsJson[name].BasePath, } - if extensionCfg, err := readExtensionConfig(path.Join(project, pluginsJson[name].BasePath)); err == nil { + if extensionCfg, err := readExtensionConfig(ctx, path.Join(project, pluginsJson[name].BasePath)); err == nil { source.AdminEsbuildCompatible = extensionCfg.Build.Zip.Assets.EnableESBuildForAdmin source.StorefrontEsbuildCompatible = extensionCfg.Build.Zip.Assets.EnableESBuildForStorefront source.NpmStrict = extensionCfg.Build.Zip.Assets.NpmStrict @@ -199,7 +199,7 @@ func FindExtensionsFromProject(ctx context.Context, project string, onlyLocal bo extensions := make(map[string]Extension) if !onlyLocal { - for _, ext := range addExtensionsByComposer(project) { + for _, ext := range addExtensionsByComposer(ctx, project) { name, err := ext.GetName() if err != nil { continue @@ -213,7 +213,7 @@ func FindExtensionsFromProject(ctx context.Context, project string, onlyLocal bo } } - for _, ext := range addExtensionsByWildcard(path.Join(project, "custom", "static-plugins")) { + for _, ext := range addExtensionsByWildcard(ctx, path.Join(project, "custom", "static-plugins")) { name, err := ext.GetName() if err != nil { continue @@ -231,7 +231,7 @@ func FindExtensionsFromProject(ctx context.Context, project string, onlyLocal bo extensions[name] = ext } - for _, ext := range addExtensionsByWildcard(path.Join(project, "custom", "plugins")) { + for _, ext := range addExtensionsByWildcard(ctx, path.Join(project, "custom", "plugins")) { name, err := ext.GetName() if err != nil { continue @@ -249,7 +249,7 @@ func FindExtensionsFromProject(ctx context.Context, project string, onlyLocal bo extensions[name] = ext } - for _, ext := range addExtensionsByWildcard(path.Join(project, "custom", "apps")) { + for _, ext := range addExtensionsByWildcard(ctx, path.Join(project, "custom", "apps")) { name, err := ext.GetName() if err != nil { continue @@ -270,7 +270,7 @@ func FindExtensionsFromProject(ctx context.Context, project string, onlyLocal bo return extensionsSlice } -func addExtensionsByComposer(project string) []Extension { +func addExtensionsByComposer(ctx context.Context, project string) []Extension { var list []Extension lock, err := os.ReadFile(path.Join(project, "composer.lock")) @@ -285,7 +285,7 @@ func addExtensionsByComposer(project string) []Extension { for _, pkg := range composer.Packages { if pkg.PackageType == ComposerTypePlugin || pkg.PackageType == ComposerTypeBundle || pkg.PackageType == ComposerTypeApp { - ext, err := GetExtensionByFolder(path.Join(project, "vendor", pkg.Name)) + ext, err := GetExtensionByFolder(ctx, path.Join(project, "vendor", pkg.Name)) if err != nil { continue } @@ -307,7 +307,7 @@ func addExtensionsByComposer(project string) []Extension { return list } -func addExtensionsByWildcard(extensionDir string) []Extension { +func addExtensionsByWildcard(ctx context.Context, extensionDir string) []Extension { var list []Extension extensions, err := os.ReadDir(extensionDir) @@ -334,7 +334,7 @@ func addExtensionsByWildcard(extensionDir string) []Extension { } if isDir { - ext, err := GetExtensionByFolder(evaluatedPath) + ext, err := GetExtensionByFolder(ctx, evaluatedPath) if err != nil { continue } diff --git a/internal/extension/root.go b/internal/extension/root.go index 9b766bd0..0c18e550 100644 --- a/internal/extension/root.go +++ b/internal/extension/root.go @@ -25,13 +25,13 @@ const ( ComposerTypeBundle = "shopware-bundle" ) -func GetExtensionByFolder(path string) (Extension, error) { +func GetExtensionByFolder(ctx context.Context, path string) (Extension, error) { if _, err := os.Stat(fmt.Sprintf("%s/plugin.xml", path)); err == nil { return nil, fmt.Errorf("shopware 5 is not supported. Please use https://github.com/FriendsOfShopware/FroshPluginUploader instead") } if _, err := os.Stat(fmt.Sprintf("%s/manifest.xml", path)); err == nil { - return newApp(path) + return newApp(ctx, path) } if _, err := os.Stat(fmt.Sprintf("%s/composer.json", path)); err != nil { @@ -40,10 +40,10 @@ func GetExtensionByFolder(path string) (Extension, error) { var ext Extension - ext, err := newPlatformPlugin(path) + ext, err := newPlatformPlugin(ctx, path) if err != nil { if errors.Is(err, ErrPlatformInvalidType) { - ext, err = newShopwareBundle(path) + ext, err = newShopwareBundle(ctx, path) } else { return nil, err } @@ -52,7 +52,7 @@ func GetExtensionByFolder(path string) (Extension, error) { return ext, err } -func GetExtensionByZip(filePath string) (Extension, error) { +func GetExtensionByZip(ctx context.Context, filePath string) (Extension, error) { dir, err := os.MkdirTemp("", "extension") if err != nil { return nil, err @@ -80,7 +80,7 @@ func GetExtensionByZip(filePath string) (Extension, error) { } extName := strings.Split(fileName, "/")[0] - return GetExtensionByFolder(fmt.Sprintf("%s/%s", dir, extName)) + return GetExtensionByFolder(ctx, fmt.Sprintf("%s/%s", dir, extName)) } type extensionTranslated struct { diff --git a/internal/extension/root_test.go b/internal/extension/root_test.go index 59b37b0d..d1665b6e 100644 --- a/internal/extension/root_test.go +++ b/internal/extension/root_test.go @@ -27,7 +27,7 @@ func TestGetExtensionByFolder_DetectsApp(t *testing.T) { ` require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "manifest.xml"), []byte(manifestContent), 0644)) - ext, err := GetExtensionByFolder(tmpDir) + ext, err := GetExtensionByFolder(t.Context(), tmpDir) require.NoError(t, err) assert.Equal(t, TypePlatformApp, ext.GetType()) @@ -77,7 +77,7 @@ func TestGetExtensionByFolder_DetectsPlatformPlugin(t *testing.T) { }` require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(composerContent), 0644)) - ext, err := GetExtensionByFolder(tmpDir) + ext, err := GetExtensionByFolder(t.Context(), tmpDir) require.NoError(t, err) assert.Equal(t, TypePlatformPlugin, ext.GetType()) @@ -109,7 +109,7 @@ func TestGetExtensionByFolder_DetectsShopwareBundle(t *testing.T) { }` require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(composerContent), 0644)) - ext, err := GetExtensionByFolder(tmpDir) + ext, err := GetExtensionByFolder(t.Context(), tmpDir) require.NoError(t, err) assert.Equal(t, TypeShopwareBundle, ext.GetType()) @@ -124,7 +124,7 @@ func TestGetExtensionByFolder_RejectsShopware5Plugin(t *testing.T) { // Create plugin.xml for a Shopware 5 plugin require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "plugin.xml"), []byte(""), 0644)) - _, err := GetExtensionByFolder(tmpDir) + _, err := GetExtensionByFolder(t.Context(), tmpDir) assert.Error(t, err) assert.Contains(t, err.Error(), "shopware 5 is not supported") } @@ -133,7 +133,7 @@ func TestGetExtensionByFolder_RejectsUnknownType(t *testing.T) { tmpDir := t.TempDir() // Empty directory - no manifest.xml, no composer.json - _, err := GetExtensionByFolder(tmpDir) + _, err := GetExtensionByFolder(t.Context(), tmpDir) assert.Error(t, err) assert.Contains(t, err.Error(), "unknown extension type") } @@ -163,7 +163,7 @@ func TestGetExtensionByFolder_PrefersManifestOverComposer(t *testing.T) { }` require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(composerContent), 0644)) - ext, err := GetExtensionByFolder(tmpDir) + ext, err := GetExtensionByFolder(t.Context(), tmpDir) require.NoError(t, err) // Should detect as App since manifest.xml is checked first assert.Equal(t, TypePlatformApp, ext.GetType()) diff --git a/internal/mjml/compiler_test.go b/internal/mjml/compiler_test.go index 46d75a79..fba6e7e9 100644 --- a/internal/mjml/compiler_test.go +++ b/internal/mjml/compiler_test.go @@ -1,7 +1,6 @@ package mjml import ( - "context" "fmt" "os" "path/filepath" @@ -65,7 +64,7 @@ func newMockExec(t *testing.T, scriptContent string) { } func TestCompile(t *testing.T) { - ctx := context.Background() + ctx := t.Context() t.Run("successful compilation", func(t *testing.T) { script := `#!/bin/sh @@ -150,7 +149,7 @@ exit 0` } func TestProcessDirectory(t *testing.T) { - ctx := context.Background() + ctx := t.Context() t.Run("successful processing", func(t *testing.T) { script := `#!/bin/sh diff --git a/internal/packagist/project_composer_json_test.go b/internal/packagist/project_composer_json_test.go index c010c33a..19d2368b 100644 --- a/internal/packagist/project_composer_json_test.go +++ b/internal/packagist/project_composer_json_test.go @@ -1,7 +1,6 @@ package packagist import ( - "context" "encoding/json" "testing" @@ -9,7 +8,7 @@ import ( ) func TestGenerateComposerJson(t *testing.T) { - ctx := context.Background() + ctx := t.Context() t.Run("without audit", func(t *testing.T) { jsonStr, err := GenerateComposerJson(ctx, "6.4.18.0", false, false, false, false) diff --git a/internal/shop/client_test.go b/internal/shop/client_test.go index 5ac7be00..12f65017 100644 --- a/internal/shop/client_test.go +++ b/internal/shop/client_test.go @@ -1,7 +1,6 @@ package shop import ( - "context" "encoding/json" "net/http" "net/http/httptest" @@ -120,7 +119,7 @@ func Test_NewShopClient(t *testing.T) { t.Setenv("SHOPWARE_CLI_API_CLIENT_SECRET", "secret") cfg := &Config{} - client, err := NewShopClient(context.Background(), cfg) + client, err := NewShopClient(t.Context(), cfg) assert.NoError(t, err) assert.NotNil(t, client) } @@ -135,7 +134,7 @@ func Test_NewShopClient_configUrl(t *testing.T) { t.Setenv("SHOPWARE_CLI_API_CLIENT_SECRET", "secret") cfg := &Config{URL: server.URL} - client, err := NewShopClient(context.Background(), cfg) + client, err := NewShopClient(t.Context(), cfg) assert.NoError(t, err) assert.NotNil(t, client) // Ideally, we'd check the client's configured URL here, but the SDK doesn't expose it easily. @@ -151,7 +150,7 @@ func Test_NewShopClient_skipSSLCheck_env(t *testing.T) { t.Setenv("SHOPWARE_CLI_API_DISABLE_SSL_CHECK", "true") cfg := &Config{} - client, err := NewShopClient(context.Background(), cfg) + client, err := NewShopClient(t.Context(), cfg) assert.NoError(t, err) assert.NotNil(t, client) // We cannot easily assert the TLS config here without reflection or modifying the original code. @@ -172,7 +171,7 @@ func Test_NewShopClient_skipSSLCheck_config(t *testing.T) { DisableSSLCheck: true, }, } - client, err := NewShopClient(context.Background(), cfg) + client, err := NewShopClient(t.Context(), cfg) assert.NoError(t, err) assert.NotNil(t, client) // We cannot easily assert the TLS config here without reflection or modifying the original code. @@ -186,7 +185,7 @@ func Test_NewShopClient_NoURL(t *testing.T) { // Config with empty URL cfg := &Config{URL: ""} - _, err := NewShopClient(context.Background(), cfg) + _, err := NewShopClient(t.Context(), cfg) // The current implementation doesn't check for empty URL // The error would come from the SDK later when it tries to make a request assert.Error(t, err) @@ -205,6 +204,6 @@ func Test_NewShopClient_CredentialsError(t *testing.T) { // Config without credentials cfg := &Config{} - _, err := NewShopClient(context.Background(), cfg) + _, err := NewShopClient(t.Context(), cfg) assert.Error(t, err) } diff --git a/internal/shop/config.go b/internal/shop/config.go index f062524b..eb3bc378 100644 --- a/internal/shop/config.go +++ b/internal/shop/config.go @@ -1,6 +1,7 @@ package shop import ( + "context" "fmt" "os" "path" @@ -12,19 +13,23 @@ import ( orderedmap "github.com/wk8/go-ordered-map/v2" "gopkg.in/yaml.v3" + "github.com/shopware/shopware-cli/internal/compatibility" "github.com/shopware/shopware-cli/internal/system" + "github.com/shopware/shopware-cli/logging" ) type Config struct { AdditionalConfigs []string `yaml:"include,omitempty"` // The URL of the Shopware instance - URL string `yaml:"url"` - Build *ConfigBuild `yaml:"build,omitempty"` - AdminApi *ConfigAdminApi `yaml:"admin_api,omitempty"` - ConfigDump *ConfigDump `yaml:"dump,omitempty"` - ConfigDeployment *ConfigDeployment `yaml:"deployment,omitempty"` - Validation *ConfigValidation `yaml:"validation,omitempty"` - ImageProxy *ConfigImageProxy `yaml:"image_proxy,omitempty"` + URL string `yaml:"url"` + // Controls date-based compatibility behavior, formatted as YYYY-MM-DD. + CompatibilityDate string `yaml:"compatibility_date,omitempty" jsonschema:"format=date"` + Build *ConfigBuild `yaml:"build,omitempty"` + AdminApi *ConfigAdminApi `yaml:"admin_api,omitempty"` + ConfigDump *ConfigDump `yaml:"dump,omitempty"` + ConfigDeployment *ConfigDeployment `yaml:"deployment,omitempty"` + Validation *ConfigValidation `yaml:"validation,omitempty"` + ImageProxy *ConfigImageProxy `yaml:"image_proxy,omitempty"` // When enabled, composer scripts will be disabled during CI builds DisableComposerScripts bool `yaml:"disable_composer_scripts,omitempty"` // When enabled, composer install will be skipped during CI builds @@ -40,6 +45,14 @@ func (c *Config) IsAdminAPIConfigured() bool { return (c.AdminApi.ClientId != "" && c.AdminApi.ClientSecret != "") || (c.AdminApi.Username != "" && c.AdminApi.Password != "") } +func (c *Config) HasCompatibilityDate() bool { + return c.CompatibilityDate != "" +} + +func (c *Config) IsCompatibilityDateAtLeast(requiredDate string) (bool, error) { + return compatibility.IsAtLeast(c.CompatibilityDate, requiredDate) +} + type ConfigBuild struct { // When enabled, the assets will not be copied to the public folder DisableAssetCopy bool `yaml:"disable_asset_copy,omitempty"` @@ -362,7 +375,7 @@ type ConfigImageProxy struct { URL string `yaml:"url,omitempty"` } -func ReadConfig(fileName string, allowFallback bool) (*Config, error) { +func ReadConfig(ctx context.Context, fileName string, allowFallback bool) (*Config, error) { config := &Config{foundConfig: false} _, err := os.Stat(fileName) @@ -391,7 +404,7 @@ func ReadConfig(fileName string, allowFallback bool) (*Config, error) { if len(config.AdditionalConfigs) > 0 { for _, additionalConfigFile := range config.AdditionalConfigs { - additionalConfig, err := ReadConfig(additionalConfigFile, allowFallback) + additionalConfig, err := ReadConfig(ctx, additionalConfigFile, allowFallback) if err != nil { return nil, fmt.Errorf("error while reading included config: %s", err.Error()) } @@ -407,10 +420,22 @@ func ReadConfig(fileName string, allowFallback bool) (*Config, error) { return nil, fmt.Errorf("ReadConfig(%s): %v", fileName, err) } + if config.foundConfig && config.CompatibilityDate == "" { + logging.FromContext(ctx).Warnf("Config %s is missing compatibility_date, defaulting to %s", fileName, compatibility.DefaultDate()) + } + + if err := compatibility.ValidateDate(config.CompatibilityDate); err != nil { + return nil, fmt.Errorf("ReadConfig(%s): %v", fileName, err) + } + return fillEmptyConfig(config), nil } func fillEmptyConfig(c *Config) *Config { + if c.CompatibilityDate == "" { + c.CompatibilityDate = compatibility.DefaultDate() + } + if c.Build == nil { c.Build = &ConfigBuild{} } diff --git a/internal/shop/config_test.go b/internal/shop/config_test.go index 8642f088..884a8b86 100644 --- a/internal/shop/config_test.go +++ b/internal/shop/config_test.go @@ -6,6 +6,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/shopware/shopware-cli/internal/compatibility" ) func TestConfigMerging(t *testing.T) { @@ -36,7 +38,7 @@ include: assert.NoError(t, os.WriteFile(baseFilePath, baseConfig, 0644)) assert.NoError(t, os.WriteFile(stagingFilePath, stagingConfig, 0644)) - config, err := ReadConfig(stagingFilePath, false) + config, err := ReadConfig(t.Context(), stagingFilePath, false) assert.NoError(t, err) assert.NotNil(t, config.ConfigDump.Where) @@ -44,6 +46,54 @@ include: assert.NoError(t, os.RemoveAll(tmpDir)) } +func TestReadConfigCompatibilityDateValidation(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".shopware-project.yml") + content := []byte(` +url: https://example.com +compatibility_date: 2026-13-11 +`) + + assert.NoError(t, os.WriteFile(configPath, content, 0o644)) + + _, err := ReadConfig(t.Context(), configPath, false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid compatibility_date") +} + +func TestConfigCompatibilityDateHelpers(t *testing.T) { + cfg := &Config{CompatibilityDate: "2026-02-11"} + assert.True(t, cfg.HasCompatibilityDate()) + + ok, err := cfg.IsCompatibilityDateAtLeast("2026-02-01") + assert.NoError(t, err) + assert.True(t, ok) + + ok, err = cfg.IsCompatibilityDateAtLeast("2026-03-01") + assert.NoError(t, err) + assert.False(t, ok) + + _, err = cfg.IsCompatibilityDateAtLeast("invalid") + assert.Error(t, err) + + emptyCfg := &Config{} + assert.False(t, emptyCfg.HasCompatibilityDate()) + + ok, err = emptyCfg.IsCompatibilityDateAtLeast("2026-01-01") + assert.NoError(t, err) + assert.True(t, ok) +} + +func TestReadConfigFallbackSetsCompatibilityDate(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".shopware-project.yml") + + cfg, err := ReadConfig(t.Context(), configPath, true) + assert.NoError(t, err) + assert.Equal(t, compatibility.DefaultDate(), cfg.CompatibilityDate) + assert.NoError(t, compatibility.ValidateDate(cfg.CompatibilityDate)) +} + func TestConfigDump_EnableAnonymization(t *testing.T) { t.Run("empty config", func(t *testing.T) { config := &ConfigDump{} diff --git a/internal/system/cache_test.go b/internal/system/cache_test.go index 8eedd8cb..6d0cff0b 100644 --- a/internal/system/cache_test.go +++ b/internal/system/cache_test.go @@ -4,7 +4,6 @@ import ( "archive/tar" "bytes" "compress/gzip" - "context" "io" "os" "path/filepath" @@ -20,7 +19,7 @@ func TestDiskCache(t *testing.T) { tmpDir := t.TempDir() cache := NewDiskCache(tmpDir) - ctx := context.Background() + ctx := t.Context() // Test Set and Get testKey := "test-key" @@ -82,7 +81,7 @@ func TestDiskCacheFilePath(t *testing.T) { assert.True(t, len(filepath.Base(filePath)) > 0) // Test setting and getting with complex key - ctx := context.Background() + ctx := t.Context() testData := "test data" err := cache.Set(ctx, testKey, strings.NewReader(testData)) @@ -153,7 +152,7 @@ func TestCacheInterfaceCompliance(t *testing.T) { // Test disk cache var diskCache Cache = NewDiskCache(tmpDir) - ctx := context.Background() + ctx := t.Context() // Test basic operations testKey := "interface-test" @@ -195,7 +194,7 @@ func TestDiskCacheFolderOperations(t *testing.T) { require.NoError(t, err) cache := NewDiskCache(tmpDir) - ctx := context.Background() + ctx := t.Context() cacheKey := "test-folder" // Test StoreFolderCache @@ -254,7 +253,7 @@ func TestDiskCacheStoreFolderCreatesParentDirectory(t *testing.T) { // Create a cache instance with a nested path to ensure parent directories need to be created cache := NewDiskCache(tmpDir) - ctx := context.Background() + ctx := t.Context() cacheKey := "test-folder-with-long-key-that-creates-nested-structure" // Store the folder cache - this should create the necessary parent directory structure diff --git a/internal/system/interaction_test.go b/internal/system/interaction_test.go index e0e33699..7a18e442 100644 --- a/internal/system/interaction_test.go +++ b/internal/system/interaction_test.go @@ -8,7 +8,7 @@ import ( ) func TestWithInteraction(t *testing.T) { - ctx := context.Background() + ctx := t.Context() ctxWithTrue := WithInteraction(ctx, true) assert.NotNil(t, ctxWithTrue) @@ -19,24 +19,24 @@ func TestWithInteraction(t *testing.T) { func TestIsInteractionEnabled(t *testing.T) { t.Run("returns true when context value is not set", func(t *testing.T) { - result := IsInteractionEnabled(context.Background()) + result := IsInteractionEnabled(t.Context()) assert.True(t, result) }) t.Run("returns true when context value is set to true", func(t *testing.T) { - ctx := WithInteraction(context.Background(), true) + ctx := WithInteraction(t.Context(), true) result := IsInteractionEnabled(ctx) assert.True(t, result) }) t.Run("returns false when context value is set to false", func(t *testing.T) { - ctx := WithInteraction(context.Background(), false) + ctx := WithInteraction(t.Context(), false) result := IsInteractionEnabled(ctx) assert.False(t, result) }) t.Run("returns true when context value is invalid type", func(t *testing.T) { - ctx := context.WithValue(context.Background(), interactionKey{}, "invalid") + ctx := context.WithValue(t.Context(), interactionKey{}, "invalid") result := IsInteractionEnabled(ctx) assert.True(t, result) }) diff --git a/internal/verifier/project.go b/internal/verifier/project.go index 2142b7b1..d210d451 100644 --- a/internal/verifier/project.go +++ b/internal/verifier/project.go @@ -97,7 +97,7 @@ func GetConfigFromProject(root string, onlyLocal bool) (*ToolConfig, error) { vendorPath := path.Join(root, "vendor") - shopCfg, err := shop.ReadConfig(path.Join(root, ".shopware-project.yml"), true) + shopCfg, err := shop.ReadConfig(logging.DisableLogger(context.Background()), path.Join(root, ".shopware-project.yml"), true) if err != nil { return nil, err } diff --git a/shopware-extension-schema.json b/shopware-extension-schema.json index 41b1edd3..4e410b3c 100644 --- a/shopware-extension-schema.json +++ b/shopware-extension-schema.json @@ -30,6 +30,11 @@ }, "Config": { "properties": { + "compatibility_date": { + "type": "string", + "format": "date", + "description": "Controls date-based compatibility behavior, formatted as YYYY-MM-DD." + }, "store": { "$ref": "#/$defs/ConfigStore", "description": "Store is the store configuration of the extension." diff --git a/shopware-project-schema.json b/shopware-project-schema.json index 69075eae..cd0863b2 100644 --- a/shopware-project-schema.json +++ b/shopware-project-schema.json @@ -15,6 +15,11 @@ "type": "string", "description": "The URL of the Shopware instance" }, + "compatibility_date": { + "type": "string", + "format": "date", + "description": "Controls date-based compatibility behavior, formatted as YYYY-MM-DD." + }, "build": { "$ref": "#/$defs/ConfigBuild" },