From 18a6028386e85d63a96a06d0787a8082c9637158 Mon Sep 17 00:00:00 2001 From: Toan Do Date: Wed, 5 Aug 2026 00:48:52 +0700 Subject: [PATCH] refactor(frameworks): consolidate framework ownership Establishes framework packages and the registry as the source of truth for framework-specific behavior, while generic orchestration stays in engine, command, and desktop layers. Ownership model: - internal/frameworks/: identity, aliases, defaults, capabilities, assets, lifecycle hooks, remote metadata, table-prefix rules, tool commands, and upgrades. - internal/frameworks: typed specs, registry construction, alias resolution, parent inheritance, validation, shared abstractions. - internal/engine: framework-agnostic orchestration (rendering, SQL execution, migration dispatch, runtime policy, configuration). - internal/cmd / internal/desktop: consume resolved framework definitions instead of owning framework-specific switches. Family inheritance: OpenMage inherits Magento 1 behavior, Mage-OS inherits Magento 2 behavior. Parents hold shared family behavior; children provide only their identity and genuine overrides via FrameworkSpec/FrameworkPatch, so future family changes don't need duplicating across forks. Registry and definitions: - Consolidated canonical names, aliases, manifests, defaults, capabilities, discovery, and operational hooks into typed framework definitions. - Added validated parent inheritance and alias resolution to the registry (RegisterSpecs, cycle/unknown-parent detection). - Kept generated framework registration checked by CI. Framework packages and assets: - Moved framework-specific blueprints, nginx templates, Varnish assets, profiles, metadata readers, upgrades, table-prefix logic, and bootstrap behavior into their owning packages. - Added embedded framework blueprint filesystems and a merged generic asset view for rendering and tests. - Moved shared Magento-family behavior to the Magento 1/Magento 2 parents, leaving OpenMage and Mage-OS as child deltas. Generic orchestration: - Replaced framework conditionals with registry dispatch for bootstrap plans, deploy locale queries, Varnish defaults, runtime/chown policy, migrations, tool execution, and related command behavior. - Kept generic SQL execution/orchestration in engine/bootstrap rather than moving infrastructure concerns into frameworks. Desktop and contributor experience: - Desktop onboarding now receives framework names, labels, and aliases from the backend registry (ListFrameworks bridge). - Updated English and Vietnamese framework-authoring documentation to cover the Spec()/Parent/FrameworkPatch/Override[T] inheritance mechanism, which the initial docs pass omitted; fixed several other stale docs surfaced along the way (table_prefix scope, detected-frameworks table, README's upgrade-pipeline list, CLAUDE.md's rotting file counts and blueprint-location note). - Added regression tests for registry inheritance, aliases, framework operations, blueprints, command dispatch, remote credential projection, desktop wiring, and source overrides. Cleanup: - Removed a dead compatibility fallback in Magento 2's PostClone (CmdHelpers.RunFrameworkAdminCreate/RunFrameworkReindex) that no production caller ever populated and carried a latent nil-pointer panic path if ever reached; rewrote its tests to cover the real RunAdminCreate/RunReindex path instead. - Fixed a staticcheck S1016 finding in the blueprints union-FS. No config-file format migration is required. Existing framework IDs and aliases remain supported through registry resolution. No new product-facing behavior is intentionally changed. Closes #104 --- CLAUDE.md | 9 +- README.md | 2 +- desktop/frontend/main.js | 1 + desktop/frontend/modules/onboarding.js | 184 ++++-- desktop/frontend/services/bridge.js | 4 + docs/developer/adding-a-framework.md | 144 ++++- docs/developer/architecture.md | 2 +- docs/getting-started/getting-started.md | 3 + docs/reference/cli-commands.md | 2 +- docs/reference/configuration.md | 4 +- docs/vi/developer/adding-a-framework.md | 144 ++++- docs/vi/developer/architecture.md | 2 +- docs/vi/getting-started/getting-started.md | 3 + docs/vi/reference/cli-commands.md | 2 +- docs/vi/reference/configuration.md | 4 +- internal/blueprints/blueprints.go | 533 +++++++++++++++++- internal/cmd/bootstrap.go | 15 +- internal/cmd/bootstrap_composer.go | 23 +- internal/cmd/bootstrap_fresh_install.go | 49 +- internal/cmd/bootstrap_magento.go | 122 +--- internal/cmd/bootstrap_options.go | 8 +- internal/cmd/bootstrap_plan.go | 21 +- internal/cmd/bootstrap_post_install.go | 94 +-- internal/cmd/bootstrap_remote.go | 87 ++- internal/cmd/config_auto.go | 53 +- internal/cmd/db_credentials.go | 200 ++----- internal/cmd/db_import.go | 2 +- internal/cmd/deploy.go | 17 +- internal/cmd/doctor_fix.go | 9 +- internal/cmd/frameworks.go | 122 ++-- internal/cmd/init.go | 42 +- internal/cmd/init_frameworks.go | 35 ++ internal/cmd/open_targets.go | 144 +---- internal/cmd/profile.go | 6 +- internal/cmd/sync.go | 7 +- internal/cmd/test_helpers.go | 25 + internal/cmd/test_project.go | 72 +-- internal/cmd/up.go | 45 +- internal/cmd/vscode_setup.go | 30 +- internal/conventions/orchestration.go | 2 +- internal/desktop/app.go | 20 +- internal/desktop/remotes.go | 34 +- internal/desktop/types.go | 9 + internal/desktop/utils.go | 25 +- internal/engine/bootstrap/base.go | 62 +- internal/engine/bootstrap/magento1.go | 97 ---- internal/engine/bootstrap/sql.go | 34 ++ internal/engine/chown_directories.go | 21 + internal/engine/config.go | 18 +- internal/engine/config_normalize.go | 7 +- internal/engine/discovery.go | 43 ++ internal/engine/framework_config.go | 44 +- internal/engine/framework_family.go | 25 - internal/engine/framework_manifest.go | 13 +- internal/engine/framework_manifest.json | 16 +- internal/engine/framework_runtime_policy.go | 22 + internal/engine/local_image_fallback.go | 74 +-- internal/engine/migrate.go | 36 +- internal/engine/migration_registry.go | 29 + internal/engine/php_image_variant.go | 80 +++ internal/engine/profile.go | 85 +-- internal/engine/profile_registry.go | 196 +------ internal/engine/profile_shift.go | 2 +- internal/engine/profiles.json | 487 +--------------- internal/engine/proxy.go | 32 +- internal/engine/remote/magento_shared.go | 71 +++ internal/engine/remote/runner.go | 2 +- internal/engine/remote/snapshot.go | 2 +- internal/engine/render.go | 103 ++-- internal/engine/run_mapping_assets.go | 34 ++ internal/engine/runtime_images.go | 11 +- internal/engine/snapshot.go | 22 +- internal/engine/store_domains.go | 2 +- internal/engine/table_prefix.go | 96 +--- internal/engine/upgrade.go | 48 +- internal/frameworks/admin_path.go | 45 ++ internal/frameworks/all_generated.go | 33 +- .../cakephp/blueprint}/cakephp.conf | 0 internal/frameworks/cakephp/cakephp.go | 26 + internal/frameworks/cakephp/embed.go | 29 + internal/frameworks/cakephp/spec.go | 6 + internal/frameworks/custom/config.go | 40 ++ internal/frameworks/custom/custom.go | 24 + internal/frameworks/custom/manifest.go | 17 + internal/frameworks/custom/spec.go | 6 + .../django/blueprint}/services.yml | 0 internal/frameworks/django/bootstrap.go | 4 +- internal/frameworks/django/django.go | 10 + internal/frameworks/django/embed.go | 27 + internal/frameworks/django/spec.go | 6 + .../drupal/blueprint}/drupal.conf | 0 internal/frameworks/drupal/drupal.go | 36 +- internal/frameworks/drupal/embed.go | 28 + internal/frameworks/drupal/spec.go | 6 + .../emdash/blueprint}/services.yml | 0 internal/frameworks/emdash/embed.go | 27 + internal/frameworks/emdash/emdash.go | 20 +- internal/frameworks/emdash/render.go | 27 + internal/frameworks/emdash/spec.go | 6 + internal/frameworks/gen/generator/discover.go | 5 +- internal/frameworks/gen/generator/render.go | 9 +- .../laravel/blueprint}/laravel.conf | 0 .../laravel/blueprint}/services.yml | 0 internal/frameworks/laravel/embed.go | 28 + internal/frameworks/laravel/laravel.go | 37 +- internal/frameworks/laravel/spec.go | 6 + .../laravel/upgrade.go} | 7 +- .../magento1/blueprint}/magento1.conf | 0 .../magento1/blueprint}/services.yml | 0 internal/frameworks/magento1/bootstrap.go | 4 +- internal/frameworks/magento1/embed.go | 28 + internal/frameworks/magento1/legacy.go | 49 ++ internal/frameworks/magento1/magento1.go | 44 +- .../magento1/metadata.go} | 13 +- internal/frameworks/magento1/spec.go | 6 + internal/frameworks/magento1/table_prefix.go | 32 ++ .../magento1/upgrade.go} | 7 +- internal/frameworks/magento2/admin_path.go | 40 ++ .../magento2/blueprint}/magento2.conf | 0 .../magento2/blueprint}/varnish/default.vcl | 0 internal/frameworks/magento2/bootstrap.go | 83 ++- .../magento2/bootstrap_environment.go | 73 +++ internal/frameworks/magento2/deploy.go | 6 + internal/frameworks/magento2/embed.go | 37 ++ internal/frameworks/magento2/local_admin.go | 96 ++++ internal/frameworks/magento2/magento2.go | 104 +++- .../magento2/metadata.go} | 76 +-- .../magento2/postinstall.go} | 118 ++-- internal/frameworks/magento2/profile.go | 234 ++++++++ internal/frameworks/magento2/profiles.json | 404 +++++++++++++ internal/frameworks/magento2/spec.go | 6 + internal/frameworks/magento2/table_prefix.go | 37 ++ .../magento2/upgrade.go} | 42 +- internal/frameworks/mageos/mageos.go | 51 ++ internal/frameworks/mageos/upgrade.go | 24 + .../nextjs/blueprint}/services.yml | 0 internal/frameworks/nextjs/embed.go | 27 + internal/frameworks/nextjs/nextjs.go | 19 +- internal/frameworks/nextjs/render.go | 15 + internal/frameworks/nextjs/spec.go | 6 + internal/frameworks/openmage/bootstrap.go | 5 +- internal/frameworks/openmage/openmage.go | 49 ++ .../prestashop/blueprint}/prestashop.conf | 0 .../prestashop/blueprint}/services.yml | 0 internal/frameworks/prestashop/bootstrap.go | 12 +- internal/frameworks/prestashop/embed.go | 28 + .../prestashop/metadata.go} | 76 +-- internal/frameworks/prestashop/prestashop.go | 37 +- internal/frameworks/prestashop/spec.go | 6 + .../frameworks/prestashop/table_prefix.go | 24 + internal/frameworks/registry.go | 195 ++++++- .../shared/dotenv/metadata.go} | 162 ++---- .../shopware/blueprint}/services.yml | 0 .../shopware/blueprint}/shopware.conf | 0 internal/frameworks/shopware/embed.go | 28 + internal/frameworks/shopware/shopware.go | 35 +- internal/frameworks/shopware/spec.go | 6 + .../symfony/blueprint}/services.yml | 0 .../symfony/blueprint}/symfony.conf | 0 internal/frameworks/symfony/embed.go | 28 + internal/frameworks/symfony/spec.go | 6 + internal/frameworks/symfony/symfony.go | 36 +- .../symfony/upgrade.go} | 7 +- internal/frameworks/types/definition.go | 265 +++++++++ internal/frameworks/types/spec.go | 255 +++++++++ internal/frameworks/types/testing.go | 8 + internal/frameworks/types/tooling.go | 13 + .../wordpress/blueprint}/wordpress.conf | 0 .../wordpress/compatibility.go} | 5 +- internal/frameworks/wordpress/embed.go | 28 + .../wordpress/metadata.go} | 67 ++- internal/frameworks/wordpress/spec.go | 6 + .../wordpress/upgrade.go} | 7 +- internal/frameworks/wordpress/wordpress.go | 79 ++- tests/blueprint_content_test.go | 140 +---- tests/blueprint_features_test.go | 8 +- tests/blueprint_override_test.go | 8 +- tests/blueprint_source_override_test.go | 63 +++ tests/blueprint_workflow_test.go | 62 +- tests/blueprints_union_fs_test.go | 125 ++++ tests/bootstrap_composer_policy_test.go | 22 + tests/bootstrap_magento1_test.go | 6 +- tests/bootstrap_magento_env_test.go | 14 +- tests/bootstrap_post_install_test.go | 25 - tests/bootstrap_prestashop_test.go | 20 +- tests/config_auto_command_test.go | 72 ++- tests/configure_command_test.go | 19 +- tests/desktop_list_frameworks_test.go | 48 ++ tests/dotenv_metadata_test.go | 8 +- tests/framework_alias_registry_test.go | 32 ++ tests/framework_gen_discover_test.go | 4 + tests/framework_gen_render_test.go | 32 +- tests/framework_operational_hooks_test.go | 83 +++ tests/framework_registry_completeness_test.go | 17 +- tests/framework_registry_inheritance_test.go | 196 +++++++ tests/framework_snapshot_test.go | 6 +- tests/framework_test_commands_test.go | 31 + tests/framework_tool_commands_test.go | 39 ++ tests/init_frameworks_test.go | 29 + tests/integration/blueprint_test.go | 27 +- tests/integration/edge_cases_test.go | 20 +- tests/integration/env_lifecycle_test.go | 2 +- tests/integration/errors_test.go | 4 +- .../framework_shift_validation_test.go | 5 +- tests/integration/framework_test.go | 2 +- tests/integration/integration_test.go | 45 +- tests/integration/magento_config_test.go | 23 +- tests/integration/magento_shift_test.go | 21 +- tests/integration/workflow_test.go | 8 +- tests/magento1_multistore_test.go | 7 +- tests/magento_composer_install_test.go | 13 +- tests/magento_detection_helpers_test.go | 8 +- tests/magento_family_fresh_install_test.go | 43 +- tests/magento_multistore_test.go | 7 +- tests/magento_search_fix_test.go | 4 +- tests/magento_upgrade_test.go | 4 +- tests/mageos_blueprint_test.go | 8 +- tests/mageos_upgrade_test.go | 5 +- tests/open_command_test.go | 37 +- tests/relax_packages_test.go | 6 +- .../remote_db_credentials_projection_test.go | 44 ++ tests/remote_magento_metadata_test.go | 12 +- tests/remote_prestashop_metadata_test.go | 12 +- tests/remote_wordpress_metadata_test.go | 46 ++ tests/snapshot_engine_test.go | 11 + tests/table_prefix_mageos_test.go | 9 +- tests/table_prefix_prestashop_test.go | 9 +- tests/up_command_test.go | 72 +-- tests/varnish_template_test.go | 2 +- tests/web_server_support_template_test.go | 32 +- 230 files changed, 6418 insertions(+), 3083 deletions(-) create mode 100644 internal/cmd/init_frameworks.go delete mode 100644 internal/engine/bootstrap/magento1.go create mode 100644 internal/engine/bootstrap/sql.go create mode 100644 internal/engine/chown_directories.go delete mode 100644 internal/engine/framework_family.go create mode 100644 internal/engine/framework_runtime_policy.go create mode 100644 internal/engine/migration_registry.go create mode 100644 internal/engine/php_image_variant.go create mode 100644 internal/engine/remote/magento_shared.go create mode 100644 internal/engine/run_mapping_assets.go create mode 100644 internal/frameworks/admin_path.go rename internal/{blueprints/files/support/nginx/templates => frameworks/cakephp/blueprint}/cakephp.conf (100%) create mode 100644 internal/frameworks/cakephp/embed.go create mode 100644 internal/frameworks/cakephp/spec.go create mode 100644 internal/frameworks/custom/config.go create mode 100644 internal/frameworks/custom/custom.go create mode 100644 internal/frameworks/custom/manifest.go create mode 100644 internal/frameworks/custom/spec.go rename internal/{blueprints/files/django => frameworks/django/blueprint}/services.yml (100%) create mode 100644 internal/frameworks/django/embed.go create mode 100644 internal/frameworks/django/spec.go rename internal/{blueprints/files/support/nginx/templates => frameworks/drupal/blueprint}/drupal.conf (100%) create mode 100644 internal/frameworks/drupal/embed.go create mode 100644 internal/frameworks/drupal/spec.go rename internal/{blueprints/files/emdash => frameworks/emdash/blueprint}/services.yml (100%) create mode 100644 internal/frameworks/emdash/embed.go create mode 100644 internal/frameworks/emdash/render.go create mode 100644 internal/frameworks/emdash/spec.go rename internal/{blueprints/files/support/nginx/templates => frameworks/laravel/blueprint}/laravel.conf (100%) rename internal/{blueprints/files/laravel => frameworks/laravel/blueprint}/services.yml (100%) create mode 100644 internal/frameworks/laravel/embed.go create mode 100644 internal/frameworks/laravel/spec.go rename internal/{engine/upgrade_laravel.go => frameworks/laravel/upgrade.go} (91%) rename internal/{blueprints/files/support/nginx/templates => frameworks/magento1/blueprint}/magento1.conf (100%) rename internal/{blueprints/files/magento1 => frameworks/magento1/blueprint}/services.yml (100%) create mode 100644 internal/frameworks/magento1/embed.go create mode 100644 internal/frameworks/magento1/legacy.go rename internal/{engine/remote/magento1_metadata.go => frameworks/magento1/metadata.go} (88%) create mode 100644 internal/frameworks/magento1/spec.go create mode 100644 internal/frameworks/magento1/table_prefix.go rename internal/{engine/upgrade_magento1.go => frameworks/magento1/upgrade.go} (93%) create mode 100644 internal/frameworks/magento2/admin_path.go rename internal/{blueprints/files/support/nginx/templates => frameworks/magento2/blueprint}/magento2.conf (100%) rename internal/{blueprints/files/magento2 => frameworks/magento2/blueprint}/varnish/default.vcl (100%) create mode 100644 internal/frameworks/magento2/bootstrap_environment.go create mode 100644 internal/frameworks/magento2/deploy.go create mode 100644 internal/frameworks/magento2/embed.go create mode 100644 internal/frameworks/magento2/local_admin.go rename internal/{engine/remote/magento_metadata.go => frameworks/magento2/metadata.go} (68%) rename internal/{engine/magento.go => frameworks/magento2/postinstall.go} (93%) create mode 100644 internal/frameworks/magento2/profile.go create mode 100644 internal/frameworks/magento2/profiles.json create mode 100644 internal/frameworks/magento2/spec.go create mode 100644 internal/frameworks/magento2/table_prefix.go rename internal/{engine/upgrade_magento2.go => frameworks/magento2/upgrade.go} (90%) create mode 100644 internal/frameworks/mageos/upgrade.go rename internal/{blueprints/files/nextjs => frameworks/nextjs/blueprint}/services.yml (100%) create mode 100644 internal/frameworks/nextjs/embed.go create mode 100644 internal/frameworks/nextjs/render.go create mode 100644 internal/frameworks/nextjs/spec.go rename internal/{blueprints/files/support/nginx/templates => frameworks/prestashop/blueprint}/prestashop.conf (100%) rename internal/{blueprints/files/prestashop => frameworks/prestashop/blueprint}/services.yml (100%) create mode 100644 internal/frameworks/prestashop/embed.go rename internal/{engine/remote/prestashop_metadata.go => frameworks/prestashop/metadata.go} (57%) create mode 100644 internal/frameworks/prestashop/spec.go create mode 100644 internal/frameworks/prestashop/table_prefix.go rename internal/{engine/remote/dotenv_metadata.go => frameworks/shared/dotenv/metadata.go} (50%) rename internal/{blueprints/files/shopware => frameworks/shopware/blueprint}/services.yml (100%) rename internal/{blueprints/files/support/nginx/templates => frameworks/shopware/blueprint}/shopware.conf (100%) create mode 100644 internal/frameworks/shopware/embed.go create mode 100644 internal/frameworks/shopware/spec.go rename internal/{blueprints/files/symfony => frameworks/symfony/blueprint}/services.yml (100%) rename internal/{blueprints/files/support/nginx/templates => frameworks/symfony/blueprint}/symfony.conf (100%) create mode 100644 internal/frameworks/symfony/embed.go create mode 100644 internal/frameworks/symfony/spec.go rename internal/{engine/upgrade_symfony.go => frameworks/symfony/upgrade.go} (92%) create mode 100644 internal/frameworks/types/spec.go create mode 100644 internal/frameworks/types/testing.go create mode 100644 internal/frameworks/types/tooling.go rename internal/{blueprints/files/support/nginx/templates => frameworks/wordpress/blueprint}/wordpress.conf (100%) rename internal/{engine/wordpress_compatibility.go => frameworks/wordpress/compatibility.go} (98%) create mode 100644 internal/frameworks/wordpress/embed.go rename internal/{engine/remote/wordpress_metadata.go => frameworks/wordpress/metadata.go} (51%) create mode 100644 internal/frameworks/wordpress/spec.go rename internal/{engine/upgrade_wordpress.go => frameworks/wordpress/upgrade.go} (90%) create mode 100644 tests/blueprint_source_override_test.go create mode 100644 tests/blueprints_union_fs_test.go create mode 100644 tests/bootstrap_composer_policy_test.go create mode 100644 tests/desktop_list_frameworks_test.go create mode 100644 tests/framework_alias_registry_test.go create mode 100644 tests/framework_operational_hooks_test.go create mode 100644 tests/framework_registry_inheritance_test.go create mode 100644 tests/framework_test_commands_test.go create mode 100644 tests/framework_tool_commands_test.go create mode 100644 tests/init_frameworks_test.go create mode 100644 tests/remote_db_credentials_projection_test.go create mode 100644 tests/remote_wordpress_metadata_test.go diff --git a/CLAUDE.md b/CLAUDE.md index dc77d849..8d5f7f5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,14 +18,14 @@ Go-based local development orchestrator for PHP and web projects (Magento, Larav cmd/govard/main.go # CLI entrypoint cmd/govard-desktop/ # Desktop app (Wails) desktop/frontend/ # Desktop frontend (vanilla JS) -internal/cmd/ # Cobra commands (70 files) +internal/cmd/ # Cobra commands bootstrap*.go # Bootstrap workflows config_*.go # Config management db*.go # Database commands doctor*.go # Diagnostics & fixes profile*.go # Profile detection/apply up*.go # Environment startup -internal/engine/ # Core engine (20 files) +internal/engine/ # Core engine (framework-agnostic dispatch + registries) config*.go # Config structs, normalize, persist compose*.go # Docker compose generation blueprint*.go # Blueprint rendering @@ -33,7 +33,8 @@ internal/engine/ # Core engine (20 files) lockfile.go # Lock file management migrate.go # DDEV/Warden migration doctor*.go # Diagnostics -internal/blueprints/ # Blueprint templates +internal/frameworks// # Per-framework definition, bootstrap, and blueprint assets +internal/blueprints/ # Shared blueprint templates (assets specific to one framework live in that framework's own package instead) internal/conventions/ # Constants, conventions internal/desktop/ # Desktop backend internal/proxy/ # Caddy/proxy TLS @@ -101,7 +102,7 @@ When adding/modifying commands: `internal/engine/render.go`'s `BlueprintVersion` const forces existing projects to re-render (`govard env up`) by invalidating a stored content hash. -- Editing files under `internal/blueprints/files/**` (base.yml, framework yml, nginx templates) already busts that hash automatically via content fingerprinting — **no bump needed**. +- Editing files under `internal/blueprints/files/**` (shared base.yml, generic nginx templates) or any `internal/frameworks//blueprint/**` (a framework's own compose fragment, nginx template, etc. — embedded via that package's `embed.go` and grafted into the merged `blueprints.FS` at init time) already busts that hash automatically via content fingerprinting — **no bump needed**. - Bump `BlueprintVersion` only when Go rendering logic changes (`render.go`, `config_normalize.go`, `framework_config.go`, `profile.go`, etc.) in a way that changes rendered output *without* changing blueprint file bytes — those changes aren't hash-detected. - When bumped, note it in `CHANGELOG.md` under a "Blueprint Lifecycle" bullet (see prior entries for wording). diff --git a/README.md b/README.md index 2e03699b..88820703 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ At a glance, these are the areas where Govard delivers stronger day-to-day value - **Global Services**: Built-in Proxy (Caddy), Mailpit, PHPMyAdmin, and Portainer (Default login for Portainer is `admin` / `AdminGovard123$`). - **Search Engine Host Access**: Elasticsearch/OpenSearch is automatically reachable from the host at `http://.test:9200` — no extra config, reuses the same Caddy proxy that serves your project's HTTPS domain. - **Desktop Dashboard**: Wails-based UI with live logs, quick actions, and settings. -- **Native Framework Upgrades**: Multi-framework upgrade pipeline (`govard upgrade`) for Magento 2, Mage-OS, Laravel, Symfony, and WordPress that automates environment restarts, dependency updates, and database migrations. +- **Native Framework Upgrades**: Multi-framework upgrade pipeline (`govard upgrade`) for Magento 2, Mage-OS, Magento 1, Laravel, Symfony, and WordPress that automates environment restarts, dependency updates, and database migrations. --- diff --git a/desktop/frontend/main.js b/desktop/frontend/main.js index cd49ff1e..f2a0bc13 100644 --- a/desktop/frontend/main.js +++ b/desktop/frontend/main.js @@ -2022,6 +2022,7 @@ const bootstrap = async () => { loadFooterVersion(), settingsController.load(), refreshDashboard(), + onboardingController.loadFrameworkOptions(), ]).catch((e) => console.error("Parallel bootstrap error:", e)); if (getState().sidebarMode === "global-services") { await globalServicesController.refreshLogs(); diff --git a/desktop/frontend/modules/onboarding.js b/desktop/frontend/modules/onboarding.js index 58d58454..7b4bc72f 100644 --- a/desktop/frontend/modules/onboarding.js +++ b/desktop/frontend/modules/onboarding.js @@ -1,5 +1,17 @@ import { normalizeRemotesPayload } from "./remotes.js"; +// Legacy shorthand aliases kept as a static fallback so normalization still +// works correctly for callers that run before loadFrameworkOptions() has +// populated frameworkOptionsCache (e.g. very early app lifecycle, or any +// caller that doesn't go through main.js's bootstrap). Once the registry +// loads, buildFrameworkLookups(frameworkOptionsCache) takes precedence. +const legacyFrameworkAliases = { + m2: "magento2", + "mage-os": "mageos", + m1: "magento1", + wp: "wordpress", +}; + export const normalizeOnboardingFramework = (framework = "") => { const normalized = String(framework || "") .trim() @@ -8,19 +20,12 @@ export const normalizeOnboardingFramework = (framework = "") => { if (["", "auto", "detect"].includes(normalized)) { return ""; } - if (normalized === "m2") { - return "magento2"; - } - if (normalized === "mage-os") { - return "mageos"; - } - if (normalized === "m1") { - return "magento1"; - } - if (normalized === "wp") { - return "wordpress"; - } - return normalized; + const { aliasToName } = buildFrameworkLookups(frameworkOptionsCache); + return ( + aliasToName.get(normalized) || + legacyFrameworkAliases[normalized] || + normalized + ); }; export const normalizeOnboardingGitProtocol = (protocol = "") => { @@ -87,33 +92,48 @@ export const normalizeOnboardingDomain = (domain = "", projectPath = "") => { return `${base}.test`; }; +// extraPathInferenceAliases covers path-matching hints that are NOT +// registered as Go-side Aliases (that field also drives CLI/config alias +// normalization, a broader surface out of scope for path inference) but +// were historically recognized when inferring a framework from a project +// path (e.g. a folder named "my-next-app" or "mage-os-store"). +const extraPathInferenceAliases = { + nextjs: ["next"], + mageos: ["mage-os"], +}; + const inferFrameworkFromPath = (projectPath = "") => { const value = String(projectPath || "").toLowerCase(); if (!value) { return ""; } - if (value.includes("mageos") || value.includes("mage-os")) { - return "mageos"; - } - if (value.includes("magento2") || value.includes("m2")) { - return "magento2"; - } - if (value.includes("magento1") || value.includes("m1")) { - return "magento1"; - } - if (value.includes("laravel")) { - return "laravel"; - } - if (value.includes("symfony")) { - return "symfony"; - } - if (value.includes("wordpress") || value.includes("wp")) { - return "wordpress"; - } - if (value.includes("next")) { - return "nextjs"; + // frameworkOptionsCache is sourced from a Go map with no defined + // iteration order, so "first match wins" would be nondeterministic + // whenever two frameworks' candidates overlap as substrings (e.g. + // magento2's "magento" alias is itself a substring of "magento1"). + // Scanning every candidate across every framework and keeping the + // single longest match instead makes the more specific name/alias win + // deterministically, independent of cache order. + let bestName = ""; + let bestLength = 0; + for (const option of frameworkOptionsCache) { + const name = String(option?.name || "").toLowerCase(); + if (!name) { + continue; + } + const candidates = [ + name, + ...(option?.aliases || []).map((a) => String(a).toLowerCase()), + ...(extraPathInferenceAliases[name] || []), + ]; + for (const candidate of candidates) { + if (candidate && value.includes(candidate) && candidate.length > bestLength) { + bestName = name; + bestLength = candidate.length; + } + } } - return ""; + return bestName; }; const levelToHintClass = { @@ -150,18 +170,16 @@ const setHint = (element, message, level = "muted") => { const formatFrameworkLabel = (framework = "") => { const normalized = normalizeOnboardingFramework(framework); - const labels = { - "": "Auto-detect", - magento2: "Magento 2", - mageos: "Mage-OS", - magento1: "Magento 1", - laravel: "Laravel", - symfony: "Symfony", - wordpress: "WordPress", - nextjs: "Next.js", - custom: "Custom", - }; - return labels[normalized] || framework || "Auto-detect"; + if (normalized === "") { + return "Auto-detect"; + } + if (normalized === "custom") { + return "Custom"; + } + const match = frameworkOptionsCache.find( + (option) => String(option?.name || "").toLowerCase() === normalized, + ); + return match?.displayName || framework || "Auto-detect"; }; const normalizeOnboardingFrameworkVersion = (frameworkVersion = "") => @@ -186,6 +204,33 @@ const frameworkVersionPlaceholderByFramework = { nextjs: "15", }; +let frameworkOptionsCache = []; + +const buildFrameworkLookups = (options = []) => { + const byName = new Map(); + const aliasToName = new Map(); + options.forEach((option) => { + const name = String(option?.name || "").trim().toLowerCase(); + if (!name) { + return; + } + byName.set(name, { + name, + displayName: String(option?.displayName || "").trim() || name, + aliases: Array.isArray(option?.aliases) + ? option.aliases.map((alias) => String(alias || "").trim().toLowerCase()).filter(Boolean) + : [], + }); + (option?.aliases || []).forEach((alias) => { + const normalizedAlias = String(alias || "").trim().toLowerCase(); + if (normalizedAlias) { + aliasToName.set(normalizedAlias, name); + } + }); + }); + return { byName, aliasToName }; +}; + const defaultServiceOptions = { varnish: false, redis: false, @@ -505,6 +550,45 @@ export const createOnboardingController = ({ }; }; + const populateFrameworkSelect = () => { + if (!refs.projectFramework) { + return; + } + const select = refs.projectFramework; + const previousValue = select.value; + Array.from(select.querySelectorAll("option[data-dynamic-framework]")).forEach( + (node) => node.remove(), + ); + const customOption = select.querySelector('option[value="custom"]'); + frameworkOptionsCache + .slice() + .sort((a, b) => String(a.displayName || a.name).localeCompare(String(b.displayName || b.name))) + .forEach((option) => { + const node = document.createElement("option"); + node.value = option.name; + node.textContent = option.displayName || option.name; + node.dataset.dynamicFramework = "true"; + if (customOption) { + select.insertBefore(node, customOption); + } else { + select.appendChild(node); + } + }); + if (previousValue) { + select.value = previousValue; + } + }; + + const loadFrameworkOptions = async () => { + try { + const list = await bridge.listFrameworks(); + frameworkOptionsCache = Array.isArray(list) ? list : []; + populateFrameworkSelect(); + } catch (err) { + console.error("Failed to load framework list:", err); + } + }; + const resetForm = () => { hasAttemptedSubmit = false; if (refs.projectPath) refs.projectPath.value = ""; @@ -932,6 +1016,7 @@ export const createOnboardingController = ({ handleProgress, handleInputChange: () => syncPreview(), resetForm, + loadFrameworkOptions, }; }; @@ -1117,13 +1202,6 @@ export const renderOnboardingModal = (container) => { class="w-full bg-surface-secondary dark:bg-black/40 border border-border-primary dark:border-white/10 rounded-2xl px-5 py-4 text-text-primary dark:text-white font-bold focus:ring-4 focus:ring-primary/15 transition-all text-sm outline-none appearance-none cursor-pointer" > - - - - - - - expand_more diff --git a/desktop/frontend/services/bridge.js b/desktop/frontend/services/bridge.js index 25dc7215..25675b83 100644 --- a/desktop/frontend/services/bridge.js +++ b/desktop/frontend/services/bridge.js @@ -90,6 +90,10 @@ export const desktopBridge = { const bridge = getBridge(); return call(bridge?.PickProjectDirectory?.bind(bridge)); }, + async listFrameworks() { + const bridge = getBridge(); + return call(bridge?.ListFrameworks?.bind(bridge)); + }, async onboardProject( inputOrPath, framework, diff --git a/docs/developer/adding-a-framework.md b/docs/developer/adding-a-framework.md index 41769196..e274da16 100644 --- a/docs/developer/adding-a-framework.md +++ b/docs/developer/adding-a-framework.md @@ -5,7 +5,7 @@ description: How Govard's framework registry is structured internally, and a con # Adding a New Framework -Govard ships support for a growing list of frameworks — Magento 2, Mage-OS, Magento 1, OpenMage, Laravel, Symfony, Drupal, WordPress, Next.js, Emdash, Shopware, CakePHP, PrestaShop, Django, and more over time. This page documents how that support is structured internally, and what to touch to add a new one. +Govard ships support for a growing list of frameworks — Magento 2, Mage-OS, Magento 1, OpenMage, Laravel, Symfony, Drupal, WordPress, Next.js, Emdash, Shopware, CakePHP, PrestaShop, Django, Custom, and more over time. This page documents how that support is structured internally, and what to touch to add a new one. --- @@ -24,15 +24,45 @@ type FrameworkDefinition struct { Manifest engine.FrameworkManifestConfig // sync excludes, sensitive tables, feature flags Detect engine.DetectionSpec // composer/package.json/auth.json/file-path signatures + DefaultDBCredentials DefaultDBCredentials // local-dev DB port/username/password/database-name defaults + PHPStanPaths []string // default `govard test phpstan` analysis paths, nil for the generic {"app","src"} default + ComposerCodingStandard ComposerCodingStandard // Composer package + phpcs --standard label for this framework's coding standard + Bootstrap BootstrapFactory // func(bootstrap.Options) bootstrap.FrameworkBootstrap BaseURLManager func() tunnel.BaseURLManager // nil if the framework needs no tunnel base-URL rewriting SupportsBootstrap bool // allow `govard bootstrap` (remote/clone workflow) SupportsFreshInstall bool // allow `govard bootstrap --fresh` + + FreshInstall func(bootstrap.Options, string, bootstrap.CmdHelpers) error // fresh-install orchestration; nil until migrated off the legacy switch + FreshInstallNeedsDB bool // populate DB credentials before invoking FreshInstall + FreshInstallNeedsDomain bool // populate the domain before invoking FreshInstall + FreshInstallManagesOwnEnvUp bool // true if FreshInstall already calls `env up` itself + + PreConfigureHook func(bootstrap.Options, string, bootstrap.CmdHelpers) error // clone-workflow setup that must run before `govard config auto` + PostCloneHook func(bootstrap.Options, string, bootstrap.CmdHelpers) error // clone-workflow setup that runs after the generic PostClone dispatch + + PHPImageVariant string // PHP image variant suffix for this framework's container, "" for the plain image + + DBDriverCategory string // phpMyAdmin per-project DB-user/label category, "" falls back to "app" + + Upgrade engine.UpgradeFunc // `govard upgrade` pipeline (deps, migrations, cache flush), nil if unimplemented + + RunMappingAssetPreparer engine.RunMappingAssetPreparer // per-store nginx/apache "run mapping" assets prepared before render, nil if none + + TablePrefixDetector engine.TablePrefixDetector // reads this framework's own config file for its DB table prefix, nil if no table-prefix concept + + VersionProfileResolver engine.VersionProfileResolver // resolves version-specific runtime-profile overrides, nil for all but magento2 today + + TemplateFuncs template.FuncMap // extra blueprint-template functions this framework contributes, nil for most + + ProbeRemoteDB func(remoteName string, remoteCfg engine.RemoteConfig) (remote.MagentoDBInfo, error) // probes a remote for live DB credentials, nil if unimplemented + + AutoConfigure func(cmd *cobra.Command, config engine.Config) error // `govard config auto`'s framework-specific post-render setup, nil if unsupported } ``` -`internal/frameworks/all_generated.go` — generated by `go generate ./internal/frameworks/...` from each package's `Definition()` (see step 6, below) — calls `Register(.Definition())` for each registered framework package, in a specific order (more on why, below), populating a package-level registry that the rest of Govard reads through three small, focused files: +`internal/frameworks/all_generated.go` — generated by `go generate ./internal/frameworks/...` from each package's `Spec()` (see step 6, below) — calls `RegisterSpecs([]types.FrameworkSpec{.Spec(), ...})` once with every registered framework package's spec, in a specific order (more on why, below). `RegisterSpecs` resolves each spec into a full `types.FrameworkDefinition` (a root spec's `Definition` is used as-is; a child spec's `Parent` definition is resolved first, then the child's `types.FrameworkPatch` is applied on top — see "Forking an existing framework" below) and populates a package-level registry. Adding a framework to the registry means its resolved `Definition()` fields automatically flow through every place that dispatches on framework identity — but that's now three different mechanisms, not one: | File | Purpose | | :--- | :--- | @@ -40,7 +70,11 @@ type FrameworkDefinition struct { | `internal/frameworks/run.go` | `RunBootstrap(name, opts)` — dispatches to `def.Bootstrap` instead of a switch | | `internal/frameworks/base_url.go` | `NewBaseURLManager(name)` — dispatches to `def.BaseURLManager`, falls back to `tunnel.NoopManager` | -Everything that reads framework data by name — `govard bootstrap`'s allowlists, `govard tunnel`'s base-URL rewriting, the bootstrap dispatcher — goes through one of those three files instead of a hardcoded `switch framework { case "magento2": ... }`. Adding a framework to the registry means it automatically participates in all three, no switch to edit. +- **The three files above** are the registry's own read side, used by `govard bootstrap`'s allowlists, `govard tunnel`'s base-URL rewriting, and the bootstrap dispatcher. +- **Top-down field reads**: code in `internal/cmd`/`internal/desktop` that already imports `internal/frameworks` calls `frameworks.Get(name)` and reads a `Definition()` field directly — `DefaultDBCredentials`, `PHPStanPaths`, `ComposerCodingStandard`, `ProbeRemoteDB`, `AutoConfigure`, `FreshInstall` (and its `FreshInstallNeedsDB`/`FreshInstallNeedsDomain`/`FreshInstallManagesOwnEnvUp` companions), `PreConfigureHook`/`PostCloneHook`. This is the default choice for anything only `cmd`/`desktop` needs. +- **Engine-owned registries**: `internal/engine` can never import `internal/frameworks` back (`frameworks` imports `engine`, not the reverse), so the handful of things engine itself needs to dispatch on — `PHPImageVariant`, `DBDriverCategory`, `Upgrade`, `RunMappingAssetPreparer`, `TablePrefixDetector`, `VersionProfileResolver`, and each entry of `TemplateFuncs` — are instead pushed into a matching `engine.RegisterX(...)` call from `frameworks.Register` (`internal/frameworks/registry.go`) at registration time, e.g. `engine.RegisterPHPImageVariant`, `engine.RegisterUpgrader`. Engine's own read-side functions (`PHPImageVariantForFramework`, `UpgradeFramework`, etc.) then dispatch off that registry instead of a per-framework switch. + +Whichever path a given field takes, no hardcoded `switch framework { case "magento2": ... }` remains for it — adding a framework to the registry means it automatically participates everywhere that field is read, no switch to edit. ### Fresh-install and clone-workflow dispatch @@ -100,11 +134,51 @@ Copy the closest existing framework's shape — e.g. `internal/frameworks/cakeph Same rule as `config.go` above: `magento2.Manifest` (`internal/frameworks/magento2/manifest.go`, referenced by `internal/frameworks/mageos/manifest.go`) and `magento1.Manifest` (`internal/frameworks/magento1/manifest.go`, referenced by `internal/frameworks/openmage/manifest.go`). -### 3. Compose blueprint — `internal/blueprints/files/whimsy/` +### 3. Blueprint assets — `internal/frameworks/whimsy/blueprint/` + `embed.go` + +Framework blueprint assets — the Compose fragment, the nginx vhost template, anything else the framework's blueprint needs — live inside the framework's own package now, not under a shared `internal/blueprints/files//` directory (that tree still exists, but only for assets genuinely shared across frameworks: `proxy.yml`, `includes/`, and the generic `support/nginx/templates` default). Two pieces: + +1. **`internal/frameworks/whimsy/blueprint/`** — the actual asset files: + - `services.yml` (Docker Compose fragment, rendered as a Go template) if the framework needs one — copy the closest analog (`internal/frameworks/nextjs/blueprint/services.yml` for a Node runtime, `internal/frameworks/cakephp/blueprint/` for a PHP framework that needs only an nginx template, no compose fragment of its own). Not every framework needs one: a framework that reuses another's compose entirely (Mage-OS reuses Magento 2's — see `varnishTemplateFramework` in `internal/engine/render.go`) skips this file, as does one contributing only an nginx template (cakephp, drupal, wordpress today). + - an nginx vhost template (e.g. `whimsy.conf`) if the framework needs one distinct from the generic default. + - any other nested assets the blueprint needs — see `internal/frameworks/magento2/blueprint/varnish/default.vcl` for an example beyond `services.yml`/the nginx template. + +2. **`internal/frameworks/whimsy/embed.go`** — embeds that directory and grafts it into the merged `blueprints.FS` tree at package-init time. Copy `internal/frameworks/magento2/embed.go` (has both a nested asset and an nginx template) or the simpler `internal/frameworks/cakephp/embed.go` (nginx template only) as your starting shape: + + ```go + package whimsy + + import ( + "embed" + "io/fs" + + "govard/internal/blueprints" + ) + + //go:embed all:blueprint + var blueprintFiles embed.FS + + var BlueprintFS fs.FS -A `services.yml` (Docker Compose fragment) rendered as a Go template — copy the closest analog (`internal/blueprints/files/nextjs/services.yml` for a Node runtime, `internal/blueprints/files/cakephp/` for PHP). Not every framework needs its own directory: Mage-OS reuses Magento 2's compose/nginx/Varnish blueprint outright (see `varnishTemplateFramework` in `internal/engine/render.go`) since it's a drop-in fork with the same runtime shape. + func init() { + var err error + BlueprintFS, err = fs.Sub(blueprintFiles, "blueprint") + if err != nil { + panic(err) + } -If your service needs to run as `user: root` (e.g. a stock Node/Python image where `npm`/`pip` need root to install), isolate any directory it writes into that's a build cache or dependency tree — not source you want to inspect on the host — behind a named Docker volume instead of the bind mount, so root-owned files never land on the host filesystem at all. See `node-modules:` in `internal/blueprints/files/emdash/services.yml` and `next-cache:` in `internal/blueprints/files/nextjs/services.yml`. For writes that can't be isolated this way (e.g. Django's `__pycache__`, scattered throughout the project tree), chown the directory back to the bind mount's own owner (`stat -c %u:%g .` — no UID plumbing needed) after the command that wrote as root; see `internal/blueprints/files/django/services.yml`'s `command:` and `internal/frameworks/django/bootstrap.go`'s `installAndMigrate`. + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "whimsy", + FS: BlueprintFS, + HasDir: true, // false if whimsy contributes only an nginx template, no services.yml/other assets + NginxTemplate: "whimsy.conf", // "" if whimsy has no nginx template of its own + }) + } + ``` + + `HasDir: true` grafts the whole of `BlueprintFS` as `whimsy/` in the merged tree (e.g. `whimsy/services.yml`); `NginxTemplate`, if set, is always grafted at `support/nginx/templates/whimsy.conf` regardless of `HasDir`. See the `FrameworkMount` doc comment in `internal/blueprints/blueprints.go` for the full contract. No registration call is needed beyond this `init()` — as long as something imports `internal/frameworks/whimsy` (step 5's `all_generated.go` does), Go runs this `init()` before `blueprints.FS` is ever read. + +If your service needs to run as `user: root` (e.g. a stock Node/Python image where `npm`/`pip` need root to install), isolate any directory it writes into that's a build cache or dependency tree — not source you want to inspect on the host — behind a named Docker volume instead of the bind mount, so root-owned files never land on the host filesystem at all. See `node-modules:` in `internal/frameworks/emdash/blueprint/services.yml` and `next-cache:` in `internal/frameworks/nextjs/blueprint/services.yml`. For writes that can't be isolated this way (e.g. Django's `__pycache__`, scattered throughout the project tree), chown the directory back to the bind mount's own owner (`stat -c %u:%g .` — no UID plumbing needed) after the command that wrote as root; see `internal/frameworks/django/blueprint/services.yml`'s `command:` and `internal/frameworks/django/bootstrap.go`'s `installAndMigrate`. ### 4. Bootstrap implementation — `internal/frameworks/whimsy/bootstrap.go` @@ -158,9 +232,61 @@ func Definition() types.FrameworkDefinition { `config` and `manifest` are the package-level vars from steps 1 and 2 — no lookup by name needed, since `Definition()` lives in the same package that declares them. `NewWhimsyBootstrap` is the local constructor from step 4's `bootstrap.go` (no `bootstrap.` prefix — the bootstrapper lives in `whimsy`'s own package, not `internal/engine/bootstrap`). Only set `BaseURLManager` if the framework needs specialized base-URL rewriting for `govard tunnel` (most don't — the default `tunnel.NoopManager` is a no-op, which is correct for anything that doesn't store its own base URL in the database or a config file). +Every framework package also needs a `Spec()` function — this, not `Definition()`, is what `all_generated.go` actually calls. For a brand-new, standalone framework like `whimsy`, it's a one-liner that just wraps `Definition()`: + +```go +// Spec declares Whimsy as a root framework. +func Spec() types.FrameworkSpec { return types.FrameworkSpec{Definition: Definition()} } +``` + +Copy this verbatim from any non-fork framework, e.g. `internal/frameworks/wordpress/spec.go` or `internal/frameworks/custom/spec.go`. If `whimsy` is instead a close fork of an existing framework, see "Forking an existing framework" below before writing `Spec()` — a fork's `Spec()` looks quite different from this one-liner. + +### 5b. Forking an existing framework — `Parent` and `FrameworkPatch` + +If `whimsy` is a near-identical fork of an already-registered framework (the way Mage-OS forks Magento 2, or OpenMage forks Magento 1), don't duplicate that framework's whole `Definition()`. Instead, declare `whimsy` as a **child spec**: give it a `Parent` and a `types.FrameworkPatch` listing only the fields that actually differ from the parent. Every other field is inherited automatically when `RegisterSpecs` resolves the registry at startup. + +`types.FrameworkPatch` has one `types.Override[T]` field per inheritable `FrameworkDefinition` field (see `internal/frameworks/types/spec.go` for the full list). An `Override[T]`'s zero value means "inherit the parent's value unchanged" — to actually change something you must call one of: + +- `types.Set(value)` — replace the inherited value with `value`. +- `types.Clear[T]()` — explicitly reset to `T`'s zero value (distinct from "inherit," since a fork's correct value genuinely _is_ the zero value for some fields, e.g. an `Upgrade` pipeline the parent has but the fork deliberately doesn't). + +Real example — `internal/frameworks/mageos/mageos.go`'s `Spec()` (Mage-OS inherits most of Magento 2's behavior, but patches its own display name, DB defaults, detection signature, and a handful of Magento-2-specific fields it doesn't share): + +```go +// Spec declares Mage-OS as a Magento 2 child. Every inherited behavior is +// intentionally omitted; only distribution-specific deltas remain here. +func Spec() types.FrameworkSpec { + def := Definition() + return types.FrameworkSpec{ + Parent: "magento2", + Definition: types.FrameworkDefinition{ + Name: def.Name, + Aliases: def.Aliases, + }, + Patch: types.FrameworkPatch{ + DisplayName: types.Set(def.DisplayName), + MigrationTypes: types.Clear[types.MigrationTypes](), + Config: types.Set(def.Config), + DefaultDBCredentials: types.Set(def.DefaultDBCredentials), + Detect: types.Set(def.Detect), + Bootstrap: types.Set(def.Bootstrap), + FreshInstall: types.Set(def.FreshInstall), + DBDriverCategory: types.Clear[string](), + Upgrade: types.Set(def.Upgrade), + VersionProfileResolver: types.Clear[engine.VersionProfileResolver](), + // ...and so on for every other field that differs from magento2. + }, + } +} +``` + +Note that `def := Definition()` still exists and is still fully populated (other code, and this `Spec()` itself, read fields off it directly) — the fork pattern only changes what `Spec()` reports to the registry, not `Definition()` itself. See `internal/frameworks/openmage/openmage.go` for a second real example (OpenMage forking Magento 1) with a different, smaller patch set — the two forks don't patch the same fields, because each fork's actual behavioral deltas from its parent are different. + +`Parent` must name an already-registered framework (checked at startup — `RegisterSpecs` panics on an unknown parent or an inheritance cycle, so a typo here fails loudly at process start, not silently at runtime). Only fork a framework this way if it's genuinely a close variant with a real, alphabetically-earlier parent to inherit from; most new frameworks are roots, not forks. + ### 6. Register it — nothing to edit -Registration is generated, not hand-maintained: run `make generate` (or `go generate ./internal/frameworks/...`) and `internal/frameworks/all_generated.go` picks up the new `whimsy` package automatically — no import or `Register()` call to add by hand. `make build` and `make test` already run this for you, so in practice you only need to run it explicitly if you want to inspect the generated file before building. +Registration is generated, not hand-maintained: run `make generate` (or `go generate ./internal/frameworks/...`) and `internal/frameworks/all_generated.go` picks up the new `whimsy` package's `Spec()` automatically — no import or `RegisterSpecs()` call to add by hand. `make build` and `make test` already run this for you, so in practice you only need to run it explicitly if you want to inspect the generated file before building. **Position still matters for detection**: `DetectFramework` walks registered frameworks in registration order and returns the first match, so a framework whose detection signature could also match another registered framework's must register in the right relative position. Ordering defaults to alphabetical; the one known exception (Emdash must register before Next.js) is declared in `internal/frameworks/gen/generator/order.go`'s `PriorityOverrides` map — add an entry there, not in `all_generated.go`, if `whimsy`'s detection signature is similarly ambiguous with an existing framework's. @@ -223,8 +349,8 @@ Mage-OS is a drop-in fork of Magento 2 and reuses most of its runtime behavior, A service that runs as `user: root` (stock Node/Python images need this for `npm`/`pip` to install without permission errors) writes any file it creates in the bind-mounted project directory as root — the host user can't delete or edit it without `sudo`. There's no single fix; pick per directory: -- If the directory is a rebuildable cache or dependency tree, not something a developer needs to browse on the host, isolate it behind a named Docker volume instead of the bind mount (`node-modules:` in `internal/blueprints/files/emdash/services.yml`, `next-cache:` in `internal/blueprints/files/nextjs/services.yml`) — root-owned files never touch the host filesystem at all. -- If root-owned files can appear anywhere in the tree (Django's `__pycache__`), chown the project directory back to the bind mount's own owner after the command that ran as root: `chown -R "$(stat -c %u:%g .)" .` — this reads the mount point's *existing* ownership rather than requiring the host UID/GID to be threaded through as a new parameter. See `internal/frameworks/django/bootstrap.go`'s `installAndMigrate` and `internal/blueprints/files/django/services.yml`'s `command:`. +- If the directory is a rebuildable cache or dependency tree, not something a developer needs to browse on the host, isolate it behind a named Docker volume instead of the bind mount (`node-modules:` in `internal/frameworks/emdash/blueprint/services.yml`, `next-cache:` in `internal/frameworks/nextjs/blueprint/services.yml`) — root-owned files never touch the host filesystem at all. +- If root-owned files can appear anywhere in the tree (Django's `__pycache__`), chown the project directory back to the bind mount's own owner after the command that ran as root: `chown -R "$(stat -c %u:%g .)" .` — this reads the mount point's *existing* ownership rather than requiring the host UID/GID to be threaded through as a new parameter. See `internal/frameworks/django/bootstrap.go`'s `installAndMigrate` and `internal/frameworks/django/blueprint/services.yml`'s `command:`. --- diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md index 1ea3d639..dbedf1a5 100644 --- a/docs/developer/architecture.md +++ b/docs/developer/architecture.md @@ -96,7 +96,7 @@ See [Configuration](/reference/configuration) for the complete contract. ## Framework Support -Each of Govard's 13 supported frameworks is registered in `internal/frameworks//` as a `types.FrameworkDefinition` — one struct carrying the framework's detection signature, runtime/manifest data, and dispatch hooks (bootstrap factory, tunnel base-URL rewriter, `govard bootstrap` support flags). `internal/frameworks/all_generated.go`'s `init()` registers all 13 into a package-level registry (`internal/frameworks/registry.go`) that `internal/frameworks/run.go` and `internal/frameworks/base_url.go` dispatch through, instead of hardcoded `switch framework { ... }` statements scattered across the codebase. +Each of Govard's supported frameworks is registered in `internal/frameworks//` as a `types.FrameworkDefinition` — one struct carrying the framework's detection signature, runtime/manifest data, and dispatch hooks (bootstrap factory, tunnel base-URL rewriter, `govard bootstrap` support flags). Every framework package also exposes a `Spec()` function: root frameworks wrap their `Definition()` directly, while close forks (Mage-OS off Magento 2, OpenMage off Magento 1) declare a `Parent` and a `types.FrameworkPatch` of only the fields that differ, inheriting everything else. `internal/frameworks/all_generated.go`'s `init()` calls `RegisterSpecs` with every package's `Spec()`, which resolves the parent/child graph (detecting cycles and unknown parents) into a package-level registry (`internal/frameworks/registry.go`) that `internal/frameworks/run.go` and `internal/frameworks/base_url.go` dispatch through, instead of hardcoded `switch framework { ... }` statements scattered across the codebase. Discovery (`engine.DetectFramework`) inspects project manifests — composer.json requires, package.json deps, auth.json hosts, file-path signatures — and maps to framework defaults for web root, PHP/Node versions, database engine, and optional cache/search/queue/Varnish services, sourced from `engine.GetFrameworkConfig`/`engine.GetFrameworkManifestConfig` (still the authoritative data, composed into each `FrameworkDefinition`). diff --git a/docs/getting-started/getting-started.md b/docs/getting-started/getting-started.md index 583207ca..ae0d99a5 100644 --- a/docs/getting-started/getting-started.md +++ b/docs/getting-started/getting-started.md @@ -25,6 +25,7 @@ Govard inspects `composer.json` or `package.json`, detects the framework, and wr | Framework | Detection | | :--- | :--- | | Magento 2 | `composer.json` with `magento/magento2-base` | +| Mage-OS | `composer.json` with `mage-os/product-community-edition` or `mage-os/project-community-edition` | | Magento 1 / OpenMage | `composer.json` patterns | | Laravel | `artisan` file + `composer.json` | | Next.js | `package.json` with `next` dependency | @@ -33,6 +34,8 @@ Govard inspects `composer.json` or `package.json`, detects the framework, and wr | Symfony | `symfony/framework-bundle` | | Shopware | `shopware/core` | | CakePHP | `cakephp/cakephp` | +| PrestaShop | `config/defines.inc.php` | +| Django | `manage.py` | | WordPress | `wp-config.php` or `wp-login.php` | | Custom | Interactive stack picker (`govard init --framework custom`) | diff --git a/docs/reference/cli-commands.md b/docs/reference/cli-commands.md index c7642b8c..e2257b8d 100644 --- a/docs/reference/cli-commands.md +++ b/docs/reference/cli-commands.md @@ -102,7 +102,7 @@ govard bootstrap -e staging --no-pii --no-noise | `--no-stream-db` | Use local temp file for DB transfer | | `--no-up` | Skip starting local containers before bootstrap steps | -For Magento projects with `table_prefix` set, DB privacy filters target prefixed table names automatically. +For Magento 2/Mage-OS, Magento 1/OpenMage, or PrestaShop projects with `table_prefix` set, DB privacy filters target prefixed table names automatically. **Magento special flags:** diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 22f38ed8..55d00803 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -130,7 +130,7 @@ linked_projects: | `domain` | Primary project domain (e.g. `myproject.test`) | | `extra_domains` | Additional hostnames routed through the local proxy | | `store_domains` | Magento multi-store hostname → scope code map | -| `table_prefix` | Magento 2, Magento 1, or OpenMage database table prefix; omit or leave empty for unprefixed schemas | +| `table_prefix` | Magento 2, Mage-OS, Magento 1, OpenMage, or PrestaShop database table prefix; omit or leave empty for unprefixed schemas | | `linked_projects` | List of dependencies (project names or IP:domain) for cross-project connectivity | ::: important IMPORTANT @@ -167,7 +167,7 @@ Use `table_prefix` when the Magento database tables are prefixed, for example `d table_prefix: "demo_" ``` -Govard uses this value for Magento 2 `env.php`, Magento 1/OpenMage `local.xml`, `config auto` SQL, DB sync privacy filters, and Warden migration. The value must contain only letters, numbers, and underscores. +Govard uses this value for Magento 2/Mage-OS `env.php`, Magento 1/OpenMage `local.xml`, PrestaShop `parameters.php`, `config auto` SQL, DB sync privacy filters, and Warden migration. The value must contain only letters, numbers, and underscores. --- diff --git a/docs/vi/developer/adding-a-framework.md b/docs/vi/developer/adding-a-framework.md index cf37bc73..39c7e6ae 100644 --- a/docs/vi/developer/adding-a-framework.md +++ b/docs/vi/developer/adding-a-framework.md @@ -5,7 +5,7 @@ description: Cấu trúc nội bộ của framework registry trong Govard, và h # Thêm Framework mới -Govard hỗ trợ một danh sách framework ngày càng mở rộng — Magento 2, Mage-OS, Magento 1, OpenMage, Laravel, Symfony, Drupal, WordPress, Next.js, Emdash, Shopware, CakePHP, PrestaShop, Django, và sẽ còn thêm nữa theo thời gian. Trang này mô tả cấu trúc nội bộ của phần hỗ trợ đó, và những gì cần đụng vào để thêm một framework mới. +Govard hỗ trợ một danh sách framework ngày càng mở rộng — Magento 2, Mage-OS, Magento 1, OpenMage, Laravel, Symfony, Drupal, WordPress, Next.js, Emdash, Shopware, CakePHP, PrestaShop, Django, Custom, và sẽ còn thêm nữa theo thời gian. Trang này mô tả cấu trúc nội bộ của phần hỗ trợ đó, và những gì cần đụng vào để thêm một framework mới. --- @@ -24,15 +24,45 @@ type FrameworkDefinition struct { Manifest engine.FrameworkManifestConfig // exclude khi sync, bảng nhạy cảm, feature flags Detect engine.DetectionSpec // chữ ký nhận diện composer/package.json/auth.json/đường dẫn file + DefaultDBCredentials DefaultDBCredentials // port/username/password/database-name mặc định cho local dev + PHPStanPaths []string // đường dẫn phân tích `govard test phpstan` mặc định, nil dùng mặc định chung {"app","src"} + ComposerCodingStandard ComposerCodingStandard // package Composer + label --standard phpcs của coding standard framework này + Bootstrap BootstrapFactory // func(bootstrap.Options) bootstrap.FrameworkBootstrap BaseURLManager func() tunnel.BaseURLManager // nil nếu framework không cần rewrite base-URL cho tunnel SupportsBootstrap bool // cho phép `govard bootstrap` (quy trình remote/clone) SupportsFreshInstall bool // cho phép `govard bootstrap --fresh` + + FreshInstall func(bootstrap.Options, string, bootstrap.CmdHelpers) error // orchestration fresh-install; nil nếu chưa migrate khỏi switch cũ + FreshInstallNeedsDB bool // populate DB credentials trước khi gọi FreshInstall + FreshInstallNeedsDomain bool // populate domain trước khi gọi FreshInstall + FreshInstallManagesOwnEnvUp bool // true nếu FreshInstall đã tự gọi `env up` + + PreConfigureHook func(bootstrap.Options, string, bootstrap.CmdHelpers) error // setup của clone workflow phải chạy trước `govard config auto` + PostCloneHook func(bootstrap.Options, string, bootstrap.CmdHelpers) error // setup của clone workflow chạy sau dispatch PostClone chung + + PHPImageVariant string // hậu tố variant image PHP cho container của framework này, "" dùng image thường + + DBDriverCategory string // category DB-user/label per-project cho phpMyAdmin, "" fallback về "app" + + Upgrade engine.UpgradeFunc // pipeline `govard upgrade` (deps, migration, flush cache), nil nếu chưa triển khai + + RunMappingAssetPreparer engine.RunMappingAssetPreparer // chuẩn bị asset "run mapping" nginx/apache theo store trước khi render, nil nếu không có + + TablePrefixDetector engine.TablePrefixDetector // đọc file config riêng của framework để lấy table prefix DB, nil nếu không có khái niệm table prefix + + VersionProfileResolver engine.VersionProfileResolver // resolve override runtime-profile theo phiên bản, nil cho mọi framework trừ magento2 hiện tại + + TemplateFuncs template.FuncMap // các hàm template blueprint bổ sung framework này góp vào, nil với hầu hết + + ProbeRemoteDB func(remoteName string, remoteCfg engine.RemoteConfig) (remote.MagentoDBInfo, error) // probe một remote để lấy DB credentials thực, nil nếu chưa triển khai + + AutoConfigure func(cmd *cobra.Command, config engine.Config) error // setup sau khi render riêng của framework cho `govard config auto`, nil nếu chưa hỗ trợ } ``` -`internal/frameworks/all_generated.go` — được sinh bởi `go generate ./internal/frameworks/...` từ `Definition()` của mỗi package (xem bước 6 dưới đây) — gọi `Register(.Definition())` cho từng framework đã đăng ký, theo một thứ tự cụ thể (lý do ở phần dưới), tạo nên một registry cấp package mà phần còn lại của Govard đọc qua 3 file nhỏ, tập trung: +`internal/frameworks/all_generated.go` — được sinh bởi `go generate ./internal/frameworks/...` từ `Spec()` của mỗi package (xem bước 6 dưới đây) — gọi `RegisterSpecs([]types.FrameworkSpec{.Spec(), ...})` một lần với spec của mọi framework đã đăng ký, theo một thứ tự cụ thể (lý do ở phần dưới). `RegisterSpecs` resolve từng spec thành một `types.FrameworkDefinition` đầy đủ (`Definition` của một spec gốc được dùng nguyên vẹn; `Definition` của parent thuộc một spec con được resolve trước, sau đó `types.FrameworkPatch` của spec con được áp lên trên — xem "Fork một framework có sẵn" dưới đây) và tạo nên một registry cấp package. Thêm framework vào registry nghĩa là các field trong `Definition()` đã resolve của nó tự động chảy qua mọi nơi dispatch theo danh tính framework — nhưng giờ có 3 cơ chế khác nhau, không chỉ 1: | File | Vai trò | | :--- | :--- | @@ -40,7 +70,11 @@ type FrameworkDefinition struct { | `internal/frameworks/run.go` | `RunBootstrap(name, opts)` — dispatch tới `def.Bootstrap` thay vì switch | | `internal/frameworks/base_url.go` | `NewBaseURLManager(name)` — dispatch tới `def.BaseURLManager`, fallback về `tunnel.NoopManager` | -Mọi nơi đọc dữ liệu framework theo tên — allowlist của `govard bootstrap`, base-URL rewriting của `govard tunnel`, bootstrap dispatcher — đều đi qua 1 trong 3 file này thay vì `switch framework { case "magento2": ... }` hardcode rải rác. Thêm framework vào registry nghĩa là nó tự động tham gia cả 3 nơi đó, không cần sửa switch nào. +- **3 file trên** là phía đọc của chính registry, dùng bởi allowlist của `govard bootstrap`, base-URL rewriting của `govard tunnel`, và bootstrap dispatcher. +- **Đọc field top-down**: code trong `internal/cmd`/`internal/desktop` (đã import sẵn `internal/frameworks`) gọi `frameworks.Get(name)` rồi đọc trực tiếp một field của `Definition()` — `DefaultDBCredentials`, `PHPStanPaths`, `ComposerCodingStandard`, `ProbeRemoteDB`, `AutoConfigure`, `FreshInstall` (cùng các field đi kèm `FreshInstallNeedsDB`/`FreshInstallNeedsDomain`/`FreshInstallManagesOwnEnvUp`), `PreConfigureHook`/`PostCloneHook`. Đây là lựa chọn mặc định cho những gì chỉ `cmd`/`desktop` cần. +- **Registry do engine sở hữu**: `internal/engine` không thể import ngược `internal/frameworks` (`frameworks` import `engine`, không phải ngược lại), nên một số thứ engine tự cần dispatch — `PHPImageVariant`, `DBDriverCategory`, `Upgrade`, `RunMappingAssetPreparer`, `TablePrefixDetector`, `VersionProfileResolver`, và từng entry của `TemplateFuncs` — được đẩy vào một lệnh gọi `engine.RegisterX(...)` tương ứng từ `frameworks.Register` (`internal/frameworks/registry.go`) lúc registration, vd `engine.RegisterPHPImageVariant`, `engine.RegisterUpgrader`. Các hàm phía đọc của engine (`PHPImageVariantForFramework`, `UpgradeFramework`, v.v.) sau đó dispatch dựa trên registry đó thay vì switch theo từng framework. + +Bất kể field nào đi theo đường nào, không còn `switch framework { case "magento2": ... }` hardcode cho nó — thêm framework vào registry nghĩa là nó tự động tham gia ở mọi nơi field đó được đọc, không cần sửa switch nào. ### Dispatch cho fresh-install và clone workflow @@ -100,11 +134,51 @@ Copy theo framework gần giống nhất — vd `internal/frameworks/cakephp/man Quy tắc tương tự `config.go` ở trên: `magento2.Manifest` (`internal/frameworks/magento2/manifest.go`, được `internal/frameworks/mageos/manifest.go` tham chiếu) và `magento1.Manifest` (`internal/frameworks/magento1/manifest.go`, được `internal/frameworks/openmage/manifest.go` tham chiếu). -### 3. Blueprint compose — `internal/blueprints/files/whimsy/` +### 3. Tài sản blueprint — `internal/frameworks/whimsy/blueprint/` + `embed.go` + +Tài sản blueprint của framework — đoạn Compose, template vhost nginx, và bất kỳ thứ gì khác blueprint cần — giờ nằm ngay trong package riêng của framework, không còn nằm dưới thư mục dùng chung `internal/blueprints/files//` nữa (cây thư mục đó vẫn tồn tại, nhưng chỉ chứa tài sản thực sự dùng chung giữa các framework: `proxy.yml`, `includes/`, và template mặc định chung trong `support/nginx/templates`). Gồm hai phần: + +1. **`internal/frameworks/whimsy/blueprint/`** — các file tài sản thực tế: + - `services.yml` (đoạn Docker Compose, được render qua Go template) nếu framework cần — copy theo ví dụ gần nhất (`internal/frameworks/nextjs/blueprint/services.yml` cho runtime Node, `internal/frameworks/cakephp/blueprint/` cho framework PHP chỉ cần template nginx, không cần đoạn compose riêng). Không phải framework nào cũng cần file này: framework tái dùng hẳn compose của framework khác (Mage-OS tái dùng của Magento 2 — xem `varnishTemplateFramework` trong `internal/engine/render.go`) bỏ qua file này, cũng như framework chỉ đóng góp template nginx (cakephp, drupal, wordpress hiện tại). + - một template vhost nginx (vd `whimsy.conf`) nếu framework cần một template khác với mặc định chung. + - bất kỳ tài sản lồng nhau nào khác mà blueprint cần — xem `internal/frameworks/magento2/blueprint/varnish/default.vcl` như một ví dụ ngoài `services.yml`/template nginx. + +2. **`internal/frameworks/whimsy/embed.go`** — embed thư mục đó và ghép nó vào cây `blueprints.FS` hợp nhất tại thời điểm package init. Copy theo `internal/frameworks/magento2/embed.go` (có cả tài sản lồng nhau lẫn template nginx) hoặc bản đơn giản hơn `internal/frameworks/cakephp/embed.go` (chỉ có template nginx) làm khuôn mẫu ban đầu: + + ```go + package whimsy + + import ( + "embed" + "io/fs" + + "govard/internal/blueprints" + ) + + //go:embed all:blueprint + var blueprintFiles embed.FS + + var BlueprintFS fs.FS -Một file `services.yml` (đoạn Docker Compose) được render qua Go template — copy theo ví dụ gần nhất (`internal/blueprints/files/nextjs/services.yml` cho runtime Node, `internal/blueprints/files/cakephp/` cho PHP). Không phải framework nào cũng cần thư mục riêng: Mage-OS tái dùng thẳng blueprint compose/nginx/Varnish của Magento 2 (xem `varnishTemplateFramework` trong `internal/engine/render.go`) vì nó là bản fork drop-in với cùng hình dạng runtime. + func init() { + var err error + BlueprintFS, err = fs.Sub(blueprintFiles, "blueprint") + if err != nil { + panic(err) + } -Nếu service của bạn cần chạy `user: root` (vd image Node/Python gốc cần root để `npm`/`pip` install không lỗi quyền), hãy cách ly thư mục nào nó ghi vào mà là cache build hay cây dependency — không phải source bạn cần xem trên host — bằng named Docker volume thay vì bind mount, để file root-owned không bao giờ chạm tới host filesystem. Xem `node-modules:` trong `internal/blueprints/files/emdash/services.yml` và `next-cache:` trong `internal/blueprints/files/nextjs/services.yml`. Với những chỗ ghi không cách ly được theo cách đó (vd `__pycache__` của Django, rải rác khắp cây thư mục dự án), chown lại thư mục về đúng owner của bind mount (`stat -c %u:%g .` — không cần truyền UID qua đâu cả) sau lệnh đã chạy as root; xem `installAndMigrate` trong `internal/frameworks/django/bootstrap.go` và `command:` trong `internal/blueprints/files/django/services.yml`. + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "whimsy", + FS: BlueprintFS, + HasDir: true, // false nếu whimsy chỉ đóng góp template nginx, không có services.yml/tài sản khác + NginxTemplate: "whimsy.conf", // "" nếu whimsy không có template nginx riêng + }) + } + ``` + + `HasDir: true` ghép toàn bộ `BlueprintFS` thành `whimsy/` trong cây hợp nhất (vd `whimsy/services.yml`); `NginxTemplate`, nếu được đặt, luôn được ghép tại `support/nginx/templates/whimsy.conf` bất kể `HasDir`. Xem chú thích doc của `FrameworkMount` trong `internal/blueprints/blueprints.go` để biết đầy đủ hợp đồng (contract). Không cần thêm lệnh đăng ký nào ngoài `init()` này — miễn là có thứ gì đó import `internal/frameworks/whimsy` (bước 5's `all_generated.go` đã làm việc này), Go sẽ chạy `init()` này trước khi `blueprints.FS` được đọc lần nào. + +Nếu service của bạn cần chạy `user: root` (vd image Node/Python gốc cần root để `npm`/`pip` install không lỗi quyền), hãy cách ly thư mục nào nó ghi vào mà là cache build hay cây dependency — không phải source bạn cần xem trên host — bằng named Docker volume thay vì bind mount, để file root-owned không bao giờ chạm tới host filesystem. Xem `node-modules:` trong `internal/frameworks/emdash/blueprint/services.yml` và `next-cache:` trong `internal/frameworks/nextjs/blueprint/services.yml`. Với những chỗ ghi không cách ly được theo cách đó (vd `__pycache__` của Django, rải rác khắp cây thư mục dự án), chown lại thư mục về đúng owner của bind mount (`stat -c %u:%g .` — không cần truyền UID qua đâu cả) sau lệnh đã chạy as root; xem `installAndMigrate` trong `internal/frameworks/django/bootstrap.go` và `command:` trong `internal/frameworks/django/blueprint/services.yml`. ### 4. Triển khai Bootstrap — `internal/frameworks/whimsy/bootstrap.go` @@ -158,9 +232,61 @@ func Definition() types.FrameworkDefinition { `config` và `manifest` chính là 2 biến cấp package từ bước 1 và 2 — không cần lookup theo tên, vì `Definition()` nằm cùng package với nơi khai báo chúng. `NewWhimsyBootstrap` là constructor cục bộ từ `bootstrap.go` ở bước 4 (không có tiền tố `bootstrap.` — bộ bootstrapper nằm ngay trong package `whimsy`, không phải `internal/engine/bootstrap`). Chỉ đặt `BaseURLManager` nếu framework cần rewrite base-URL riêng cho `govard tunnel` (đa số không cần — `tunnel.NoopManager` mặc định là no-op, đúng cho bất kỳ framework nào không tự lưu base URL trong database hay file config). +Mỗi package framework cũng cần một hàm `Spec()` — đây, không phải `Definition()`, mới là thứ `all_generated.go` thực sự gọi. Với một framework hoàn toàn mới, độc lập như `whimsy`, đây chỉ là một dòng bọc trực tiếp `Definition()`: + +```go +// Spec declares Whimsy as a root framework. +func Spec() types.FrameworkSpec { return types.FrameworkSpec{Definition: Definition()} } +``` + +Copy nguyên văn từ bất kỳ framework không-fork nào, vd `internal/frameworks/wordpress/spec.go` hoặc `internal/frameworks/custom/spec.go`. Nếu `whimsy` lại là bản fork gần của một framework có sẵn, xem mục "Fork một framework có sẵn" dưới đây trước khi viết `Spec()` — `Spec()` của một bản fork trông khác hẳn dòng bọc đơn giản này. + +### 5b. Fork một framework có sẵn — `Parent` và `FrameworkPatch` + +Nếu `whimsy` là bản fork gần như giống hệt một framework đã đăng ký (như cách Mage-OS fork từ Magento 2, hay OpenMage fork từ Magento 1), đừng copy lại nguyên `Definition()` của framework đó. Thay vào đó, khai báo `whimsy` như một **spec con**: cho nó một `Parent` và một `types.FrameworkPatch` chỉ liệt kê những field thực sự khác với parent. Mọi field khác được kế thừa tự động khi `RegisterSpecs` resolve registry lúc khởi động. + +`types.FrameworkPatch` có một field `types.Override[T]` cho mỗi field có thể kế thừa của `FrameworkDefinition` (xem `internal/frameworks/types/spec.go` để biết danh sách đầy đủ). Giá trị zero của `Override[T]` nghĩa là "kế thừa nguyên giá trị của parent" — để thực sự thay đổi gì đó, bạn phải gọi một trong hai: + +- `types.Set(value)` — thay giá trị kế thừa bằng `value`. +- `types.Clear[T]()` — reset tường minh về giá trị zero của `T` (khác với "kế thừa", vì với một số field, giá trị đúng của bản fork thực sự *là* giá trị zero, vd một pipeline `Upgrade` mà parent có nhưng bản fork chủ ý không có). + +Ví dụ thật — `Spec()` của `internal/frameworks/mageos/mageos.go` (Mage-OS kế thừa hầu hết hành vi của Magento 2, nhưng patch riêng display name, DB mặc định, chữ ký nhận diện, và một số field đặc thù Magento 2 mà nó không dùng chung): + +```go +// Spec declares Mage-OS as a Magento 2 child. Every inherited behavior is +// intentionally omitted; only distribution-specific deltas remain here. +func Spec() types.FrameworkSpec { + def := Definition() + return types.FrameworkSpec{ + Parent: "magento2", + Definition: types.FrameworkDefinition{ + Name: def.Name, + Aliases: def.Aliases, + }, + Patch: types.FrameworkPatch{ + DisplayName: types.Set(def.DisplayName), + MigrationTypes: types.Clear[types.MigrationTypes](), + Config: types.Set(def.Config), + DefaultDBCredentials: types.Set(def.DefaultDBCredentials), + Detect: types.Set(def.Detect), + Bootstrap: types.Set(def.Bootstrap), + FreshInstall: types.Set(def.FreshInstall), + DBDriverCategory: types.Clear[string](), + Upgrade: types.Set(def.Upgrade), + VersionProfileResolver: types.Clear[engine.VersionProfileResolver](), + // ...and so on for every other field that differs from magento2. + }, + } +} +``` + +Lưu ý `def := Definition()` vẫn tồn tại và vẫn được điền đầy đủ (code khác, và chính `Spec()` này, đọc field trực tiếp từ nó) — pattern fork chỉ thay đổi những gì `Spec()` báo cho registry, không thay đổi `Definition()` bản thân nó. Xem `internal/frameworks/openmage/openmage.go` để có ví dụ thật thứ hai (OpenMage fork từ Magento 1) với một bộ patch khác, nhỏ hơn — hai bản fork không patch cùng field, vì mỗi bản fork có delta hành vi thực tế khác nhau so với parent của nó. + +`Parent` phải trỏ tới một framework đã đăng ký (được kiểm tra lúc khởi động — `RegisterSpecs` panic nếu gặp parent không tồn tại hoặc chu trình kế thừa, nên lỗi gõ nhầm ở đây fail ngay lúc process khởi động, không âm thầm ở runtime). Chỉ fork một framework theo cách này nếu nó thực sự là biến thể gần với một parent có thật, đứng trước nó theo alphabet, để kế thừa; đa số framework mới là framework gốc, không phải fork. + ### 6. Đăng ký — không cần sửa gì -Việc đăng ký được sinh tự động, không còn duy trì thủ công: chạy `make generate` (hoặc `go generate ./internal/frameworks/...`) và `internal/frameworks/all_generated.go` sẽ tự nhận package `whimsy` mới — không cần thêm import hay dòng `Register()` nào bằng tay. `make build` và `make test` đã tự chạy bước này cho bạn, nên trên thực tế bạn chỉ cần chạy tay khi muốn xem trước file sinh ra trước khi build. +Việc đăng ký được sinh tự động, không còn duy trì thủ công: chạy `make generate` (hoặc `go generate ./internal/frameworks/...`) và `internal/frameworks/all_generated.go` sẽ tự nhận `Spec()` của package `whimsy` mới — không cần thêm import hay dòng `RegisterSpecs()` nào bằng tay. `make build` và `make test` đã tự chạy bước này cho bạn, nên trên thực tế bạn chỉ cần chạy tay khi muốn xem trước file sinh ra trước khi build. **Vị trí vẫn có ý nghĩa cho detection**: `DetectFramework` duyệt qua các framework theo đúng thứ tự đăng ký và trả về kết quả khớp đầu tiên, nên nếu chữ ký nhận diện của framework mới có thể trùng với framework khác đã đăng ký, nó cần được đăng ký ở đúng vị trí tương đối. Thứ tự mặc định là theo alphabet; trường hợp ngoại lệ duy nhất đã biết (Emdash phải đăng ký trước Next.js) được khai báo trong map `PriorityOverrides` của `internal/frameworks/gen/generator/order.go` — thêm một entry ở đó, không phải trong `all_generated.go`, nếu chữ ký nhận diện của `whimsy` cũng mơ hồ tương tự với một framework đã có. @@ -223,8 +349,8 @@ Mage-OS là bản fork drop-in của Magento 2 và tái dùng phần lớn hành Một service chạy `user: root` (image Node/Python gốc cần vậy để `npm`/`pip` install không lỗi quyền) sẽ ghi bất kỳ file nào nó tạo trong thư mục dự án bind-mount dưới quyền root — user trên host không xóa/sửa được nếu không có `sudo`. Không có 1 fix chung cho mọi trường hợp; chọn theo từng thư mục: -- Nếu thư mục đó là cache có thể build lại hoặc cây dependency, không phải thứ dev cần xem trên host, hãy cách ly nó bằng named Docker volume thay vì bind mount (`node-modules:` trong `internal/blueprints/files/emdash/services.yml`, `next-cache:` trong `internal/blueprints/files/nextjs/services.yml`) — file root-owned không bao giờ chạm tới host filesystem. -- Nếu file root-owned có thể xuất hiện ở bất kỳ đâu trong cây thư mục (`__pycache__` của Django), chown lại thư mục dự án về đúng owner của bind mount sau lệnh đã chạy as root: `chown -R "$(stat -c %u:%g .)" .` — lệnh này đọc owner *hiện tại* của mount point thay vì phải truyền UID/GID host qua như một tham số mới. Xem `installAndMigrate` trong `internal/frameworks/django/bootstrap.go` và `command:` trong `internal/blueprints/files/django/services.yml`. +- Nếu thư mục đó là cache có thể build lại hoặc cây dependency, không phải thứ dev cần xem trên host, hãy cách ly nó bằng named Docker volume thay vì bind mount (`node-modules:` trong `internal/frameworks/emdash/blueprint/services.yml`, `next-cache:` trong `internal/frameworks/nextjs/blueprint/services.yml`) — file root-owned không bao giờ chạm tới host filesystem. +- Nếu file root-owned có thể xuất hiện ở bất kỳ đâu trong cây thư mục (`__pycache__` của Django), chown lại thư mục dự án về đúng owner của bind mount sau lệnh đã chạy as root: `chown -R "$(stat -c %u:%g .)" .` — lệnh này đọc owner *hiện tại* của mount point thay vì phải truyền UID/GID host qua như một tham số mới. Xem `installAndMigrate` trong `internal/frameworks/django/bootstrap.go` và `command:` trong `internal/frameworks/django/blueprint/services.yml`. --- diff --git a/docs/vi/developer/architecture.md b/docs/vi/developer/architecture.md index 9706ed81..9ee21da4 100644 --- a/docs/vi/developer/architecture.md +++ b/docs/vi/developer/architecture.md @@ -96,7 +96,7 @@ Xem thêm tài liệu [Cấu hình](/vi/reference/configuration) để biết ch ## Hỗ trợ Framework -Mỗi framework trong số 13 framework Govard hỗ trợ được đăng ký tại `internal/frameworks//` dưới dạng một `types.FrameworkDefinition` — một struct duy nhất mang chữ ký nhận diện, dữ liệu runtime/manifest, và các hook dispatch (bootstrap factory, base-URL rewriter cho tunnel, cờ hỗ trợ `govard bootstrap`) của framework đó. `init()` của `internal/frameworks/all_generated.go` đăng ký cả 13 framework vào một registry cấp package (`internal/frameworks/registry.go`), và `internal/frameworks/run.go`/`internal/frameworks/base_url.go` dispatch thông qua registry này thay vì rải rác các `switch framework { ... }` khắp codebase. +Mỗi framework Govard hỗ trợ được đăng ký tại `internal/frameworks//` dưới dạng một `types.FrameworkDefinition` — một struct duy nhất mang chữ ký nhận diện, dữ liệu runtime/manifest, và các hook dispatch (bootstrap factory, base-URL rewriter cho tunnel, cờ hỗ trợ `govard bootstrap`) của framework đó. Mỗi package framework cũng có một hàm `Spec()`: framework gốc bọc trực tiếp `Definition()` của nó, còn các bản fork gần (Mage-OS fork từ Magento 2, OpenMage fork từ Magento 1) khai báo một `Parent` và một `types.FrameworkPatch` chỉ gồm những field thực sự khác biệt, còn mọi field khác được kế thừa. `init()` của `internal/frameworks/all_generated.go` gọi `RegisterSpecs` với `Spec()` của mọi package, hàm này resolve cây quan hệ parent/child (phát hiện chu trình và parent không tồn tại) thành một registry cấp package (`internal/frameworks/registry.go`), và `internal/frameworks/run.go`/`internal/frameworks/base_url.go` dispatch thông qua registry này thay vì rải rác các `switch framework { ... }` khắp codebase. Bộ nhận diện (`engine.DetectFramework`) quét file manifest của dự án — composer.json requires, package.json deps, auth.json hosts, chữ ký đường dẫn file — và ánh xạ về cấu hình mặc định của framework (web root, phiên bản PHP/Node, database engine, các dịch vụ cache/search/queue/Varnish tùy chọn), lấy từ `engine.GetFrameworkConfig`/`engine.GetFrameworkManifestConfig` (vẫn là nguồn dữ liệu gốc, được compose vào từng `FrameworkDefinition`). diff --git a/docs/vi/getting-started/getting-started.md b/docs/vi/getting-started/getting-started.md index 11746de5..4a5963fe 100644 --- a/docs/vi/getting-started/getting-started.md +++ b/docs/vi/getting-started/getting-started.md @@ -25,6 +25,7 @@ Govard sẽ kiểm tra file `composer.json` hoặc `package.json`, nhận diện | Framework | Cách nhận diện | | :--- | :--- | | Magento 2 | `composer.json` có `magento/magento2-base` | +| Mage-OS | `composer.json` có `mage-os/product-community-edition` hoặc `mage-os/project-community-edition` | | Magento 1 / OpenMage | Pattern cấu trúc file trong `composer.json` | | Laravel | Có file `artisan` + `composer.json` | | Next.js | `package.json` có dependency `next` | @@ -33,6 +34,8 @@ Govard sẽ kiểm tra file `composer.json` hoặc `package.json`, nhận diện | Symfony | Có `symfony/framework-bundle` | | Shopware | Có `shopware/core` | | CakePHP | Có `cakephp/cakephp` | +| PrestaShop | `config/defines.inc.php` | +| Django | `manage.py` | | WordPress | Có `wp-config.php` hoặc `wp-login.php` | | Custom (Tùy chỉnh) | Chọn stack qua prompt tương tác (`govard init --framework custom`) | diff --git a/docs/vi/reference/cli-commands.md b/docs/vi/reference/cli-commands.md index 306eaf74..bb9201a3 100644 --- a/docs/vi/reference/cli-commands.md +++ b/docs/vi/reference/cli-commands.md @@ -102,7 +102,7 @@ govard bootstrap -e staging --no-pii --no-noise | `--no-stream-db` | Sử dụng một file tạm local để truyền DB thay vì stream trực tiếp | | `--no-up` | Bỏ qua bước khởi động container local trước khi chạy bootstrap | -Đối với các dự án Magento có thiết lập `table_prefix`, các bộ lọc bảo mật DB sẽ tự động áp dụng chính xác cho các bảng có tiền tố tương ứng. +Đối với các dự án Magento 2/Mage-OS, Magento 1/OpenMage hoặc PrestaShop có thiết lập `table_prefix`, các bộ lọc bảo mật DB sẽ tự động áp dụng chính xác cho các bảng có tiền tố tương ứng. **Các cờ đặc thù của Magento:** diff --git a/docs/vi/reference/configuration.md b/docs/vi/reference/configuration.md index 77e94316..53a94aa8 100644 --- a/docs/vi/reference/configuration.md +++ b/docs/vi/reference/configuration.md @@ -130,7 +130,7 @@ linked_projects: | `domain` | Domain chính của dự án (ví dụ: `myproject.test`) | | `extra_domains` | Các hostname bổ sung được định tuyến qua local proxy | | `store_domains` | Magento multi-store hostname → ánh xạ mã scope | -| `table_prefix` | Tiền tố bảng database cho Magento 2, Magento 1 hoặc OpenMage; bỏ qua hoặc để trống nếu không dùng | +| `table_prefix` | Tiền tố bảng database cho Magento 2, Mage-OS, Magento 1, OpenMage hoặc PrestaShop; bỏ qua hoặc để trống nếu không dùng | | `linked_projects` | Danh sách các dependency (tên dự án hoặc IP:domain) để kết nối liên dự án | ::: important QUAN TRỌNG @@ -167,7 +167,7 @@ Sử dụng `table_prefix` khi các bảng cơ sở dữ liệu Magento có ti table_prefix: "demo_" ``` -Govard sử dụng giá trị này cho Magento 2 `env.php`, Magento 1/OpenMage `local.xml`, SQL của lệnh `config auto`, các bộ lọc dữ liệu khi sync DB và quá trình migrate từ Warden. Giá trị này chỉ được chứa chữ cái, chữ số và dấu gạch dưới. +Govard sử dụng giá trị này cho Magento 2/Mage-OS `env.php`, Magento 1/OpenMage `local.xml`, PrestaShop `parameters.php`, SQL của lệnh `config auto`, các bộ lọc dữ liệu khi sync DB và quá trình migrate từ Warden. Giá trị này chỉ được chứa chữ cái, chữ số và dấu gạch dưới. --- diff --git a/internal/blueprints/blueprints.go b/internal/blueprints/blueprints.go index 0b72d701..d0e04ef4 100644 --- a/internal/blueprints/blueprints.go +++ b/internal/blueprints/blueprints.go @@ -1,20 +1,547 @@ +// Package blueprints exposes the merged blueprints filesystem: the +// remainder of internal/blueprints/files/** that was not relocated, unioned +// with each framework package's own embedded blueprint sub-filesystem +// (registered via RegisterFrameworkMount from that package's init()). package blueprints import ( "embed" + "errors" + "io" "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + "time" ) +// files is the remainder of internal/blueprints/files/** - everything that +// was NOT relocated next to a framework package (shared includes/, proxy.yml, +// the generic support/ tree minus the 9 relocated nginx templates). +// //go:embed all:files var files embed.FS -// FS is the embedded blueprints filesystem -var FS fs.FS +// fallback is files, rooted at "files" so paths match the historical +// contract (e.g. "proxy.yml", "includes/base.yml", +// "support/nginx/templates/default.conf"). +var fallback fs.FS func init() { var err error - FS, err = fs.Sub(files, "files") + fallback, err = fs.Sub(files, "files") if err != nil { panic(err) } } + +// FrameworkMount describes how one framework package's embedded blueprint +// assets are grafted into the merged blueprints tree. +type FrameworkMount struct { + // Framework is the canonical framework name (e.g. "magento2"). Also + // used as the directory-mount prefix when HasDir is true. + Framework string + // FS is the framework package's embedded blueprint sub-filesystem, + // already fs.Sub'd so paths are relative to the framework's own + // blueprint root (e.g. "services.yml", "varnish/default.vcl", + // "magento2.conf" - not "blueprint/services.yml"). + FS fs.FS + // HasDir grafts the whole of FS as a directory at "/" in the + // merged tree (e.g. "magento2/services.yml", "magento2/varnish/default.vcl"). + // Frameworks that only contribute an nginx template and have no other + // assets (cakephp, drupal, wordpress today) leave this false. + HasDir bool + // NginxTemplate is the file name within FS holding the framework's + // nginx vhost template (e.g. "magento2.conf"), or "" if the framework + // has none. When set it is grafted as a single file at + // "support/nginx/templates/". + NginxTemplate string +} + +// mounts accumulates every framework's RegisterFrameworkMount call. It is +// read directly by Open/ReadDir/Stat (never snapshotted), so registrations +// performed by framework package init() functions - which run after this +// package's own init(), since Go initializes imported packages before their +// importers - are always visible by the time any blueprint is rendered. +var mounts []FrameworkMount + +// RegisterFrameworkMount is called from a framework package's init() to +// graft its embedded blueprint sub-filesystem into the merged blueprints.FS +// tree. Not safe for concurrent use; intended usage is exclusively from +// package init() functions, mirroring frameworks.Registry.Register. +func RegisterFrameworkMount(m FrameworkMount) { + mounts = append(mounts, m) +} + +// ResetMountsForTest clears the package-level mounts slice and returns a +// restore callback, since mounts is process-global and otherwise +// accumulates real framework registrations across tests (from every +// framework package's init()) as well as leaking between test functions +// within the same test binary. Callers should invoke the returned func via +// defer or t.Cleanup to restore the prior state. +func ResetMountsForTest() func() { + previous := mounts + mounts = nil + return func() { + mounts = previous + } +} + +// dirMountForPath returns the mount that owns name because name is either +// exactly the mount's directory root or a path beneath it, plus name's +// path relative to that mount's FS root. +// +// A mount's own NginxTemplate file is deliberately excluded here even +// though it physically lives in the same embedded blueprint/ directory as +// the mount's other assets: that file is grafted ONLY at +// "support/nginx/templates/" (via fileMount), matching the +// pre-migration contract where e.g. "magento2/magento2.conf" never existed +// as a path. Without this exclusion the nginx template would appear twice +// in the merged tree - once via this dir mount, once via the file mount - +// which fs.WalkDir would then visit twice. +func dirMountForPath(name string) (FrameworkMount, string, bool) { + for _, m := range mounts { + if !m.HasDir { + continue + } + if name == m.Framework { + return m, ".", true + } + if rel, ok := strings.CutPrefix(name, m.Framework+"/"); ok { + if m.NginxTemplate != "" && rel == m.NginxTemplate { + continue + } + return m, rel, true + } + } + return FrameworkMount{}, "", false +} + +// fileMount returns the mount that grafts a single nginx template file at +// exactly name ("support/nginx/templates/"), if any. +func fileMount(name string) (FrameworkMount, bool) { + const nginxDir = "support/nginx/templates/" + if !strings.HasPrefix(name, nginxDir) { + return FrameworkMount{}, false + } + base := strings.TrimPrefix(name, nginxDir) + if strings.Contains(base, "/") { + return FrameworkMount{}, false + } + for _, m := range mounts { + if m.NginxTemplate != "" && m.NginxTemplate == base { + return m, true + } + } + return FrameworkMount{}, false +} + +// fileMountChildrenOf returns the synthetic file-mount DirEntry values that +// belong directly under dir (e.g. dir="support/nginx/templates" yields one +// entry per registered NginxTemplate). +func fileMountChildrenOf(dir string) ([]fs.DirEntry, error) { + const nginxDir = "support/nginx/templates" + if dir != nginxDir { + return nil, nil + } + var out []fs.DirEntry + for _, m := range mounts { + if m.NginxTemplate == "" { + continue + } + info, err := fs.Stat(m.FS, m.NginxTemplate) + if err != nil { + return nil, err + } + out = append(out, fs.FileInfoToDirEntry(info)) + } + return out, nil +} + +// dirMountChildrenOf returns the synthetic directory-mount DirEntry values +// that belong directly under dir (dir="." yields "django", "magento2", ...). +func dirMountChildrenOf(dir string) []fs.DirEntry { + if dir != "." { + return nil + } + var out []fs.DirEntry + for _, m := range mounts { + if !m.HasDir { + continue + } + out = append(out, mountDirEntry{name: m.Framework}) + } + return out +} + +// mountDirEntry is a synthetic fs.DirEntry representing a framework's +// directory mount (e.g. "magento2") that has no backing entry in fallback. +type mountDirEntry struct{ name string } + +func (e mountDirEntry) Name() string { return e.name } +func (e mountDirEntry) IsDir() bool { return true } +func (e mountDirEntry) Type() fs.FileMode { return fs.ModeDir } +func (e mountDirEntry) Info() (fs.FileInfo, error) { return mountDirInfo(e), nil } + +type mountDirInfo struct{ name string } + +func (i mountDirInfo) Name() string { return i.name } +func (i mountDirInfo) Size() int64 { return 0 } +func (i mountDirInfo) Mode() fs.FileMode { return fs.ModeDir | 0o555 } +func (i mountDirInfo) ModTime() time.Time { return time.Time{} } +func (i mountDirInfo) IsDir() bool { return true } +func (i mountDirInfo) Sys() any { return nil } + +// unionFS merges fallback with every registered framework mount. It +// implements fs.FS, fs.ReadDirFS and fs.StatFS so fs.WalkDir enumerates the +// merged tree without ever needing to Open() a directory itself. +type unionFS struct{} + +// FS is the merged blueprints filesystem: fallback (internal/blueprints/files +// minus relocated assets) unioned with every framework's registered mount. +var FS fs.FS = unionFS{} + +// WithSourceOverrides layers blueprint files from a source checkout over base. +// The shared assets live under internal/blueprints/files while framework-owned +// assets live beside their owning framework package. Missing checkout files +// always fall back to base, which keeps partial checkouts and installed builds +// usable. +func WithSourceOverrides(base fs.FS, checkoutRoot string) fs.FS { + return sourceOverlayFS{base: base, checkoutRoot: filepath.Clean(checkoutRoot)} +} + +type sourceOverlayFS struct { + base fs.FS + checkoutRoot string +} + +func (s sourceOverlayFS) Open(name string) (fs.File, error) { + if !fs.ValidPath(name) { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid} + } + + baseInfo, baseErr := fs.Stat(s.base, name) + overridePath, hasOverride := s.pathFor(name) + overrideInfo, overrideErr := s.statOverride(overridePath, hasOverride) + if overrideErr != nil { + return nil, overrideErr + } + + if overrideInfo != nil && !overrideInfo.IsDir() { + return os.Open(overridePath) + } + if (overrideInfo != nil && overrideInfo.IsDir()) || (baseErr == nil && baseInfo.IsDir()) { + entries, err := s.ReadDir(name) + if err != nil { + return nil, err + } + info := baseInfo + if baseErr != nil { + info = overrideInfo + } + return &mergedDirFile{info: info, entries: entries}, nil + } + if baseErr != nil { + return nil, baseErr + } + return s.base.Open(name) +} + +func (s sourceOverlayFS) ReadDir(name string) ([]fs.DirEntry, error) { + if !fs.ValidPath(name) { + return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrInvalid} + } + + entriesByName := map[string]fs.DirEntry{} + baseEntries, baseErr := fs.ReadDir(s.base, name) + if baseErr != nil && !errors.Is(baseErr, fs.ErrNotExist) { + return nil, baseErr + } + for _, entry := range baseEntries { + entriesByName[entry.Name()] = entry + } + + overridePath, hasOverride := s.pathFor(name) + if hasOverride { + overrideEntries, err := os.ReadDir(overridePath) + if err == nil { + for _, entry := range overrideEntries { + if mount, relative, mounted := dirMountForPath(name); mounted && relative == "." && entry.Name() == mount.NginxTemplate { + continue + } + entriesByName[entry.Name()] = entry + } + } else if !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + } + + // Framework nginx templates are mounted virtually at this shared path, + // while their disk overrides live beside their framework package. + if name == "support/nginx/templates" { + for _, mount := range mounts { + if mount.NginxTemplate == "" { + continue + } + overrideTemplate := filepath.Join(s.checkoutRoot, "internal", "frameworks", mount.Framework, "blueprint", mount.NginxTemplate) + if info, err := os.Stat(overrideTemplate); err == nil && !info.IsDir() { + entriesByName[mount.NginxTemplate] = fs.FileInfoToDirEntry(info) + } + } + } + + if len(entriesByName) == 0 && baseErr != nil { + return nil, baseErr + } + entries := make([]fs.DirEntry, 0, len(entriesByName)) + for _, entry := range entriesByName { + entries = append(entries, entry) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + return entries, nil +} + +func (s sourceOverlayFS) Stat(name string) (fs.FileInfo, error) { + if !fs.ValidPath(name) { + return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrInvalid} + } + overridePath, hasOverride := s.pathFor(name) + overrideInfo, err := s.statOverride(overridePath, hasOverride) + if err != nil { + return nil, err + } + if overrideInfo != nil && !overrideInfo.IsDir() { + return overrideInfo, nil + } + if info, baseErr := fs.Stat(s.base, name); baseErr == nil { + return info, nil + } else if overrideInfo != nil { + return overrideInfo, nil + } else { + return nil, baseErr + } +} + +func (s sourceOverlayFS) pathFor(name string) (string, bool) { + if mount, ok := fileMount(name); ok { + return filepath.Join(s.checkoutRoot, "internal", "frameworks", mount.Framework, "blueprint", mount.NginxTemplate), true + } + if mount, relative, ok := dirMountForPath(name); ok { + if relative == "." { + return filepath.Join(s.checkoutRoot, "internal", "frameworks", mount.Framework, "blueprint"), true + } + return filepath.Join(s.checkoutRoot, "internal", "frameworks", mount.Framework, "blueprint", filepath.FromSlash(relative)), true + } + return filepath.Join(s.checkoutRoot, "internal", "blueprints", "files", filepath.FromSlash(name)), true +} + +func (s sourceOverlayFS) statOverride(overridePath string, present bool) (fs.FileInfo, error) { + if !present { + return nil, nil + } + info, err := os.Stat(overridePath) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + return info, nil +} + +func (unionFS) Open(name string) (fs.File, error) { + // fs.FS.Open's contract (io/fs doc) requires rejecting invalid names + // via fs.ValidPath rather than silently normalizing them (e.g. + // "django/." or "support//nginx" must error, not be treated as + // "django" / "support/nginx"). + if !fs.ValidPath(name) { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid} + } + + if m, ok := fileMount(name); ok { + return m.FS.Open(m.NginxTemplate) + } + + // An exact dir-mount root (e.g. Open("magento2")) must NOT delegate + // straight to the framework's embedded sub-FS: that would (a) leak the + // NginxTemplate file, which belongs only under + // "support/nginx/templates/", and (b) report the wrong directory Name() + // (fs.Sub roots keep the original embed pattern's base name, e.g. + // "blueprint", not the virtual mount name "magento2"). Route it through + // the same merged-directory construction used for "." and + // "support/nginx/templates" below. + if _, rel, ok := dirMountForPath(name); ok && rel == "." { + return openMergedDir(name) + } + if m, rel, ok := dirMountForPath(name); ok { + return m.FS.Open(rel) + } + + // Directories that merge fallback entries with synthetic mount + // entries (root "." and "support/nginx/templates") need a synthetic + // directory file so a plain Open() + type-assert to fs.ReadDirFile + // still behaves, even though our own ReadDir method (below) is what + // fs.WalkDir actually calls. + fileEntries, err := fileMountChildrenOf(name) + if err != nil { + return nil, err + } + extra := append(dirMountChildrenOf(name), fileEntries...) + if len(extra) > 0 { + return openMergedDir(name) + } + + return fallback.Open(name) +} + +// openMergedDir builds the fs.File for a directory whose listing is +// synthesized rather than a direct passthrough to a single backing fs.FS: +// root ".", "support/nginx/templates", and every dir-mount's own root +// (e.g. "magento2", which must exclude its NginxTemplate entry and report +// its virtual name rather than the embedded FS's internal root name). +func openMergedDir(name string) (fs.File, error) { + entries, err := readDirMerged(name) + if err != nil { + return nil, err + } + info, err := Stat(name) + if err != nil { + return nil, err + } + return &mergedDirFile{info: info, entries: entries}, nil +} + +func (unionFS) ReadDir(name string) ([]fs.DirEntry, error) { + if !fs.ValidPath(name) { + return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrInvalid} + } + return readDirMerged(name) +} + +func readDirMerged(name string) ([]fs.DirEntry, error) { + // name is exactly a dir-mount root, or somewhere inside one (e.g. + // "magento2" or "magento2/varnish"): the whole listing comes from that + // framework's own FS - fallback has nothing at these paths (the + // directory was relocated out of internal/blueprints/files entirely), + // and no other framework's mount can nest inside it. + if m, rel, ok := dirMountForPath(name); ok { + entries, err := fs.ReadDir(m.FS, rel) + if err != nil { + return nil, err + } + if rel == "." && m.NginxTemplate != "" { + // Filter out the nginx template: it is grafted only at + // "support/nginx/templates/", not here (see + // dirMountForPath). + filtered := entries[:0:0] + for _, e := range entries { + if e.Name() != m.NginxTemplate { + filtered = append(filtered, e) + } + } + entries = filtered + } + return entries, nil + } + + var base []fs.DirEntry + if entries, err := fs.ReadDir(fallback, name); err == nil { + base = entries + } else if !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + + fileEntries, err := fileMountChildrenOf(name) + if err != nil { + return nil, err + } + extra := append(dirMountChildrenOf(name), fileEntries...) + if len(base) == 0 && len(extra) == 0 { + // Neither side has this directory: surface a real not-exist error + // (matches the fallback's own error rather than silently + // returning an empty, "successful" listing). + if _, err := fs.ReadDir(fallback, name); err != nil { + return nil, err + } + } + + merged := append(append([]fs.DirEntry{}, base...), extra...) + sort.Slice(merged, func(i, j int) bool { return merged[i].Name() < merged[j].Name() }) + return merged, nil +} + +// Stat returns file info for name in the merged blueprints tree. +func Stat(name string) (fs.FileInfo, error) { + if !fs.ValidPath(name) { + return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrInvalid} + } + if m, ok := fileMount(name); ok { + return fs.Stat(m.FS, m.NginxTemplate) + } + // An exact dir-mount root reports its virtual name (e.g. "magento2"), + // not the embedded sub-FS's own root name (e.g. "blueprint") - see + // openMergedDir's doc comment for why. + if m, rel, ok := dirMountForPath(name); ok { + if rel == "." { + return mountDirInfo{m.Framework}, nil + } + return fs.Stat(m.FS, rel) + } + if name == "." { + return fallbackRootInfo{}, nil + } + fileEntries, err := fileMountChildrenOf(name) + if err != nil { + return nil, err + } + extra := append(dirMountChildrenOf(name), fileEntries...) + if len(extra) > 0 { + return mountDirInfo{path.Base(name)}, nil + } + return fs.Stat(fallback, name) +} + +func (unionFS) Stat(name string) (fs.FileInfo, error) { return Stat(name) } + +type fallbackRootInfo struct{} + +func (fallbackRootInfo) Name() string { return "." } +func (fallbackRootInfo) Size() int64 { return 0 } +func (fallbackRootInfo) Mode() fs.FileMode { return fs.ModeDir | 0o555 } +func (fallbackRootInfo) ModTime() time.Time { return time.Time{} } +func (fallbackRootInfo) IsDir() bool { return true } +func (fallbackRootInfo) Sys() any { return nil } + +// mergedDirFile is the fs.File returned by Open() for a directory whose +// listing is a merge of fallback entries and synthetic mount entries (root +// "." and "support/nginx/templates"). It only needs to support Stat and +// ReadDir - callers that want file content always Open() a leaf path, which +// routes to a real backing fs.FS above and never reaches this type. +type mergedDirFile struct { + info fs.FileInfo + entries []fs.DirEntry + off int +} + +func (f *mergedDirFile) Stat() (fs.FileInfo, error) { return f.info, nil } +func (f *mergedDirFile) Read([]byte) (int, error) { return 0, fs.ErrInvalid } +func (f *mergedDirFile) Close() error { return nil } +func (f *mergedDirFile) ReadDir(n int) ([]fs.DirEntry, error) { + if n <= 0 { + rest := f.entries[f.off:] + f.off = len(f.entries) + return rest, nil + } + if f.off >= len(f.entries) { + return nil, io.EOF + } + end := f.off + n + if end > len(f.entries) { + end = len(f.entries) + } + out := f.entries[f.off:end] + f.off = end + return out, nil +} diff --git a/internal/cmd/bootstrap.go b/internal/cmd/bootstrap.go index 6c8d143a..b32a1279 100644 --- a/internal/cmd/bootstrap.go +++ b/internal/cmd/bootstrap.go @@ -422,8 +422,8 @@ func govardComposerSubcommandArgs(args ...string) []string { return commandArgs } -func govardMagentoSubcommandArgs(args ...string) []string { - commandArgs := []string{"tool", "magento"} +func govardToolSubcommandArgs(tool string, args ...string) []string { + commandArgs := []string{"tool", tool} commandArgs = append(commandArgs, args...) return commandArgs } @@ -500,13 +500,10 @@ func shouldIgnoreFrameworkPostCloneError(config engine.Config, err error, cwd st } errText := strings.ToLower(err.Error()) - // WordPress config might already exist or fail in non-critical ways - if config.Framework == "wordpress" { - return fileExists(filepath.Join(cwd, "wp-config.php")) - } - - if config.Framework == "prestashop" { - return fileExists(filepath.Join(cwd, "app", "config", "parameters.php")) + if definition, ok := frameworks.Get(config.Framework); ok && definition.IgnorePostCloneError != nil { + if definition.IgnorePostCloneError(err, cwd) { + return true + } } if !strings.Contains(errText, "composer install failed") { diff --git a/internal/cmd/bootstrap_composer.go b/internal/cmd/bootstrap_composer.go index 9ad9b3e1..0022bcc2 100644 --- a/internal/cmd/bootstrap_composer.go +++ b/internal/cmd/bootstrap_composer.go @@ -8,6 +8,7 @@ import ( "strings" "govard/internal/engine" + "govard/internal/frameworks" "github.com/pterm/pterm" "github.com/spf13/cobra" @@ -39,6 +40,8 @@ func FixComposerCompatibility(config engine.Config) error { } func ensureBootstrapAuthJSON(config engine.Config, opts BootstrapRuntimeOptions) error { + definition, _ := frameworks.Get(config.Framework) + auth := definition.ComposerAuth cwd, _ := os.Getwd() authPath := filepath.Join(cwd, "auth.json") if _, err := os.Stat(authPath); err == nil { @@ -64,18 +67,20 @@ func ensureBootstrapAuthJSON(config engine.Config, opts BootstrapRuntimeOptions) } if opts.MageUsername != "" && opts.MagePassword != "" { - return createAuthJSONFromCredentials(globalAuthPath, opts.MageUsername, opts.MagePassword, cwd) + return createAuthJSONFromCredentials(globalAuthPath, auth.Repository, opts.MageUsername, opts.MagePassword, cwd) } - if config.Framework == "magento2" && !shouldUseGlobalAuthByDefault() && !opts.AssumeYes { - pterm.Info.Println("Magento 2 requires authentication for repo.magento.com.") - pterm.Info.Println("You can find your keys at: https://marketplace.magento.com/customer/accessKeys/") + if auth.Repository != "" && !shouldUseGlobalAuthByDefault() && !opts.AssumeYes { + pterm.Info.Printf("%s requires authentication for %s.\n", auth.DisplayName, auth.Repository) + if auth.CredentialURL != "" { + pterm.Info.Printf("You can find your keys at: %s\n", auth.CredentialURL) + } username, _ := pterm.DefaultInteractiveTextInput.Show("Magento Public Key") password, _ := pterm.DefaultInteractiveTextInput.WithMask("*").Show("Magento Private Key") if username != "" && password != "" { - return createAuthJSONFromCredentials(globalAuthPath, username, password, cwd) + return createAuthJSONFromCredentials(globalAuthPath, auth.Repository, username, password, cwd) } } @@ -83,8 +88,12 @@ func ensureBootstrapAuthJSON(config engine.Config, opts BootstrapRuntimeOptions) return nil } -func createAuthJSONFromCredentials(path, username, password, cwd string) error { - payload := fmt.Sprintf("{\n \"http-basic\": {\n \"repo.magento.com\": {\n \"username\": %q,\n \"password\": %q\n }\n }\n}\n", username, password) +func createAuthJSONFromCredentials(path, repository, username, password, cwd string) error { + repository = strings.TrimSpace(repository) + if repository == "" { + return fmt.Errorf("framework does not declare a Composer authentication repository") + } + payload := fmt.Sprintf("{\n \"http-basic\": {\n %q: {\n \"username\": %q,\n \"password\": %q\n }\n }\n}\n", repository, username, password) if err := os.MkdirAll(filepath.Dir(path), conventions.SecretDirPerm); err != nil { return fmt.Errorf("failed to ensure directory for auth.json: %w", err) } diff --git a/internal/cmd/bootstrap_fresh_install.go b/internal/cmd/bootstrap_fresh_install.go index f019bef6..40723f04 100644 --- a/internal/cmd/bootstrap_fresh_install.go +++ b/internal/cmd/bootstrap_fresh_install.go @@ -20,14 +20,13 @@ import ( func runBootstrapFrameworkFreshInstall(cmd *cobra.Command, config engine.Config, opts BootstrapRuntimeOptions) error { cwd, _ := os.Getwd() - if config.Framework == "mageos" && opts.MetaPackage == defaultBootstrapMetaPackage { - opts.MetaPackage = "mage-os/project-community-edition" - } - def, ok := frameworks.Get(config.Framework) if !ok || def.FreshInstall == nil { return fmt.Errorf("fresh install not supported for framework: %s", config.Framework) } + if opts.MetaPackage == defaultBootstrapMetaPackage && def.DefaultFreshMetaPackage != "" { + opts.MetaPackage = def.DefaultFreshMetaPackage + } return runBootstrapRegistryFreshInstall(cmd, config, opts, def, cwd) } @@ -85,14 +84,18 @@ func runBootstrapRegistryFreshInstall(cmd *cobra.Command, config engine.Config, RunHyvaInstall: func() error { return runBootstrapHyvaInstall(cmd, opts) }, - ResolveMagentoTablePrefix: func() (string, error) { - return resolveBootstrapMagentoTablePrefix(config) + ResolveFrameworkTablePrefix: func() (string, error) { + if def.ResolveBootstrapTablePrefix == nil { + return "", fmt.Errorf("fresh install table prefix is not supported for framework: %s", config.Framework) + } + return def.ResolveBootstrapTablePrefix(config.TablePrefix) }, - RunMagentoSetupInstall: func(args []string) error { - return runBootstrapMagentoSetupInstall(cmd, config, args) + RunTool: func(tool string, args []string) error { + return runGovardSubcommand(cmd, govardToolSubcommandArgs(tool, args...)...) }, - RunMagentoSampleData: func() error { - return runBootstrapSampleData(cmd) + RunEnvironmentCommand: func(args []string) error { return runGovardSubcommand(cmd, append([]string{"env"}, args...)...) }, + IsPHPContainerRunning: func() bool { + return engine.IsContainerRunning(context.Background(), fmt.Sprintf("%s%s", config.ProjectName, conventions.PHPSuffix)) }, } @@ -107,32 +110,6 @@ func runBootstrapRegistryFreshInstall(cmd *cobra.Command, config engine.Config, return nil } -// runBootstrapMagentoSetupInstall runs `bin/magento setup:install` with the -// given args, first applying a best-effort Elasticsearch/OpenSearch -// read-only-allow-delete unblock if the PHP container is already running. -// Moved from the tail of runBootstrapPostInstall (internal/cmd/ -// bootstrap_post_install.go) - the "build the args" half of that function -// is now magento2.BuildSetupInstallArgs -// (internal/frameworks/magento2/bootstrap.go), called by the Magento -// family's shared freshInstall before this function ever runs. -func runBootstrapMagentoSetupInstall(cmd *cobra.Command, config engine.Config, args []string) error { - containerName := fmt.Sprintf("%s%s", config.ProjectName, conventions.PHPSuffix) - if engine.IsContainerRunning(context.Background(), containerName) { - esFixCmd := []string{ - "exec", "-T", "php", "sh", "-c", - "curl -s -X PUT 'http://elasticsearch:9200/_all/_settings' -H 'Content-Type: application/json' -d'{\"index.blocks.read_only_allow_delete\": null}' > /dev/null 2>&1 || true", - } - if err := runGovardSubcommand(cmd, append([]string{"env"}, esFixCmd...)...); err != nil { - pterm.Warning.Printf("Failed to apply Elasticsearch block fix: %v\n", err) - } - } - - if err := runGovardSubcommand(cmd, govardMagentoSubcommandArgs(args...)...); err != nil { - return fmt.Errorf("magento setup:install failed: %w", err) - } - return nil -} - // RunBootstrapFrameworkFreshInstallForTest exposes runBootstrapFrameworkFreshInstall for tests in /tests. func RunBootstrapFrameworkFreshInstallForTest(cmd *cobra.Command, config engine.Config, source, metaVersion string) error { return runBootstrapFrameworkFreshInstall(cmd, config, BootstrapRuntimeOptions{ diff --git a/internal/cmd/bootstrap_magento.go b/internal/cmd/bootstrap_magento.go index 29b6c2c1..a68f8f0e 100644 --- a/internal/cmd/bootstrap_magento.go +++ b/internal/cmd/bootstrap_magento.go @@ -10,15 +10,20 @@ import ( "strings" "govard/internal/engine" - "govard/internal/engine/remote" + "govard/internal/frameworks" + "govard/internal/frameworks/types" "github.com/pterm/pterm" "github.com/spf13/cobra" ) -func ensureBootstrapMagentoEnvPHP(config engine.Config, opts BootstrapRuntimeOptions) error { +func ensureBootstrapFrameworkEnvironment(config engine.Config, opts BootstrapRuntimeOptions) error { + definition, ok := frameworks.Get(config.Framework) + if !ok || definition.BootstrapEnvironmentPath == "" || definition.RenderBootstrapEnvironment == nil { + return fmt.Errorf("framework %q does not provide a bootstrap environment renderer", config.Framework) + } cwd, _ := os.Getwd() - envPath := filepath.Join(cwd, "app", "etc", "env.php") + envPath := filepath.Join(cwd, definition.BootstrapEnvironmentPath) if info, err := os.Lstat(envPath); err == nil && (info.Mode()&os.ModeSymlink) != 0 { if _, err := os.Stat(envPath); err != nil { @@ -44,93 +49,44 @@ func ensureBootstrapMagentoEnvPHP(config engine.Config, opts BootstrapRuntimeOpt tablePrefix := engine.NormalizeTablePrefix(config.TablePrefix) if remoteCfg, ok := config.Remotes[opts.Source]; ok { - if metadata, err := remote.ProbeMagento2Environment(opts.Source, remoteCfg); err == nil { - if strings.TrimSpace(metadata.CryptKey) != "" { - cryptKey = strings.TrimSpace(metadata.CryptKey) - } - if tablePrefix == "" { - tablePrefix = metadata.DB.TablePrefix + if definition.ProbeRemoteBootstrapMetadata != nil { + metadata, err := definition.ProbeRemoteBootstrapMetadata(opts.Source, remoteCfg) + if err == nil { + if remoteKey := strings.TrimSpace(metadata.Private[definition.BootstrapEnvironmentMetadataKey]); remoteKey != "" { + cryptKey = remoteKey + } + if tablePrefix == "" { + tablePrefix = metadata.TablePrefix + } + } else { + pterm.Warning.Printf("Could not extract remote bootstrap metadata (%v). Using generated fallback secret.\n", err) } - } else { - pterm.Warning.Printf("Could not extract crypt/key from remote env.php (%v). Using fallback key.\n", err) } } containerName := fmt.Sprintf("%s%s", config.ProjectName, conventions.DBSuffix) localDB := resolveLocalDBCredentials(config, containerName) - template := buildBootstrapMagentoEnvPHP(cryptKey, localDB, tablePrefix) + template := definition.RenderBootstrapEnvironment(cryptKey, types.BootstrapEnvironmentDatabase{ + Database: localDB.Database, + Username: localDB.Username, + Password: localDB.Password, + }, tablePrefix) if err := os.WriteFile(envPath, []byte(template), conventions.DefaultFilePerm); err != nil { - return fmt.Errorf("failed to write app/etc/env.php: %w", err) + return fmt.Errorf("write framework bootstrap environment: %w", err) } - pterm.Info.Println("Generated local app/etc/env.php for bootstrap.") + pterm.Info.Println("Generated local framework bootstrap environment.") return nil } -func buildBootstrapMagentoEnvPHP(cryptKey string, localDB dbCredentials, tablePrefix string) string { - localDB = localDB.withDefaults() - tablePrefix = engine.NormalizeTablePrefix(tablePrefix) - - return fmt.Sprintf(` [ - 'frontName' => %q - ], - 'crypt' => [ - 'key' => %q - ], - 'db' => [ - 'table_prefix' => %q, - 'connection' => [ - 'default' => [ - 'host' => %q, - 'dbname' => %q, - 'username' => %q, - 'password' => %q, - 'active' => '1' - ], - 'indexer' => [ - 'host' => %q, - 'dbname' => %q, - 'username' => %q, - 'password' => %q, - 'active' => '1' - ] - ] - ], - 'resource' => [ - 'default_setup' => [ - 'connection' => 'default' - ] - ], - 'x-frame-options' => 'SAMEORIGIN', - 'MAGE_MODE' => 'developer', - 'session' => [ - 'save' => 'files' - ], - 'install' => [ - 'date' => 'Mon, 01 May 2023 00:00:00 +0000' - ] -]; -`, conventions.DefaultAdminPath, - cryptKey, - tablePrefix, - conventions.DefaultMagentoDBHost, - localDB.Database, localDB.Username, localDB.Password, - conventions.DefaultMagentoDBHost, - localDB.Database, localDB.Username, localDB.Password, - ) -} - -func runMagentoSearchHostFixViaCLI(cmd *cobra.Command, config engine.Config) error { - host := "elasticsearch" - if s := strings.ToLower(strings.TrimSpace(config.Stack.Services.Search)); s != "" && s != "none" { - host = s +func runFrameworkSearchHostFixViaCLI(cmd *cobra.Command, config engine.Config) error { + definition, ok := frameworks.Get(config.Framework) + if !ok || definition.BuildSearchHostFixSQL == nil { + return nil } - searchEngine := engine.ResolveMagentoSearchEngine(config) - sql := engine.BuildMagentoSearchHostFixSQL(host, searchEngine) + sql := definition.BuildSearchHostFixSQL(config) // Skip the --environment flag implicitly because we're running it locally err := runGovardSubcommand(cmd, "db", "query", sql) if err != nil { @@ -138,19 +94,3 @@ func runMagentoSearchHostFixViaCLI(cmd *cobra.Command, config engine.Config) err } return err } - -func BuildBootstrapMagentoEnvPHPForTest(cryptKey, database, username, password string) string { - return buildBootstrapMagentoEnvPHP(cryptKey, dbCredentials{ - Database: database, - Username: username, - Password: password, - }, "") -} - -func BuildBootstrapMagentoEnvPHPWithPrefixForTest(cryptKey, database, username, password, tablePrefix string) string { - return buildBootstrapMagentoEnvPHP(cryptKey, dbCredentials{ - Database: database, - Username: username, - Password: password, - }, tablePrefix) -} diff --git a/internal/cmd/bootstrap_options.go b/internal/cmd/bootstrap_options.go index ea63dc8e..6be99c65 100644 --- a/internal/cmd/bootstrap_options.go +++ b/internal/cmd/bootstrap_options.go @@ -4,6 +4,8 @@ import ( "fmt" "strings" + "govard/internal/frameworks" + "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -109,10 +111,10 @@ func validateBootstrapFrameworkVersion(framework string, version string) error { if !comparable || comparison < 0 { return fmt.Errorf("invalid --framework-version value %q (must be a numeric dotted version)", version) } - if strings.EqualFold(strings.TrimSpace(framework), "magento2") || strings.EqualFold(strings.TrimSpace(framework), "magento") { - comparison, _ = compareNumericDotVersions(version, "2.0.0") + if definition, ok := frameworks.Get(framework); ok && definition.MinimumBootstrapVersion != "" { + comparison, _ = compareNumericDotVersions(version, definition.MinimumBootstrapVersion) if comparison < 0 { - return fmt.Errorf("invalid --framework-version value %q (must be Magento 2.0.0+)", version) + return fmt.Errorf("invalid --framework-version value %q (must be %s %s+)", version, definition.DisplayName, definition.MinimumBootstrapVersion) } } return nil diff --git a/internal/cmd/bootstrap_plan.go b/internal/cmd/bootstrap_plan.go index 8948475a..16fc1771 100644 --- a/internal/cmd/bootstrap_plan.go +++ b/internal/cmd/bootstrap_plan.go @@ -5,6 +5,7 @@ import ( "strings" "govard/internal/engine" + "govard/internal/frameworks" "github.com/pterm/pterm" ) @@ -72,23 +73,11 @@ func buildBootstrapRemotePlan(config engine.Config, opts BootstrapRuntimeOptions plan.Commands = append(plan.Commands, cmdLine) } - // 6. Framework specific post-steps - switch { - case engine.IsMagento2Family(framework): - frameworkName := engine.Magento2FamilyDisplayName(framework) - plan.Descriptions = append(plan.Descriptions, fmt.Sprintf("Configuring %s environment (env.php)...", frameworkName)) - plan.Commands = append(plan.Commands, "govard config auto") - - if opts.AdminCreate { - plan.Descriptions = append(plan.Descriptions, fmt.Sprintf("Creating %s admin user...", frameworkName)) - plan.Commands = append(plan.Commands, "govard tool magento admin:user:create ...") + if definition, ok := frameworks.Get(framework); ok && definition.BootstrapPlanSteps != nil { + for _, step := range definition.BootstrapPlanSteps(opts.AdminCreate) { + plan.Descriptions = append(plan.Descriptions, step.Description) + plan.Commands = append(plan.Commands, step.Command) } - - plan.Descriptions = append(plan.Descriptions, fmt.Sprintf("Reindexing %s data...", frameworkName)) - plan.Commands = append(plan.Commands, "govard tool magento indexer:reindex") - case framework == "magento1" || framework == "openmage": - plan.Descriptions = append(plan.Descriptions, "Configuring Magento 1 environment (base URLs and scoped website/store URLs)...") - plan.Commands = append(plan.Commands, "govard config auto") } return plan, nil diff --git a/internal/cmd/bootstrap_post_install.go b/internal/cmd/bootstrap_post_install.go index 253060da..122db97c 100644 --- a/internal/cmd/bootstrap_post_install.go +++ b/internal/cmd/bootstrap_post_install.go @@ -1,17 +1,9 @@ package cmd import ( - "context" "fmt" - "os" - "path/filepath" - "strings" - - "govard/internal/conventions" - "govard/internal/engine" - - "github.com/pterm/pterm" "github.com/spf13/cobra" + "strings" ) func runBootstrapHyvaInstall(cmd *cobra.Command, opts BootstrapRuntimeOptions) error { @@ -43,93 +35,9 @@ func runBootstrapHyvaInstall(cmd *cobra.Command, opts BootstrapRuntimeOptions) e return nil } -func resolveBootstrapMagentoTablePrefix(config engine.Config) (string, error) { - if prefix := engine.NormalizeTablePrefix(config.TablePrefix); prefix != "" { - if !engine.ValidateTablePrefix(prefix) { - return "", fmt.Errorf("invalid table_prefix %q (allowed: letters, numbers, and underscore)", prefix) - } - return prefix, nil - } - prefix := engine.NormalizeTablePrefix(os.Getenv("TABLE_PREFIX")) - if !engine.ValidateTablePrefix(prefix) { - return "", fmt.Errorf("invalid TABLE_PREFIX %q (allowed: letters, numbers, and underscore)", prefix) - } - return prefix, nil -} - -func runBootstrapSampleData(cmd *cobra.Command) error { - commands := [][]string{ - {"sample:deploy"}, - {"setup:upgrade"}, - {"indexer:reindex"}, - {"cache:flush"}, - } - for _, args := range commands { - if err := runGovardSubcommand(cmd, govardMagentoSubcommandArgs(args...)...); err != nil { - return fmt.Errorf("sample data step failed (%s): %w", strings.Join(args, " "), err) - } - } - return nil -} - -func runBootstrapMagentoReindex(cmd *cobra.Command) error { - pterm.Info.Println("Reindexing Magento data...") - cwd, _ := os.Getwd() - projectName := filepath.Base(cwd) - if cfg, _, err := engine.LoadConfigFromDir(cwd, false); err == nil && strings.TrimSpace(cfg.ProjectName) != "" { - projectName = cfg.ProjectName - } - - containerName := fmt.Sprintf("%s%s", projectName, conventions.PHPSuffix) - if !engine.IsContainerRunning(context.Background(), containerName) { - pterm.Warning.Printf("Skipping reindex: container %s is not running\n", containerName) - return nil - } - - if err := runGovardSubcommand(cmd, govardMagentoSubcommandArgs("indexer:reindex")...); err != nil { - return fmt.Errorf("reindex failed: %w", err) - } - return nil -} - -func runBootstrapAdminCreate(cmd *cobra.Command, config engine.Config) { - cwd, _ := os.Getwd() - projectName := filepath.Base(cwd) - if strings.TrimSpace(config.ProjectName) != "" { - projectName = config.ProjectName - } - - containerName := fmt.Sprintf("%s%s", projectName, conventions.PHPSuffix) - if !engine.IsContainerRunning(context.Background(), containerName) { - pterm.Warning.Printf("Skipping admin user creation: container %s is not running\n", containerName) - return - } - - pterm.Info.Println("Creating Magento admin user...") - err := runGovardSubcommandSilent( - cmd, - govardMagentoSubcommandArgs( - "admin:user:create", - "--admin-user="+conventions.DefaultAdminUser, - "--admin-password="+conventions.DefaultAdminPassword, - "--admin-firstname=Govard", - "--admin-lastname=Admin", - "--admin-email="+conventions.AdminEmailForDomain(config.Domain), - )..., - ) - if err != nil { - pterm.Warning.Printf("Admin user creation skipped: %v\n", err) - } -} - // RunBootstrapHyvaInstallForTest exposes runBootstrapHyvaInstall for tests in /tests. func RunBootstrapHyvaInstallForTest(cmd *cobra.Command, hyvaToken string) error { return runBootstrapHyvaInstall(cmd, BootstrapRuntimeOptions{ HyvaToken: strings.TrimSpace(hyvaToken), }) } - -// RunBootstrapSampleDataForTest exposes runBootstrapSampleData for tests in /tests. -func RunBootstrapSampleDataForTest(cmd *cobra.Command) error { - return runBootstrapSampleData(cmd) -} diff --git a/internal/cmd/bootstrap_remote.go b/internal/cmd/bootstrap_remote.go index 49d5bd0f..2fb0fb5f 100644 --- a/internal/cmd/bootstrap_remote.go +++ b/internal/cmd/bootstrap_remote.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "fmt" "os" "path/filepath" @@ -22,6 +23,45 @@ var bootstrapRemoteDirExists = func(remoteName string, remoteCfg engine.RemoteCo return probe.Run() == nil } +var frameworkLookupForBootstrap = frameworks.Get + +func prepareFrameworkComposer(config engine.Config) error { + definition, ok := frameworkLookupForBootstrap(config.Framework) + if !ok || definition.PrepareComposer == nil { + return nil + } + return definition.PrepareComposer(config) +} + +// SetFrameworkLookupForBootstrapForTest swaps the registry lookup used by the +// clone-bootstrap capabilities. It lets tests exercise a hook without running +// an external framework command. +func SetFrameworkLookupForBootstrapForTest(fn func(name string) (types.FrameworkDefinition, bool)) func() { + previous := frameworkLookupForBootstrap + frameworkLookupForBootstrap = fn + return func() { frameworkLookupForBootstrap = previous } +} + +// PrepareFrameworkComposerForTest exposes the generic composer preparation +// dispatch for a focused capability test. +func PrepareFrameworkComposerForTest(config engine.Config) error { + return prepareFrameworkComposer(config) +} + +func shouldRunComposerDumpAutoload(framework string, composerJSONExists bool) bool { + if composerJSONExists { + return true + } + definition, ok := frameworkLookupForBootstrap(framework) + return !ok || !definition.RequiresComposerManifestForDumpAutoload +} + +// ShouldRunComposerDumpAutoloadForTest exposes the registry-driven policy for +// a focused regression test. +func ShouldRunComposerDumpAutoloadForTest(framework string, composerJSONExists bool) bool { + return shouldRunComposerDumpAutoload(framework, composerJSONExists) +} + // bootstrapPostCloneDefinition returns framework's registry entry if it // participates in the generic FrameworkBootstrap.PostClone interface step of // the remote/clone bootstrap workflow. magento2/mageos are excluded even @@ -30,7 +70,7 @@ var bootstrapRemoteDirExists = func(remoteName string, remoteCfg engine.RemoteCo // PreConfigureHook/PostCloneHook fields, not through this interface method. func bootstrapPostCloneDefinition(framework string) (types.FrameworkDefinition, bool) { def, ok := frameworks.Get(framework) - if !ok || !def.SupportsBootstrap || engine.IsMagento2Family(framework) { + if !ok || !def.SupportsBootstrap || def.PreConfigureHook != nil || def.PostCloneHook != nil { return types.FrameworkDefinition{}, false } return def, true @@ -90,10 +130,8 @@ func runBootstrapRemote(cmd *cobra.Command, config engine.Config, opts Bootstrap pterm.Warning.Printf("Could not verify/fix composer compatibility: %v\n", err) } - if config.Framework == "wordpress" { - if err := engine.FixWordPressCompatibility(config); err != nil { - pterm.Warning.Printf("Could not verify/fix WordPress compatibility: %v\n", err) - } + if err := prepareFrameworkComposer(config); err != nil { + pterm.Warning.Printf("Could not prepare framework composer compatibility: %v\n", err) } if err := ensureBootstrapAuthJSON(config, opts); err != nil { @@ -140,7 +178,7 @@ func runBootstrapRemote(cmd *cobra.Command, config engine.Config, opts Bootstrap // a remote sync or when a lock file references a missing VCS commit but the dependency already exists locally. if opts.ComposerInstall { composerJSONPath := filepath.Join(cwd, "composer.json") - if fileExists(composerJSONPath) || strings.ToLower(config.Framework) != "wordpress" { + if shouldRunComposerDumpAutoload(config.Framework, fileExists(composerJSONPath)) { if err := bootstrapComposerDumpAutoload(cmd, cwd); err != nil { return err } @@ -168,15 +206,17 @@ func runBootstrapRemote(cmd *cobra.Command, config engine.Config, opts Bootstrap }, } hookHelpers := bootstrap.CmdHelpers{ - EnsureMagentoEnvPHP: func() error { - return ensureBootstrapMagentoEnvPHP(config, opts) + EnsureFrameworkEnvironment: func() error { + return ensureBootstrapFrameworkEnvironment(config, opts) }, - RunMagentoAdminCreate: func() error { - runBootstrapAdminCreate(cmd, config) - return nil + RunTool: func(tool string, args []string) error { + return runGovardSubcommand(cmd, govardToolSubcommandArgs(tool, args...)...) + }, + RunToolSilent: func(tool string, args []string) error { + return runGovardSubcommandSilent(cmd, govardToolSubcommandArgs(tool, args...)...) }, - RunMagentoReindex: func() error { - return runBootstrapMagentoReindex(cmd) + IsPHPContainerRunning: func() bool { + return engine.IsContainerRunning(context.Background(), fmt.Sprintf("%s%s", config.ProjectName, conventions.PHPSuffix)) }, } @@ -220,21 +260,16 @@ func runBootstrapRemote(cmd *cobra.Command, config engine.Config, opts Bootstrap Domain: config.Domain, } - if config.Framework == "prestashop" { - if remoteCfg, ok := config.Remotes[opts.Source]; ok { - if psEnv, err := remote.ProbePrestaShopEnvironment(opts.Source, remoteCfg); err == nil { - // The remote's actual table prefix reflects the DB that was just - // imported and takes priority over local config, same precedence as - // resolveRemoteDBCredentials uses for every other framework. - if remotePrefix := engine.SafeTablePrefix(psEnv.DB.TablePrefix); remotePrefix != "" { + if def, ok := frameworks.Get(config.Framework); ok && def.ProbeRemoteBootstrapMetadata != nil { + if remoteCfg, configured := config.Remotes[opts.Source]; configured { + metadata, err := def.ProbeRemoteBootstrapMetadata(opts.Source, remoteCfg) + if err != nil { + pterm.Warning.Printf("Could not probe remote bootstrap metadata, falling back to local config: %v\n", err) + } else { + if remotePrefix := engine.SafeTablePrefix(metadata.TablePrefix); remotePrefix != "" { bootstrapOpts.TablePrefix = remotePrefix } - bootstrapOpts.PrestaShopSecret = psEnv.Secrets.Secret - bootstrapOpts.PrestaShopCookieKey = psEnv.Secrets.CookieKey - bootstrapOpts.PrestaShopCookieIV = psEnv.Secrets.CookieIV - bootstrapOpts.PrestaShopNewCookieKey = psEnv.Secrets.NewCookieKey - } else { - pterm.Warning.Printf("Could not probe remote PrestaShop secrets/table prefix, falling back to local config: %v\n", err) + bootstrapOpts.RemoteMetadata = metadata.Private } } } diff --git a/internal/cmd/config_auto.go b/internal/cmd/config_auto.go index 160b4609..b841ed60 100644 --- a/internal/cmd/config_auto.go +++ b/internal/cmd/config_auto.go @@ -3,17 +3,18 @@ package cmd import ( "fmt" "govard/internal/engine" + "govard/internal/frameworks" + "govard/internal/frameworks/types" "github.com/pterm/pterm" "github.com/spf13/cobra" ) -var ( - runMagento2AutoConfiguration = func(projectName string, config engine.Config, force bool) error { - return engine.ConfigureMagento(projectName, config, force, nil) - } - runMagento1AutoConfiguration = engine.ConfigureMagento1 -) +// frameworkLookupForAutoConfigure is swappable in tests so +// ApplyFrameworkAutoConfigurationForTest can exercise a fake AutoConfigure +// closure without registering a throwaway framework in the real, +// process-global frameworks registry. +var frameworkLookupForAutoConfigure = frameworks.Get var configAutoCmd = &cobra.Command{ Use: "auto", @@ -35,39 +36,33 @@ var configAutoCmd = &cobra.Command{ } func applyFrameworkAutoConfiguration(cmd *cobra.Command, config engine.Config) error { - switch config.Framework { - case "magento2", "mageos": - // Proactively fix search host in DB via CLI (using govard db query) - if config.Stack.Features.Search || config.Stack.Services.Search != "none" { - _ = runMagentoSearchHostFixViaCLI(cmd, config) - } - return runMagento2AutoConfiguration(config.ProjectName, config, true) - case "magento1", "openmage": - return runMagento1AutoConfiguration(config.ProjectName, config) - case "wordpress": - return nil - default: + def, ok := frameworkLookupForAutoConfigure(config.Framework) + if !ok || def.AutoConfigure == nil { pterm.Warning.Printf( "Auto configuration is not supported for framework %q yet.\n", config.Framework, ) return nil } -} - -func SetMagento1AutoConfigurationRunnerForTest(fn func(projectName string, config engine.Config) error) func() { - previous := runMagento1AutoConfiguration - runMagento1AutoConfiguration = fn - return func() { - runMagento1AutoConfiguration = previous + if def.BuildSearchHostFixSQL != nil { + // The framework provides its SQL while generic command orchestration + // executes it through the local database command. + if config.Stack.Features.Search || config.Stack.Services.Search != "none" { + _ = runFrameworkSearchHostFixViaCLI(cmd, config) + } } + return def.AutoConfigure(cmd, config) } -func SetMagento2AutoConfigurationRunnerForTest(fn func(projectName string, config engine.Config, force bool) error) func() { - previous := runMagento2AutoConfiguration - runMagento2AutoConfiguration = fn +// SetFrameworkLookupForAutoConfigureForTest swaps the framework-lookup used +// by applyFrameworkAutoConfiguration so tests can exercise a fake +// AutoConfigure closure without registering a throwaway framework in the +// real, process-global frameworks registry. +func SetFrameworkLookupForAutoConfigureForTest(fn func(name string) (types.FrameworkDefinition, bool)) func() { + previous := frameworkLookupForAutoConfigure + frameworkLookupForAutoConfigure = fn return func() { - runMagento2AutoConfiguration = previous + frameworkLookupForAutoConfigure = previous } } diff --git a/internal/cmd/db_credentials.go b/internal/cmd/db_credentials.go index 8b05674c..aefc451b 100644 --- a/internal/cmd/db_credentials.go +++ b/internal/cmd/db_credentials.go @@ -10,6 +10,7 @@ import ( "govard/internal/conventions" "govard/internal/engine" "govard/internal/engine/remote" + "govard/internal/frameworks" ) type dbCredentials struct { @@ -46,64 +47,8 @@ func dbEngineForFramework(framework string) string { } func defaultDBCredentialsForFrameworkFields(framework string) dbCredentials { - switch strings.TrimSpace(framework) { - case conventions.FrameworkSymfony: - return dbCredentials{ - Port: conventions.MySQLPort, - Username: conventions.DefaultSymfonyDBUser, - Password: conventions.DefaultSymfonyDBPass, - Database: conventions.DefaultSymfonyDBName, - } - case conventions.FrameworkLaravel: - return dbCredentials{ - Port: conventions.MySQLPort, - Username: conventions.DefaultLaravelDBUser, - Password: conventions.DefaultLaravelDBPass, - Database: conventions.DefaultLaravelDBName, - } - case conventions.FrameworkWordPress: - return dbCredentials{ - Port: conventions.MySQLPort, - Username: conventions.DefaultWordPressDBUser, - Password: conventions.DefaultWordPressDBPass, - Database: conventions.DefaultWordPressDBName, - } - case conventions.FrameworkPrestaShop: - return dbCredentials{ - Port: conventions.MySQLPort, - Username: conventions.DefaultPrestaShopDBUser, - Password: conventions.DefaultPrestaShopDBPass, - Database: conventions.DefaultPrestaShopDBName, - } - case conventions.FrameworkMagento2, conventions.FrameworkMagento1: - return dbCredentials{ - Port: conventions.MySQLPort, - Username: conventions.DefaultMagentoDBUser, - Password: conventions.DefaultMagentoDBPass, - Database: conventions.DefaultMagentoDBName, - } - case conventions.FrameworkOpenMage: - return dbCredentials{ - Port: conventions.MySQLPort, - Username: conventions.DefaultOpenMageDBUser, - Password: conventions.DefaultOpenMageDBPass, - Database: conventions.DefaultOpenMageDBName, - } - case conventions.FrameworkMageOS: - return dbCredentials{ - Port: conventions.MySQLPort, - Username: conventions.DefaultMageOSDBUser, - Password: conventions.DefaultMageOSDBPass, - Database: conventions.DefaultMageOSDBName, - } - case conventions.FrameworkDjango: - return dbCredentials{ - Port: conventions.PostgresPort, - Username: conventions.DefaultDjangoDBUser, - Password: conventions.DefaultDjangoDBPass, - Database: conventions.DefaultDjangoDBName, - } - default: + def, ok := frameworks.Get(framework) + if !ok { return dbCredentials{ Port: conventions.MySQLPort, Username: conventions.DefaultDBUser, @@ -111,6 +56,12 @@ func defaultDBCredentialsForFrameworkFields(framework string) dbCredentials { Database: conventions.DefaultDBName, } } + return dbCredentials{ + Port: def.DefaultDBCredentials.Port, + Username: def.DefaultDBCredentials.Username, + Password: def.DefaultDBCredentials.Password, + Database: def.DefaultDBCredentials.Database, + } } func (credentials dbCredentials) withDefaults() dbCredentials { @@ -132,10 +83,10 @@ func (credentials dbCredentials) withDefaults() dbCredentials { return result } if strings.TrimSpace(result.Username) == "" { - result.Username = conventions.DefaultMagentoDBUser + result.Username = conventions.DefaultDBUser } if strings.TrimSpace(result.Database) == "" { - result.Database = conventions.DefaultMagentoDBName + result.Database = conventions.DefaultDBName } if strings.TrimSpace(result.Host) != "" && result.Port <= 0 { result.Port = conventions.MySQLPort @@ -320,97 +271,9 @@ func getPostgresDatabaseSize(config engine.Config, remoteName string, remoteCfg func resolveRemoteDBCredentials(config engine.Config, remoteName string, remoteCfg engine.RemoteConfig) (dbCredentials, error) { fallback := defaultDBCredentialsForFramework(config.Framework) fallback.TablePrefix = engine.NormalizeTablePrefix(config.TablePrefix) - switch strings.TrimSpace(config.Framework) { - case conventions.FrameworkMagento2, conventions.FrameworkMageOS: - metadata, err := remote.ProbeMagento2Environment(remoteName, remoteCfg) - if err != nil { - return fallback, err - } - return dbCredentials{ - Host: metadata.DB.Host, - Port: metadata.DB.Port, - Username: metadata.DB.Username, - Password: metadata.DB.Password, - Database: metadata.DB.Database, - TablePrefix: firstNonEmpty(metadata.DB.TablePrefix, config.TablePrefix), - }.withDefaults(), nil - case conventions.FrameworkMagento1, conventions.FrameworkOpenMage: - metadata, err := remote.ProbeMagento1Environment(remoteName, remoteCfg) - if err != nil { - return fallback, err - } - return dbCredentials{ - Host: metadata.DB.Host, - Port: metadata.DB.Port, - Username: metadata.DB.Username, - Password: metadata.DB.Password, - Database: metadata.DB.Database, - TablePrefix: firstNonEmpty(metadata.DB.TablePrefix, config.TablePrefix), - }.withDefaults(), nil - case conventions.FrameworkPrestaShop: - metadata, err := remote.ProbePrestaShopEnvironment(remoteName, remoteCfg) - if err != nil { - return fallback, err - } - return dbCredentials{ - Host: metadata.DB.Host, - Port: metadata.DB.Port, - Username: metadata.DB.Username, - Password: metadata.DB.Password, - Database: metadata.DB.Database, - TablePrefix: firstNonEmpty(metadata.DB.TablePrefix, config.TablePrefix), - }.withDefaults(), nil - case "wordpress": - metadata, err := remote.ProbeWordPressEnvironment(remoteName, remoteCfg) - if err != nil { - // Fallback to Dotenv for Bedrock-style WordPress sites - metadataDotenv, errDotenv := remote.ProbeDotenvEnvironment(remoteName, remoteCfg) - if errDotenv == nil { - return dbCredentials{ - Host: metadataDotenv.DB.Host, - Port: metadataDotenv.DB.Port, - Username: metadataDotenv.DB.Username, - Password: metadataDotenv.DB.Password, - Database: metadataDotenv.DB.Database, - }.withDefaults(), nil - } - return fallback, err - } - return dbCredentials{ - Host: metadata.DB.Host, - Port: metadata.DB.Port, - Username: metadata.DB.Username, - Password: metadata.DB.Password, - Database: metadata.DB.Database, - }.withDefaults(), nil - case "symfony", "laravel", "drupal", "shopware", "cakephp": - metadata, err := remote.ProbeDotenvEnvironment(remoteName, remoteCfg) - if err != nil { - return fallback, err - } - return dbCredentials{ - Host: metadata.DB.Host, - Port: metadata.DB.Port, - Username: metadata.DB.Username, - Password: metadata.DB.Password, - Database: metadata.DB.Database, - }.withDefaults(), nil - case "custom": - if remoteCfg.DBName != "" { - fallback.Database = remoteCfg.DBName - } - if remoteCfg.DBUser != "" { - fallback.Username = remoteCfg.DBUser - } - if remoteCfg.DBPass != "" { - fallback.Password = remoteCfg.DBPass - } - if remoteCfg.DBPort > 0 { - fallback.Port = remoteCfg.DBPort - } - return fallback, nil - default: + def, ok := frameworks.Get(config.Framework) + if !ok || def.ProbeRemoteDB == nil { if remoteCfg.DBName != "" { fallback.Database = remoteCfg.DBName } @@ -425,6 +288,27 @@ func resolveRemoteDBCredentials(config engine.Config, remoteName string, remoteC } return fallback, nil } + + metadata, err := def.ProbeRemoteDB(remoteName, remoteCfg) + if err != nil { + return fallback, err + } + return projectRemoteDBCredentials(config, metadata, def.RemoteDBUsesConfigTablePrefix), nil +} + +func projectRemoteDBCredentials(config engine.Config, metadata remote.RemoteDatabaseMetadata, useConfigTablePrefix bool) dbCredentials { + tablePrefix := metadata.TablePrefix + if tablePrefix == "" && useConfigTablePrefix { + tablePrefix = config.TablePrefix + } + return dbCredentials{ + Host: metadata.Host, + Port: metadata.Port, + Username: metadata.Username, + Password: metadata.Password, + Database: metadata.Database, + TablePrefix: tablePrefix, + }.withDefaults() } func resolveLocalDBCredentials(config engine.Config, containerName string) dbCredentials { @@ -466,16 +350,6 @@ func parseEnvMap(raw string) map[string]string { return result } -func firstNonEmpty(values ...string) string { - for _, value := range values { - trimmed := strings.TrimSpace(value) - if trimmed != "" { - return trimmed - } - } - return "" -} - func DefaultDBCredentialsForFrameworkForTest(framework string) dbCredentials { return defaultDBCredentialsForFramework(framework) } @@ -695,13 +569,13 @@ func BuildRemoteMySQLDumpCommandForTest(host string, port int, username string, Username: username, Password: password, Database: database, - }, false, false, "magento2", compress) + }, false, false, "", compress) } func BuildRemoteMySQLDumpCommandWithPrefixForTest(database string, tablePrefix string, noNoise bool, noPII bool, framework string) string { return buildRemoteMySQLDumpCommandString(dbCredentials{ - Username: conventions.DefaultMagentoDBUser, - Password: conventions.DefaultMagentoDBPass, + Username: conventions.DefaultDBUser, + Password: conventions.DefaultDBPass, Database: database, TablePrefix: tablePrefix, }, noNoise, noPII, framework, false) diff --git a/internal/cmd/db_import.go b/internal/cmd/db_import.go index 8bd14395..f17b32c4 100644 --- a/internal/cmd/db_import.go +++ b/internal/cmd/db_import.go @@ -133,7 +133,7 @@ func normalizeDatabaseName(database string) string { if name != "" { return name } - return "magento" + return conventions.DefaultDBName } func validateDatabaseName(name string) error { diff --git a/internal/cmd/deploy.go b/internal/cmd/deploy.go index b80b3344..e70f0b6d 100644 --- a/internal/cmd/deploy.go +++ b/internal/cmd/deploy.go @@ -9,6 +9,7 @@ import ( "time" "govard/internal/engine" + "govard/internal/frameworks" "github.com/pterm/pterm" "github.com/spf13/cobra" @@ -29,8 +30,8 @@ var deployCmd = &cobra.Command{ } locales, _ := cmd.Flags().GetString("locales") - if strings.TrimSpace(locales) == "" && engine.IsMagento2Family(config.Framework) { - detected := detectMagento2Locales(config) + if strings.TrimSpace(locales) == "" { + detected := detectFrameworkLocales(config) if len(detected) > 0 { locales = strings.Join(detected, " ") pterm.Info.Printf("Auto-detected locales: %s\n", locales) @@ -59,10 +60,14 @@ func init() { rootCmd.AddCommand(deployCmd) } -// detectMagento2Locales queries the local database container for all active locale codes. +// detectFrameworkLocales queries framework-owned locale metadata. // It returns a deduplicated, sorted list that always includes "en_US". // Falls back silently on any error. -func detectMagento2Locales(config engine.Config) []string { +func detectFrameworkLocales(config engine.Config) []string { + definition, ok := frameworks.Get(config.Framework) + if !ok || definition.BuildDeployLocalesQuery == nil { + return nil + } containerName := dbContainerName(config) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) @@ -70,9 +75,7 @@ func detectMagento2Locales(config engine.Config) []string { credentials := resolveLocalDBCredentials(config, containerName) credentials = credentials.withDefaults() - configTable := credentials.TablePrefix + "core_config_data" - // Query locale codes from core_config_data (covers storefront + admin). - query := "SELECT DISTINCT value FROM " + configTable + " WHERE path IN ('general/locale/code','general/locale/timezone') AND value REGEXP '^[a-z]{2}_[A-Z]{2}$';" + query := definition.BuildDeployLocalesQuery(credentials.TablePrefix) args := []string{"exec", "-i"} if strings.TrimSpace(credentials.Password) != "" { diff --git a/internal/cmd/doctor_fix.go b/internal/cmd/doctor_fix.go index 44dfec94..c561a0df 100644 --- a/internal/cmd/doctor_fix.go +++ b/internal/cmd/doctor_fix.go @@ -10,6 +10,7 @@ import ( "time" "govard/internal/engine" + "govard/internal/frameworks" "github.com/pterm/pterm" "gopkg.in/yaml.v3" @@ -138,8 +139,14 @@ func unblockSearchIndex(check engine.DoctorCheck) DoctorFixResult { } config := loadConfig() + definition, ok := frameworks.Get(config.Framework) + if !ok || definition.UnblockSearchIndex == nil { + result.Status = DoctorFixStatusUnavailable + result.Message = fmt.Sprintf("%s does not support this search-index fix.", config.Framework) + return result + } result.Actions = append(result.Actions, "unblock search index via docker exec curl") - if err := engine.FixElasticsearchIndexBlock(config.ProjectName, config); err != nil { + if err := definition.UnblockSearchIndex(config); err != nil { result.Status = DoctorFixStatusSkipped result.Message = err.Error() return result diff --git a/internal/cmd/frameworks.go b/internal/cmd/frameworks.go index 4d3b3731..ab7dce80 100644 --- a/internal/cmd/frameworks.go +++ b/internal/cmd/frameworks.go @@ -4,10 +4,12 @@ import ( "fmt" "os" "os/exec" + "sort" "strings" "govard/internal/conventions" "govard/internal/engine" + "govard/internal/frameworks" "github.com/spf13/cobra" ) @@ -53,76 +55,7 @@ Case Studies: govard tool npm install`, } -var frameworkCommands = []FrameworkCommand{ - { - Name: "magento", - Short: "Run Magento CLI commands", - Frameworks: []string{"magento2", "mageos"}, - Binary: "php", - PrependArgs: []string{"bin/magento"}, - DefaultUser: "", - }, - { - Name: "artisan", - Short: "Run Laravel Artisan commands", - Frameworks: []string{"laravel"}, - Binary: "php", - PrependArgs: []string{"artisan"}, - DefaultUser: "", - }, - { - Name: "magerun", - Aliases: []string{"mr"}, - Short: "Run n98-magerun commands", - Frameworks: []string{"magento1", "magento2", "mageos", "openmage"}, - Binary: "n98-magerun", - DefaultUser: "", - }, - { - Name: "drush", - Short: "Run Drupal Drush commands", - Frameworks: []string{"drupal"}, - Binary: "drush", - DefaultUser: "", - }, - { - Name: "symfony", - Short: "Run Symfony CLI commands", - Frameworks: []string{"symfony"}, - Binary: "php", - PrependArgs: []string{"bin/console"}, - DefaultUser: "", - }, - { - Name: "shopware", - Short: "Run Shopware CLI commands", - Frameworks: []string{"shopware"}, - Binary: "bin/console", - DefaultUser: "", - }, - { - Name: "cake", - Short: "Run CakePHP CLI commands", - Frameworks: []string{"cakephp"}, - Binary: "bin/cake", - DefaultUser: "", - }, - { - Name: "prestashop", - Short: "Run PrestaShop CLI commands (Symfony console)", - Frameworks: []string{"prestashop"}, - Binary: "php", - PrependArgs: []string{"bin/console"}, - DefaultUser: "", - }, - { - Name: "manage", - Short: "Run Django management commands", - Frameworks: []string{"django"}, - Binary: "python", - PrependArgs: []string{"manage.py"}, - DefaultUser: "", - }, +var genericToolCommands = []FrameworkCommand{ { Name: "composer", Short: "Run composer commands", @@ -135,13 +68,6 @@ var frameworkCommands = []FrameworkCommand{ Binary: "php", DefaultUser: "", }, - { - Name: "wp", - Short: "Run WordPress CLI commands", - Frameworks: []string{"wordpress"}, - Binary: "wp", - DefaultUser: "", - }, { Name: "npm", Short: "Run npm commands", @@ -174,6 +100,42 @@ var frameworkCommands = []FrameworkCommand{ }, } +var frameworkCommands = append(frameworkToolCommands(), genericToolCommands...) + +func frameworkToolCommands() []FrameworkCommand { + byName := make(map[string]int) + var commands []FrameworkCommand + for _, definition := range frameworks.All() { + for _, declaration := range definition.ToolCommands { + index, exists := byName[declaration.Name] + if !exists { + byName[declaration.Name] = len(commands) + commands = append(commands, FrameworkCommand{ + Name: declaration.Name, + Aliases: append([]string(nil), declaration.Aliases...), + Short: declaration.Short, + Binary: declaration.Binary, + PrependArgs: append([]string(nil), declaration.PrependArgs...), + DefaultUser: declaration.DefaultUser, + }) + index = len(commands) - 1 + } + commands[index].Frameworks = append(commands[index].Frameworks, definition.Name) + } + } + for index := range commands { + sort.Strings(commands[index].Frameworks) + } + sort.Slice(commands, func(i, j int) bool { return commands[i].Name < commands[j].Name }) + return commands +} + +// FrameworkToolCommandsForTest exposes only framework-owned commands; generic +// Composer/PHP/Node tools deliberately remain outside the registry. +func FrameworkToolCommandsForTest() []FrameworkCommand { + return frameworkToolCommands() +} + func initFrameworkCommands() { for _, fc := range frameworkCommands { usage := fmt.Sprintf("%s [args]", fc.Name) @@ -277,11 +239,7 @@ func resolveToolExecution(config engine.Config, binary string, defaultUser strin } } - if engine.IsMagento2Family(config.Framework) && (binary == "php" || binary == "composer" || - binary == "npm" || binary == "yarn" || binary == "npx" || - binary == "pnpm" || binary == "grunt") { - user = config.ResolveProjectExecUser(conventions.UserWWWData) - } else if user == "" { + if user == "" { user = config.ResolveProjectExecUser(conventions.UserWWWData) } diff --git a/internal/cmd/init.go b/internal/cmd/init.go index 27e928cc..8a80f4c8 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -4,10 +4,10 @@ import ( "fmt" "govard/internal/conventions" "govard/internal/engine" + "govard/internal/frameworks" "os" "os/exec" "path/filepath" - "sort" "strings" "time" @@ -107,41 +107,22 @@ Case Studies: metadata.Framework = migrated.Framework } if initFramework != "" { - metadata.Framework = strings.ToLower(initFramework) - if metadata.Framework == "magento" { - metadata.Framework = "magento2" - } + metadata.Framework = frameworks.Normalize(initFramework) } if initFrameworkVersion != "" { metadata.Version = initFrameworkVersion } if metadata.Framework == "" || metadata.Framework == "generic" { - frameworkMap := map[string]string{ - "CakePHP": "cakephp", - "Custom": "custom", - "Drupal": "drupal", - "Emdash": "emdash", - "Laravel": "laravel", - "Magento 1": "magento1", - "Magento 2": "magento2", - "Mage-OS": "mageos", - "Next.js": "nextjs", - "OpenMage": "openmage", - "PrestaShop": "prestashop", - "Shopware": "shopware", - "Symfony": "symfony", - "WordPress": "wordpress", - } - - frameworkDisplayOptions := make([]string, 0, len(frameworkMap)) - for k := range frameworkMap { - frameworkDisplayOptions = append(frameworkDisplayOptions, k) + options := frameworkSelectionOptions() + labels := make([]string, 0, len(options)) + byLabel := make(map[string]string, len(options)) + for _, option := range options { + labels = append(labels, option.DisplayName) + byLabel[option.DisplayName] = option.Name } - sort.Strings(frameworkDisplayOptions) - - selectedDisplay := selectOption("Select project framework", frameworkDisplayOptions, "Custom") - metadata.Framework = frameworkMap[selectedDisplay] + selectedDisplay := selectOption("Select project framework", labels, "Custom") + metadata.Framework = byLabel[selectedDisplay] } if metadata.Version != "" { @@ -166,7 +147,8 @@ Case Studies: composerVersion := profileResult.Profile.ComposerVersion xdebugSession := profileResult.Profile.XdebugSession webRoot := profileResult.Profile.WebRoot - enableVarnish := engine.IsMagento2Family(metadata.Framework) && migrateFrom == "" && !hasExistingConfig + definition, _ := frameworks.Get(metadata.Framework) + enableVarnish := definition.EnableVarnishOnInit && migrateFrom == "" && !hasExistingConfig if metadata.Framework == "custom" { pterm.Info.Println("Customize your stack services for the custom framework.") diff --git a/internal/cmd/init_frameworks.go b/internal/cmd/init_frameworks.go new file mode 100644 index 00000000..2f56713e --- /dev/null +++ b/internal/cmd/init_frameworks.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "sort" + + "govard/internal/frameworks" +) + +// FrameworkSelectionOption is the presentation data used by the init picker. +// It comes from the registry so the CLI and desktop cannot drift apart. +type FrameworkSelectionOption struct { + Name string + DisplayName string +} + +func frameworkSelectionOptions() []FrameworkSelectionOption { + definitions := frameworks.All() + options := make([]FrameworkSelectionOption, 0, len(definitions)) + for _, definition := range definitions { + options = append(options, FrameworkSelectionOption{ + Name: definition.Name, + DisplayName: definition.DisplayName, + }) + } + sort.Slice(options, func(i, j int) bool { + return options[i].DisplayName < options[j].DisplayName + }) + return options +} + +// InitFrameworkOptionsForTest exposes the picker data for a registry-parity +// test without coupling that test to interactive terminal input. +func InitFrameworkOptionsForTest() []FrameworkSelectionOption { + return frameworkSelectionOptions() +} diff --git a/internal/cmd/open_targets.go b/internal/cmd/open_targets.go index 2b6db3bf..4de9f2cd 100644 --- a/internal/cmd/open_targets.go +++ b/internal/cmd/open_targets.go @@ -6,24 +6,18 @@ import ( "net/url" "os" "os/exec" - "path/filepath" - "regexp" "strings" "govard/internal/conventions" "govard/internal/engine" engineremote "govard/internal/engine/remote" + "govard/internal/frameworks" "github.com/pterm/pterm" ) const openLocalEnvironment = "local" -var ( - magentoFrontNamePattern = regexp.MustCompile(`(?i)['"]frontName['"]\s*=>\s*['"]([^'"]+)['"]`) - magentoTablePrefixPattern = regexp.MustCompile(`(?i)['"]table_prefix['"]\s*=>\s*['"]([^'"]*)['"]`) -) - func runOpenAdminTarget(config engine.Config, requestedEnvironment string) error { environment, isRemote, err := resolveOpenEnvironment(config, requestedEnvironment) if err != nil { @@ -36,17 +30,9 @@ func runOpenAdminTarget(config engine.Config, requestedEnvironment string) error if err != nil { return err } - var adminPath string - if strings.EqualFold(strings.TrimSpace(config.Framework), conventions.FrameworkEmdash) { - adminPath = "_emdash/admin" - } else { - detectedAdminPath, probeErr := detectRemoteMagentoAdminPath(config, environment, remoteCfg) - if probeErr != nil { - adminPath = conventions.DefaultAdminPath - pterm.Warning.Printf("Could not auto-detect admin path for '%s': %v\n", environment, probeErr) - } else { - adminPath = detectedAdminPath - } + adminPath, probeErr := frameworks.ResolveRemoteAdminPath(config.Framework, environment, remoteCfg) + if probeErr != nil { + pterm.Warning.Printf("Could not auto-detect admin path for '%s': %v\n", environment, probeErr) } url = buildRemoteAdminURL(remoteCfg, adminPath) } else { @@ -164,29 +150,23 @@ func runOpenPortainerTarget(config engine.Config, requestedEnvironment string) e func openAdminURL(config engine.Config) string { baseURL := "https://" + strings.TrimSpace(config.Domain) - if strings.EqualFold(strings.TrimSpace(config.Framework), conventions.FrameworkEmdash) { - return joinURLWithPath(baseURL, "_emdash/admin") - } - return joinURLWithPath(baseURL, conventions.DefaultAdminPath) + return joinURLWithPath(baseURL, frameworks.DefaultAdminPath(config.Framework)) } func detectLocalAdminURL(config engine.Config) string { - if strings.EqualFold(strings.TrimSpace(config.Framework), conventions.FrameworkEmdash) { + definition, ok := frameworks.Get(config.Framework) + if !ok || definition.DetectLocalAdminMetadata == nil || definition.BuildLocalAdminSettingsQuery == nil || definition.ResolveLocalAdminURL == nil { return openAdminURL(config) } baseURL := "https://" + strings.TrimSpace(config.Domain) - if !engine.IsMagento2Family(config.Framework) { - return joinURLWithPath(baseURL, conventions.DefaultAdminPath) - } - projectRoot, _ := os.Getwd() - frontName, tablePrefix := detectLocalMagentoAdminMeta(projectRoot) + frontName, tablePrefix := definition.DetectLocalAdminMetadata(projectRoot) if tablePrefix == "" { tablePrefix = config.TablePrefix } - dbValues := readLocalMagentoAdminDBValues(config, tablePrefix) - return resolveMagentoAdminURL(baseURL, frontName, dbValues) + dbValues := readLocalFrameworkAdminDBValues(config, definition.BuildLocalAdminSettingsQuery(tablePrefix)) + return definition.ResolveLocalAdminURL(baseURL, frontName, dbValues) } func runOpenLocalShell(config engine.Config) error { @@ -256,38 +236,13 @@ func buildRemoteAdminURL(remoteCfg engine.RemoteConfig, adminPath string) string return base + "/" + trimmedPath } -func detectLocalMagentoAdminMeta(projectRoot string) (string, string) { - envPath := filepath.Join(projectRoot, "app", "etc", "env.php") - content, err := os.ReadFile(envPath) - if err != nil { - return "", "" - } - - raw := string(content) - frontName := "" - tablePrefix := "" - - if match := magentoFrontNamePattern.FindStringSubmatch(raw); len(match) == 2 { - frontName = strings.Trim(strings.TrimSpace(match[1]), "/") - } - if match := magentoTablePrefixPattern.FindStringSubmatch(raw); len(match) == 2 { - tablePrefix = strings.TrimSpace(match[1]) - } - - return frontName, tablePrefix -} - -func readLocalMagentoAdminDBValues(config engine.Config, tablePrefix string) map[string]string { +func readLocalFrameworkAdminDBValues(config engine.Config, query string) map[string]string { containerName := dbContainerName(config) if err := ensureLocalDBRunning(containerName); err != nil { return map[string]string{} } credentials := resolveLocalDBCredentials(config, containerName) - table := tablePrefix + "core_config_data" - query := "SELECT path, value FROM " + table + - " WHERE path IN ('admin/url/use_custom','admin/url/use_custom_path','admin/url/custom','admin/url/custom_path')" - args := []string{"exec", "-i"} if strings.TrimSpace(credentials.Password) != "" { args = append(args, "-e", "MYSQL_PWD="+credentials.Password) @@ -299,10 +254,10 @@ func readLocalMagentoAdminDBValues(config engine.Config, tablePrefix string) map return map[string]string{} } - return parseMagentoAdminDBRows(string(output)) + return parseAdminDBRows(string(output)) } -func parseMagentoAdminDBRows(raw string) map[string]string { +func parseAdminDBRows(raw string) map[string]string { values := map[string]string{} for _, line := range strings.Split(raw, "\n") { trimmed := strings.TrimSpace(line) @@ -318,51 +273,6 @@ func parseMagentoAdminDBRows(raw string) map[string]string { return values } -func resolveMagentoAdminURL(baseURL string, envFrontName string, dbValues map[string]string) string { - frontName := strings.Trim(strings.TrimSpace(envFrontName), "/") - if frontName == "" { - frontName = conventions.DefaultAdminPath - } - - if truthyMagentoConfig(dbValues["admin/url/use_custom_path"]) { - if customPath := normalizeMagentoAdminTarget(dbValues["admin/url/custom_path"]); customPath != "" { - if isURLTarget(customPath) { - return customPath - } - return joinURLWithPath(baseURL, customPath) - } - } - - if truthyMagentoConfig(dbValues["admin/url/use_custom"]) { - if custom := normalizeMagentoAdminTarget(dbValues["admin/url/custom"]); custom != "" { - if isURLTarget(custom) { - return custom - } - return joinURLWithPath(baseURL, custom) - } - } - - return joinURLWithPath(baseURL, frontName) -} - -func truthyMagentoConfig(raw string) bool { - switch strings.ToLower(strings.TrimSpace(raw)) { - case "1", "true", "yes", "on": - return true - default: - return false - } -} - -func normalizeMagentoAdminTarget(raw string) string { - return strings.Trim(strings.TrimSpace(raw), "/") -} - -func isURLTarget(raw string) bool { - value := strings.ToLower(strings.TrimSpace(raw)) - return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") -} - func joinURLWithPath(baseURL string, path string) string { base := strings.TrimRight(strings.TrimSpace(baseURL), "/") trimmedPath := strings.Trim(strings.TrimSpace(path), "/") @@ -372,30 +282,6 @@ func joinURLWithPath(baseURL string, path string) string { return base + "/" + trimmedPath } -func detectRemoteMagentoAdminPath(config engine.Config, remoteName string, remoteCfg engine.RemoteConfig) (string, error) { - if !engine.IsMagento2Family(config.Framework) { - return conventions.DefaultAdminPath, nil - } - - phpScript := `$c=@include "app/etc/env.php"; if(!is_array($c)){fwrite(STDERR,"env.php not found"); exit(2);} echo (string)($c["backend"]["frontName"] ?? "` + conventions.DefaultAdminPath + `");` - remoteCommand := "php -r " + engine.ShellQuote(phpScript) - if path := strings.TrimSpace(remoteCfg.Path); path != "" { - remoteCommand = "cd " + engineremote.QuoteRemotePath(path) + " && " + remoteCommand - } - - probeCmd := engineremote.BuildSSHExecCommand(remoteName, remoteCfg, true, remoteCommand) - output, err := probeCmd.CombinedOutput() - if err != nil { - return conventions.DefaultAdminPath, fmt.Errorf("probe failed: %w", err) - } - - value := strings.Trim(strings.TrimSpace(string(output)), "/") - if value == "" { - value = conventions.DefaultAdminPath - } - return value, nil -} - func buildSFTPURL(remoteCfg engine.RemoteConfig) string { port := remoteCfg.Port if port <= 0 { @@ -429,7 +315,3 @@ func BuildRemoteAdminURLForTest(remoteCfg engine.RemoteConfig, adminPath string) func BuildSFTPURLForTest(remoteCfg engine.RemoteConfig) string { return buildSFTPURL(remoteCfg) } - -func ResolveMagentoAdminURLForTest(baseURL string, envFrontName string, dbValues map[string]string) string { - return resolveMagentoAdminURL(baseURL, envFrontName, dbValues) -} diff --git a/internal/cmd/profile.go b/internal/cmd/profile.go index bf7ae458..2bc40da7 100644 --- a/internal/cmd/profile.go +++ b/internal/cmd/profile.go @@ -7,6 +7,7 @@ import ( "strings" "govard/internal/engine" + "govard/internal/frameworks" "github.com/pterm/pterm" "github.com/spf13/cobra" @@ -216,10 +217,7 @@ func resolveProfileForCurrentProject() (engine.ProjectMetadata, engine.RuntimePr metadata := engine.DetectFramework(cwd) if v := strings.TrimSpace(profileFrameworkOverride); v != "" { - metadata.Framework = strings.ToLower(v) - if metadata.Framework == "magento" { - metadata.Framework = "magento2" - } + metadata.Framework = frameworks.Normalize(v) } if v := strings.TrimSpace(profileVersionOverride); v != "" { metadata.Version = v diff --git a/internal/cmd/sync.go b/internal/cmd/sync.go index 344c3b6b..6e4a0d2a 100644 --- a/internal/cmd/sync.go +++ b/internal/cmd/sync.go @@ -9,6 +9,7 @@ import ( "govard/internal/engine" "govard/internal/engine/remote" + "govard/internal/frameworks" "github.com/pterm/pterm" "github.com/spf13/cobra" @@ -327,8 +328,10 @@ Case Studies: return fmt.Errorf("post-synchronization hooks failed to execute: %w", err) } - if engine.IsMagento2Family(config.Framework) && (files || mediaMode != "") { - _ = engine.FixProjectPermissions(config.ProjectName, config) + if files || mediaMode != "" { + if definition, ok := frameworks.Get(config.Framework); ok && definition.PostSync != nil { + _ = definition.PostSync(config) + } } pterm.Success.Println("Synchronization successfully completed.") diff --git a/internal/cmd/test_helpers.go b/internal/cmd/test_helpers.go index 07bda8d2..4d5cb433 100644 --- a/internal/cmd/test_helpers.go +++ b/internal/cmd/test_helpers.go @@ -6,6 +6,7 @@ import ( "time" "govard/internal/engine" + "govard/internal/engine/remote" ) type UpReadinessCheckForTest struct { @@ -13,6 +14,30 @@ type UpReadinessCheckForTest struct { ContainerName string } +// RemoteDBCredentialsForTest is the observable subset of resolved remote DB +// credentials. It avoids exposing the command package's internal credential +// type while preserving the table-prefix contract in external tests. +type RemoteDBCredentialsForTest struct { + Host string + Port int + Username string + Database string + TablePrefix string +} + +// ProjectRemoteDBCredentialsForTest exposes framework-aware remote metadata +// projection without creating an SSH connection. +func ProjectRemoteDBCredentialsForTest(config engine.Config, metadata remote.RemoteDatabaseMetadata, useConfigTablePrefix bool) RemoteDBCredentialsForTest { + credentials := projectRemoteDBCredentials(config, metadata, useConfigTablePrefix) + return RemoteDBCredentialsForTest{ + Host: credentials.Host, + Port: credentials.Port, + Username: credentials.Username, + Database: credentials.Database, + TablePrefix: credentials.TablePrefix, + } +} + type UpCrossProjectRefreshDependenciesForTest struct { GetRunningProjectNames func(context.Context) ([]string, error) ReadProjectRegistryEntries func() ([]engine.ProjectRegistryEntry, error) diff --git a/internal/cmd/test_project.go b/internal/cmd/test_project.go index 2b592bea..04faab20 100644 --- a/internal/cmd/test_project.go +++ b/internal/cmd/test_project.go @@ -4,6 +4,8 @@ import ( "fmt" "govard/internal/conventions" "govard/internal/engine" + "govard/internal/frameworks" + "govard/internal/frameworks/types" "strings" "github.com/pterm/pterm" @@ -45,15 +47,10 @@ func init() { } func runDefaultTests(config engine.Config) error { - if engine.IsMagento2Family(config.Framework) { - return runPHPUnit(config, nil) - } - switch config.Framework { - case "laravel": - return runInPHPContainer(config, "php", []string{"artisan", "test"}) - default: - return runPHPUnit(config, nil) + if command, ok := frameworkTestCommand(config.Framework, "default"); ok { + return runInPHPContainer(config, command.Binary, command.Args) } + return runPHPUnit(config, nil) } func runPHPUnit(config engine.Config, args []string) error { @@ -77,43 +74,54 @@ func runPHPStan(config engine.Config, args []string) error { cmdArgs := []string{binaryPath, "analyze"} if len(args) > 0 { cmdArgs = append(cmdArgs, args...) + } else if def, ok := frameworks.Get(config.Framework); ok && len(def.PHPStanPaths) > 0 { + cmdArgs = append(cmdArgs, def.PHPStanPaths...) } else { - // Default paths for Magento 2 or others - if engine.IsMagento2Family(config.Framework) { - cmdArgs = append(cmdArgs, "app/code", "app/design") - } else { - cmdArgs = append(cmdArgs, "app", "src") - } + cmdArgs = append(cmdArgs, "app", "src") } return RunInContainer(config.ProjectName+conventions.PHPSuffix, ResolveProjectExecUser(config, conventions.UserWWWData), "php", cmdArgs) } func runMFTF(config engine.Config, args []string) error { - if !engine.IsMagento2Family(config.Framework) { - return fmt.Errorf("MFTF is only supported for Magento 2 projects") - } - fmt.Println() - pterm.NewStyle(pterm.BgLightBlue, pterm.FgBlack, pterm.Bold).Println(" Running MFTF Tests ") - fmt.Println() - binaryPath := "vendor/bin/mftf" - cmdArgs := []string{binaryPath, "run:group"} - cmdArgs = append(cmdArgs, args...) - - return RunInContainer(config.ProjectName+conventions.PHPSuffix, ResolveProjectExecUser(config, conventions.UserWWWData), "php", cmdArgs) + return runFrameworkTestSuite(config, "mftf", args) } func runIntegrationTests(config engine.Config, args []string) error { - if engine.IsMagento2Family(config.Framework) { + return runFrameworkTestSuite(config, "integration", args) +} + +func runFrameworkTestSuite(config engine.Config, suite string, args []string) error { + command, ok := frameworkTestCommand(config.Framework, suite) + if !ok { + return fmt.Errorf("%s tests not configured for framework: %s", suite, config.Framework) + } + if command.Label != "" { fmt.Println() - pterm.NewStyle(pterm.BgLightBlue, pterm.FgBlack, pterm.Bold).Println(" Running Magento 2 Integration Tests ") + pterm.NewStyle(pterm.BgLightBlue, pterm.FgBlack, pterm.Bold).Println(" Running " + command.Label + " ") fmt.Println() - binaryPath := "vendor/bin/phpunit" - cmdArgs := []string{"-c", "dev/tests/integration/phpunit.xml", binaryPath} - cmdArgs = append(cmdArgs, args...) - return RunInContainer(config.ProjectName+conventions.PHPSuffix, ResolveProjectExecUser(config, conventions.UserWWWData), "php", cmdArgs) } - return fmt.Errorf("integration tests not configured for framework: %s", config.Framework) + commandArgs := append(append([]string(nil), command.Args...), args...) + return RunInContainer(config.ProjectName+conventions.PHPSuffix, ResolveProjectExecUser(config, conventions.UserWWWData), command.Binary, commandArgs) +} + +func frameworkTestCommand(framework string, suite string) (types.TestCommand, bool) { + definition, ok := frameworks.Get(framework) + if !ok { + return types.TestCommand{}, false + } + if suite == "default" { + command := definition.DefaultTestCommand + return command, command.Binary != "" + } + command, ok := definition.TestSuiteCommands[suite] + return command, ok && command.Binary != "" +} + +// FrameworkTestCommandForTest resolves one command without starting a +// container, so tests cover registry ownership of suite availability. +func FrameworkTestCommandForTest(framework string, suite string) (types.TestCommand, bool) { + return frameworkTestCommand(framework, suite) } func runInPHPContainer(config engine.Config, binary string, args []string) error { diff --git a/internal/cmd/up.go b/internal/cmd/up.go index f8373350..7dbed9f8 100644 --- a/internal/cmd/up.go +++ b/internal/cmd/up.go @@ -5,6 +5,7 @@ import ( "fmt" "govard/internal/conventions" "govard/internal/engine" + "govard/internal/frameworks" "govard/internal/proxy" "govard/internal/updater" "io" @@ -400,11 +401,9 @@ func buildUpPipelineStages(cmd *cobra.Command, context *upRuntimeContext) []upPi } } - { - if context.Config.Framework == "wordpress" { - if err := engine.FixWordPressCompatibility(context.Config); err != nil { - pterm.Warning.Printf("Could not ensure WordPress (WP-CLI) compatibility: %v\n", err) - } + if definition, ok := frameworks.Get(context.Config.Framework); ok && definition.PostEnvironmentUp != nil { + if err := definition.PostEnvironmentUp(context.Config); err != nil { + pterm.Warning.Printf("Could not complete %s post-start compatibility: %v\n", definition.DisplayName, err) } } @@ -454,8 +453,8 @@ func buildUpPipelineStages(cmd *cobra.Command, context *upRuntimeContext) []upPi pterm.Warning.Printf("Could not refresh PMA active projects: %v\n", err) } - if engine.IsMagento2Family(context.Config.Framework) { - frameworkName := engine.Magento2FamilyDisplayName(context.Config.Framework) + if definition, ok := frameworks.Get(context.Config.Framework); ok && definition.ConfigureAfterProfileShift != nil { + frameworkName := definition.DisplayName if context.SkipTuning { pterm.Info.Println("Skipping framework auto-configuration (--no-tuning)") } else if context.ShiftInfo != nil && context.ShiftInfo.Shifted && stdinIsTerminal() { @@ -467,7 +466,7 @@ func buildUpPipelineStages(cmd *cobra.Command, context *upRuntimeContext) []upPi WithDefaultText("Y = tune now, N = skip tuning"). Show(fmt.Sprintf("Run %s auto-configuration?", frameworkName)) if proceed { - if err := engine.ConfigureMagento(context.Config.ProjectName, context.Config, false, context.ShiftInfo); err != nil { + if err := definition.ConfigureAfterProfileShift(context.Config, context.ShiftInfo); err != nil { pterm.Warning.Printf("%s auto-configuration failed: %v\n", frameworkName, err) } } else { @@ -475,7 +474,7 @@ func buildUpPipelineStages(cmd *cobra.Command, context *upRuntimeContext) []upPi } } else if context.ShiftInfo != nil && context.ShiftInfo.Shifted { // Non-interactive mode: run without prompt if shift detected - if err := engine.ConfigureMagento(context.Config.ProjectName, context.Config, false, context.ShiftInfo); err != nil { + if err := definition.ConfigureAfterProfileShift(context.Config, context.ShiftInfo); err != nil { pterm.Warning.Printf("%s auto-configuration failed: %v\n", frameworkName, err) } } @@ -792,34 +791,6 @@ func ApplyQuickstartProfile(config *engine.Config) { config.Stack.QueueVersion = "" } -// CheckMagentoRuntimeSync checks the detected Magento framework against configured -// runtime and returns warnings if there's a mismatch (without auto-tuning). -func CheckMagentoRuntimeSync(config engine.Config, metadata engine.ProjectMetadata) []string { - if config.Framework != "magento2" { - return nil - } - - cwd, _ := os.Getwd() - rawConfig, err := engine.LoadRawConfigFromDir(cwd, false) - if err != nil { - return nil - } - - warnings := engine.CollectProfileSyncWarnings(rawConfig, metadata) - if len(warnings) > 0 { - version := strings.TrimSpace(metadata.Version) - if version == "" { - version = strings.TrimSpace(config.FrameworkVersion) - } - return []string{fmt.Sprintf( - "Magento %s expects different services: %s. Run 'govard doctor --fix' to align.", - version, strings.Join(warnings, ", "), - )} - } - - return nil -} - func compareNumericDotVersions(left, right string) (int, bool) { return engine.CompareNumericDotVersions(left, right) } diff --git a/internal/cmd/vscode_setup.go b/internal/cmd/vscode_setup.go index e3873122..1dd81174 100644 --- a/internal/cmd/vscode_setup.go +++ b/internal/cmd/vscode_setup.go @@ -10,6 +10,7 @@ import ( "strings" "govard/internal/engine" + "govard/internal/frameworks" "github.com/pterm/pterm" "github.com/spf13/cobra" @@ -421,8 +422,8 @@ var phpstanDefaultConfigFilenames = []string{"phpstan.neon", "phpstan.neon.dist" // phpstan` already uses when no paths are given on the command line, so this // doesn't invent a new, inconsistent convention. func phpstanDefaultPaths(framework string) []string { - if engine.IsMagento2Family(framework) { - return []string{"app/code", "app/design"} + if def, ok := frameworks.Get(framework); ok && len(def.PHPStanPaths) > 0 { + return def.PHPStanPaths } return []string{"app", "src"} } @@ -465,18 +466,6 @@ func phpunitAvailable(root string) bool { return err == nil } -// composerCodingStandardPackages maps known Composer packages that register a -// phpcs coding standard to the standard name to pass as --standard. Checked -// in order; the first match wins. -var composerCodingStandardPackages = []struct { - Package string - Standard string -}{ - {Package: "magento/magento-coding-standard", Standard: "Magento2"}, - {Package: "wp-coding-standards/wpcs", Standard: "WordPress"}, - {Package: "drupal/coder", Standard: "Drupal"}, -} - // detectPHPCSStandard picks a phpcs coding standard for the project at root // by checking composer.json for a known coding-standard package, falling // back to PSR12 (always available in squizlabs/php_codesniffer) if none of @@ -497,12 +486,15 @@ func detectPHPCSStandard(root string) string { return fallback } - for _, candidate := range composerCodingStandardPackages { - if _, ok := composer.Require[candidate.Package]; ok { - return candidate.Standard + for _, def := range frameworks.All() { + if def.ComposerCodingStandard.Package == "" { + continue + } + if _, ok := composer.Require[def.ComposerCodingStandard.Package]; ok { + return def.ComposerCodingStandard.Standard } - if _, ok := composer.RequireDev[candidate.Package]; ok { - return candidate.Standard + if _, ok := composer.RequireDev[def.ComposerCodingStandard.Package]; ok { + return def.ComposerCodingStandard.Standard } } return fallback diff --git a/internal/conventions/orchestration.go b/internal/conventions/orchestration.go index 055f509c..c4be9baf 100644 --- a/internal/conventions/orchestration.go +++ b/internal/conventions/orchestration.go @@ -7,7 +7,7 @@ const ( // compose files' working_dir/volumes. NodeWorkDir = "/app" // PythonWorkDir is the working directory inside Django's "web" service - // container - see internal/blueprints/files/django/services.yml's + // container - see internal/frameworks/django/blueprint/services.yml's // working_dir/volumes. PythonWorkDir = "/app" diff --git a/internal/desktop/app.go b/internal/desktop/app.go index b0722b38..cf984940 100644 --- a/internal/desktop/app.go +++ b/internal/desktop/app.go @@ -10,7 +10,7 @@ import ( "sync" "govard/internal/engine" - _ "govard/internal/frameworks" // registers framework detection/config data via init() + "govard/internal/frameworks" ) func (app *App) GetUserInfo() (res UserInfo, err error) { @@ -171,3 +171,21 @@ func (app *App) DeleteProject(projectQuery string) (res string, err error) { } return "Project deleted successfully", nil } + +// ListFrameworks returns every framework registered in internal/frameworks, +// for the onboarding UI's framework picker. This keeps the frontend's +// dropdown, alias resolution, and display-name formatting in sync with the +// Go-side registry instead of duplicating framework metadata in JS. +func (app *App) ListFrameworks() (res []FrameworkOption, err error) { + defer RecoverPanic(&err, "ListFrameworks") + defs := frameworks.All() + res = make([]FrameworkOption, 0, len(defs)) + for _, def := range defs { + res = append(res, FrameworkOption{ + Name: def.Name, + DisplayName: def.DisplayName, + Aliases: append([]string(nil), def.Aliases...), // defensive copy - registry.go's All()/Get() warn callers not to mutate shared slice fields + }) + } + return res, nil +} diff --git a/internal/desktop/remotes.go b/internal/desktop/remotes.go index b27c46fb..828628a6 100644 --- a/internal/desktop/remotes.go +++ b/internal/desktop/remotes.go @@ -18,11 +18,11 @@ import ( "govard/internal/engine" engineremote "govard/internal/engine/remote" + "govard/internal/frameworks" "gopkg.in/yaml.v3" ) -const remoteMagentoAdminProbeScript = `$c=@include "app/etc/env.php"; if(!is_array($c)){fwrite(STDERR,"env.php not found"); exit(2);} echo (string)($c["backend"]["frontName"] ?? "` + conventions.DefaultAdminPath + `");` const remoteLastSyncReadLimit = 5000 var defaultRunGovardCommandForDesktop = func(root string, args []string) (string, error) { @@ -360,13 +360,7 @@ func resolveRemoteAdminURL(project string, remoteName string) (string, string, e return "", "", err } - adminPath := conventions.DefaultAdminPath - if engine.IsMagento2Family(cfg.Framework) { - detectedPath, probeErr := detectRemoteMagentoAdminPathForDesktop(resolvedRemoteName, remoteCfg) - if probeErr == nil { - adminPath = detectedPath - } - } + adminPath, _ := frameworks.ResolveRemoteAdminPath(cfg.Framework, resolvedRemoteName, remoteCfg) return buildRemoteAdminURLForDesktop(remoteCfg, adminPath), resolvedRemoteName, nil } @@ -432,28 +426,6 @@ func resolveRemoteConfigForCapability( return "", engine.RemoteConfig{}, fmt.Errorf("unknown remote: %s", trimmedRequested) } -func detectRemoteMagentoAdminPathForDesktop( - remoteName string, - remoteCfg engine.RemoteConfig, -) (string, error) { - remoteCommand := "php -r " + engine.ShellQuote(remoteMagentoAdminProbeScript) - if path := strings.TrimSpace(remoteCfg.Path); path != "" { - remoteCommand = "cd " + engineremote.QuoteRemotePath(path) + " && " + remoteCommand - } - - probeCmd := engineremote.BuildSSHExecCommand(remoteName, remoteCfg, true, remoteCommand) - output, err := probeCmd.CombinedOutput() - if err != nil { - return conventions.DefaultAdminPath, fmt.Errorf("probe failed: %w", err) - } - - value := strings.Trim(strings.TrimSpace(string(output)), "/") - if value == "" { - value = conventions.DefaultAdminPath - } - return value, nil -} - func buildRemoteAdminURLForDesktop(remoteCfg engine.RemoteConfig, adminPath string) string { base := strings.TrimSpace(remoteCfg.URL) if base == "" { @@ -1239,7 +1211,7 @@ func buildPresetSyncOptionDefs(project, preset string) presetSyncOptions { } } - isMagento := engine.IsMagento2Family(framework) || framework == "magento1" || framework == "openmage" + isMagento := frameworks.IsA(framework, "magento2") || frameworks.IsA(framework, "magento1") switch normalizedPreset { case "db": diff --git a/internal/desktop/types.go b/internal/desktop/types.go index 1a4be98d..ce04a6ae 100644 --- a/internal/desktop/types.go +++ b/internal/desktop/types.go @@ -123,3 +123,12 @@ type RemoteConfigSnapshot struct { AuthMethod string Capabilities []string } + +// FrameworkOption describes one selectable framework for the onboarding UI, +// sourced from the internal/frameworks registry so the frontend never +// hardcodes framework metadata independently of the Go-side definitions. +type FrameworkOption struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Aliases []string `json:"aliases"` +} diff --git a/internal/desktop/utils.go b/internal/desktop/utils.go index 3cc8a240..7226790d 100644 --- a/internal/desktop/utils.go +++ b/internal/desktop/utils.go @@ -4,6 +4,7 @@ import ( "fmt" "govard/internal/conventions" "govard/internal/engine" + "govard/internal/frameworks" "os" "path/filepath" "sort" @@ -121,34 +122,18 @@ func normalizeOnboardingFramework(framework string) string { switch strings.ToLower(strings.TrimSpace(framework)) { case "", "auto", "detect": return "" - case "m2": - return conventions.FrameworkMagento2 - case "m1": - return conventions.FrameworkMagento1 - case "wp": - return conventions.FrameworkWordPress default: - return strings.ToLower(strings.TrimSpace(framework)) + return frameworks.Normalize(framework) } } // ... (existing functions) func displayFramework(framework string) string { - switch framework { - case conventions.FrameworkMagento1: - return "Magento 1" - case conventions.FrameworkMagento2: - return "Magento 2" - case conventions.FrameworkNextJS: - return "Next.js" - case conventions.FrameworkEmdash: - return "Emdash" - case conventions.FrameworkCakePHP: - return "CakePHP" - default: - return titleCase(framework) + if def, ok := frameworks.Get(framework); ok && def.DisplayName != "" { + return def.DisplayName } + return titleCase(framework) } func formatDatabase(dbType, dbVersion string) string { diff --git a/internal/engine/bootstrap/base.go b/internal/engine/bootstrap/base.go index f90268ad..bec4a06a 100644 --- a/internal/engine/bootstrap/base.go +++ b/internal/engine/bootstrap/base.go @@ -48,14 +48,10 @@ type Options struct { Domain string ProjectName string - // PrestaShop encryption secrets carried over from a remote's parameters.php, so a - // fabricated local parameters.php can reuse them instead of generating fresh ones - // (module data encrypted under the remote's keys would otherwise be undecryptable - // after a DB clone). Left empty when no remote secrets were available/probed. - PrestaShopSecret string - PrestaShopCookieKey string - PrestaShopCookieIV string - PrestaShopNewCookieKey string + // RemoteMetadata carries framework-owned opaque values discovered while + // probing a remote. Generic bootstrap orchestration transports this map; + // each framework decides which keys it understands. + RemoteMetadata map[string]string } // CmdHelpers bundles the cmd-package-level closures a framework's @@ -98,41 +94,21 @@ type CmdHelpers struct { // theme package (internal/cmd's runBootstrapHyvaInstall). Only called // when Options.HyvaInstall is true. RunHyvaInstall func() error - // ResolveMagentoTablePrefix resolves/validates the table prefix from - // config or the TABLE_PREFIX env var (internal/cmd's - // resolveBootstrapMagentoTablePrefix). - ResolveMagentoTablePrefix func() (string, error) - // RunMagentoSetupInstall applies a best-effort Elasticsearch/OpenSearch - // read-only-allow-delete fix (if the PHP container is already running) - // and then runs `govard tool magento` with the given setup:install - // args (internal/cmd's tail of runBootstrapPostInstall). The args - // themselves are built by the caller via - // magento2.BuildSetupInstallArgs (internal/frameworks/magento2/ - // bootstrap.go) - this closure only executes them, since running a - // govard subcommand needs the cmd-package-only *cobra.Command. - RunMagentoSetupInstall func(args []string) error - // RunMagentoSampleData runs sample:deploy/setup:upgrade/indexer:reindex/ - // cache:flush (internal/cmd's runBootstrapSampleData). Only called - // when Options.IncludeSample is true. - RunMagentoSampleData func() error - - // EnsureMagentoEnvPHP generates app/etc/env.php if missing, probing - // the remote for a crypt key/table prefix to reuse where possible - // (internal/cmd's ensureBootstrapMagentoEnvPHP). Only the Magento - // family's PreConfigureHook calls this. - EnsureMagentoEnvPHP func() error - // RunMagentoAdminCreate creates the default Magento admin user via - // `govard tool magento admin:user:create`, best-effort - it never - // returns an error itself (internal/cmd's runBootstrapAdminCreate - // only warns on failure), so this closure always returns nil. Only - // the Magento family's PostCloneHook calls this, and only when - // Options.AdminCreate is true. - RunMagentoAdminCreate func() error - // RunMagentoReindex runs `govard tool magento indexer:reindex` - // (internal/cmd's runBootstrapMagentoReindex) - unlike - // RunMagentoAdminCreate, a failure here does propagate as a real - // error. Only the Magento family's PostCloneHook calls this. - RunMagentoReindex func() error + // RunTool executes a framework-selected `govard tool` command. Framework + // packages own the tool name and action arguments; cmd only transports them + // through Cobra's command runner. + RunTool func(tool string, args []string) error + RunToolSilent func(tool string, args []string) error + RunEnvironmentCommand func(args []string) error + IsPHPContainerRunning func() bool + // ResolveFrameworkTablePrefix resolves/validates the framework table prefix from + // config or the TABLE_PREFIX env var. + ResolveFrameworkTablePrefix func() (string, error) + + // EnsureFrameworkEnvironment generates a framework-owned runtime file, + // the remote for framework-owned configuration to reuse where possible. + // Only the Magento family's PreConfigureHook calls this. + EnsureFrameworkEnvironment func() error } // ErrFreshInstallSkipUp is returned by a framework's FreshInstall function diff --git a/internal/engine/bootstrap/magento1.go b/internal/engine/bootstrap/magento1.go deleted file mode 100644 index 286c959d..00000000 --- a/internal/engine/bootstrap/magento1.go +++ /dev/null @@ -1,97 +0,0 @@ -package bootstrap - -import ( - "context" - "crypto/md5" //nolint:gosec // MD5 is intentional here: Magento 1 uses salted MD5 for admin passwords - "crypto/rand" - "encoding/hex" - "fmt" - "govard/internal/conventions" - "os/exec" - "time" -) - -// GenerateMagento1CryptKey returns a random 32-character hex string for use -// as an encryption key. Exported (not a same-package-only helper) because -// OpenMage's and Magento1's bootstrap code, both now living in their own -// internal/frameworks/ packages, generate a local.xml crypt key and -// need to call this cross-package. -func GenerateMagento1CryptKey() (string, error) { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} - -// RunMagento1AdminUserSQL inserts/updates the admin user in the local DB using a salted MD5 hash. -// This matches the approach in warden-custom-commands bootstrap.cmd for maximum M1 compatibility. -func RunMagento1AdminUserSQL(containerName string, dbUser string, dbPassword string, dbName string, dbPrefix string, adminEmail string) error { - // Salted MD5: md5(default admin user + default admin password) + ":" + default admin user. - passHash := Md5SaltedHash(conventions.DefaultAdminUser, conventions.DefaultAdminPassword) - saltedPass := passHash + ":" + conventions.DefaultAdminUser - - insertSQL := fmt.Sprintf(` -INSERT INTO %sadmin_user(username, firstname, lastname, email, password, created, lognum, reload_acl_flag, is_active, extra, rp_token, rp_token_created_at) -VALUES (%q, "Admin", "User", %q, %q, NOW(), 0, 0, 1, NULL, NULL, NOW()) -ON DUPLICATE KEY UPDATE password = %q, is_active = 1; - --- Ensure Administrators group exists -INSERT IGNORE INTO %sadmin_role (parent_id, tree_level, sort_order, role_type, user_id, role_name) -VALUES (0, 1, 1, 'G', 0, 'Administrators'); - --- Ensure full permissions -INSERT IGNORE INTO %sadmin_rule (role_id, resource_id, privileges, assert_id, role_type, permission) -SELECT role_id, 'all', NULL, 0, 'G', 'allow' FROM %sadmin_role WHERE role_type = 'G' AND role_name = 'Administrators' LIMIT 1; - --- Assign user to Administrators -INSERT INTO %sadmin_role (parent_id, tree_level, sort_order, role_type, user_id, role_name) -SELECT role_id, 2, 0, 'U', (SELECT user_id FROM %sadmin_user WHERE username = %q LIMIT 1), %q -FROM %sadmin_role WHERE role_type = 'G' AND role_name = 'Administrators' LIMIT 1 - ON DUPLICATE KEY UPDATE parent_id = VALUES(parent_id); - `, - dbPrefix, conventions.DefaultAdminUser, adminEmail, saltedPass, saltedPass, - dbPrefix, dbPrefix, dbPrefix, dbPrefix, dbPrefix, conventions.DefaultAdminUser, conventions.DefaultAdminUser, dbPrefix) - - return RunSQLViaDockerExec(containerName, dbUser, dbPassword, dbName, insertSQL) -} - -// RunSQLViaDockerExec executes a SQL statement via docker exec on the given DB container. -// This is framework-agnostic (no Magento-specific logic in the body) and is reused by -// other frameworks' bootstrap code that need to run a one-off SQL statement against the -// project's local DB container. -func RunSQLViaDockerExec(containerName string, dbUser string, dbPassword string, dbName string, sql string) error { - script := fmt.Sprintf( - `if command -v mysql >/dev/null 2>&1; then DB_CLI=mysql; elif command -v mariadb >/dev/null 2>&1; then DB_CLI=mariadb; else exit 1; fi && echo %s | "$DB_CLI" -u %s %s -f`, - conventions.ShellQuote(sql), conventions.ShellQuote(dbUser), conventions.ShellQuote(dbName), - ) - - args := []string{"exec", "-i"} - if dbPassword != "" { - args = append(args, "-e", "MYSQL_PWD="+dbPassword) - } - args = append(args, containerName, "sh", "-lc", script) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - cmd := exec.CommandContext(ctx, "docker", args...) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("SQL exec failed: %w: %s", err, out) - } - return nil -} - -// Md5SaltedHash returns the MD5 hash of (salt + password) as a hex string. -// This matches Magento 1's salted password hashing: md5(salt . password). -// -// MD5 is required here, not a choice: Magento 1's own auth code only ever -// verifies this exact format, so using a stronger algorithm would produce a -// hash Magento 1 itself cannot log in with. This only ever hashes the local -// dev bootstrap's default admin credentials (see conventions.DefaultAdminUser -// / DefaultAdminPassword), never a real user secret. -func Md5SaltedHash(salt, password string) string { - h := md5.New() //nolint:gosec - fmt.Fprint(h, salt+password) - return hex.EncodeToString(h.Sum(nil)) -} diff --git a/internal/engine/bootstrap/sql.go b/internal/engine/bootstrap/sql.go new file mode 100644 index 00000000..2e7fdd61 --- /dev/null +++ b/internal/engine/bootstrap/sql.go @@ -0,0 +1,34 @@ +package bootstrap + +import ( + "context" + "fmt" + "os/exec" + "time" + + "govard/internal/conventions" +) + +// RunSQLViaDockerExec executes a SQL statement via docker exec on the +// project's local DB container. Framework packages supply their own SQL. +func RunSQLViaDockerExec(containerName string, dbUser string, dbPassword string, dbName string, sql string) error { + script := fmt.Sprintf( + `if command -v mysql >/dev/null 2>&1; then DB_CLI=mysql; elif command -v mariadb >/dev/null 2>&1; then DB_CLI=mariadb; else exit 1; fi && echo %s | "$DB_CLI" -u %s %s -f`, + conventions.ShellQuote(sql), conventions.ShellQuote(dbUser), conventions.ShellQuote(dbName), + ) + + args := []string{"exec", "-i"} + if dbPassword != "" { + args = append(args, "-e", "MYSQL_PWD="+dbPassword) + } + args = append(args, containerName, "sh", "-lc", script) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "docker", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("SQL exec failed: %w: %s", err, out) + } + return nil +} diff --git a/internal/engine/chown_directories.go b/internal/engine/chown_directories.go new file mode 100644 index 00000000..09202661 --- /dev/null +++ b/internal/engine/chown_directories.go @@ -0,0 +1,21 @@ +package engine + +import "strings" + +var defaultChownDirectories = map[string][]string{} + +// RegisterDefaultChownDirectories installs framework-owned ownership paths. +func RegisterDefaultChownDirectories(framework string, directories []string) { + key := strings.ToLower(strings.TrimSpace(framework)) + if key == "" { + return + } + defaultChownDirectories[key] = append([]string(nil), directories...) +} + +// DefaultChownDirectoriesForFramework returns a defensive copy of the +// framework-specific ownership paths registered at startup. +func DefaultChownDirectoriesForFramework(framework string) []string { + values := defaultChownDirectories[strings.ToLower(strings.TrimSpace(framework))] + return append([]string(nil), values...) +} diff --git a/internal/engine/config.go b/internal/engine/config.go index b4d42e4f..101e3b61 100644 --- a/internal/engine/config.go +++ b/internal/engine/config.go @@ -2,9 +2,10 @@ package engine import ( "fmt" - "govard/internal/conventions" "sort" "strings" + + "govard/internal/conventions" ) type Features struct { @@ -140,8 +141,17 @@ func GetDefaultChownDirList(framework string) []string { // Note: /home/www-data/.ssh is intentionally NOT included here. // The SSH directory is always mounted :ro, so chown would fail. list := []string{"/bash_history"} - if framework == "magento2" || framework == "mageos" { - list = append(list, conventions.DefaultWorkDir, conventions.HomeWWWData+"/.cache/composer") - } + list = append(list, DefaultChownDirectoriesForFramework(framework)...) return list } + +// GetEntrypointChownDirList excludes bind mounts that rely on UID/GID mapping. +func GetEntrypointChownDirList(dirs []string) []string { + result := make([]string, 0, len(dirs)) + for _, dir := range dirs { + if dir != conventions.DefaultWorkDir && dir != conventions.HomeWWWData+"/.cache/composer" { + result = append(result, dir) + } + } + return result +} diff --git a/internal/engine/config_normalize.go b/internal/engine/config_normalize.go index 5ca4c3d8..92318c03 100644 --- a/internal/engine/config_normalize.go +++ b/internal/engine/config_normalize.go @@ -13,13 +13,10 @@ func NormalizeConfig(config *Config, root string) { normalizeBlueprintRegistryConfig(&config.BlueprintRegistry) config.StoreDomains = normalizeStoreDomainMappings(config.StoreDomains) - config.Framework = strings.ToLower(strings.TrimSpace(config.Framework)) - if config.Framework == "magento" { - config.Framework = "magento2" - } + config.Framework = NormalizeFrameworkAlias(config.Framework) config.TablePrefix = NormalizeTablePrefix(config.TablePrefix) if config.TablePrefix == "" && root != "" { - config.TablePrefix = DetectMagentoTablePrefix(root, config.Framework) + config.TablePrefix = DetectFrameworkTablePrefix(root, config.Framework) } fwConfig, ok := GetFrameworkConfig(config.Framework) diff --git a/internal/engine/discovery.go b/internal/engine/discovery.go index 2b318037..f7ed2f7a 100644 --- a/internal/engine/discovery.go +++ b/internal/engine/discovery.go @@ -180,3 +180,46 @@ func readPackageDependencies(path string) (map[string]interface{}, bool) { } return deps, true } + +// aliasRegistry maps a lowercased/trimmed alias to its canonical framework +// name (e.g. "magento" -> "magento2", "wp" -> "wordpress"). Populated via +// RegisterFrameworkAlias, normally called once per framework from +// internal/frameworks's package-level Register (see +// internal/frameworks/registry.go), looping over each FrameworkDefinition's +// Aliases field. +var aliasRegistry = map[string]string{} + +// RegisterFrameworkAlias registers alias as resolving to canonical. Not +// safe for concurrent calls; intended usage is registration during package +// init(), before NormalizeFrameworkAlias is ever called. +func RegisterFrameworkAlias(alias string, canonical string) { + alias = strings.ToLower(strings.TrimSpace(alias)) + canonical = strings.ToLower(strings.TrimSpace(canonical)) + if alias == "" || canonical == "" { + return + } + aliasRegistry[alias] = canonical +} + +// NormalizeFrameworkAlias resolves a raw framework name (possibly an +// alias registered via RegisterFrameworkAlias) to its canonical name. +// Unknown names are returned lowercased/trimmed but otherwise unchanged - +// engine-internal code should call this instead of a local +// magento/wp-style switch. Packages that already import +// internal/frameworks (internal/cmd, internal/desktop) should prefer +// frameworks.Normalize directly instead - this function exists only for +// engine-internal code, which cannot import internal/frameworks without +// creating an import cycle (frameworks already imports engine). +func NormalizeFrameworkAlias(raw string) string { + normalized := strings.ToLower(strings.TrimSpace(raw)) + if canonical, ok := aliasRegistry[normalized]; ok { + return canonical + } + return normalized +} + +// GetRegisteredFrameworkAliasForTest exposes the alias registry for tests. +func GetRegisteredFrameworkAliasForTest(alias string) (string, bool) { + canonical, ok := aliasRegistry[strings.ToLower(strings.TrimSpace(alias))] + return canonical, ok +} diff --git a/internal/engine/framework_config.go b/internal/engine/framework_config.go index f448e53b..27d896f5 100644 --- a/internal/engine/framework_config.go +++ b/internal/engine/framework_config.go @@ -34,49 +34,11 @@ type FrameworkConfig struct { Includes []string // List of include files to load } -// FrameworkConfigs maps framework names to their configurations -var FrameworkConfigs = map[string]FrameworkConfig{ - "custom": { - Name: "custom", - Runtime: "php", - AppService: "php", - AppWorkdir: conventions.DefaultWorkDir, - NGINXPUBLIC: "", - NGINXTemplate: "default.conf", - DatabaseName: "app", - DefaultPHP: "", - DefaultNodeVer: "", - DefaultDB: "none", - DefaultDBVer: "", - DefaultMySQLVer: "", - DefaultNginxVer: "1.28", - DefaultApacheVer: "2.4", - DefaultCacheVer: "7.4", - DefaultSearchVer: "3.0", - DefaultVarnishVer: "8.0", - DefaultQueueVer: "4.2", - DefaultWebServer: "nginx", - DefaultSearch: "none", - DefaultCache: "none", - DefaultQueue: "none", - DefaultComposerVer: "", - Includes: []string{ - "includes/base.yml", - "includes/redis.yml", - "includes/elasticsearch.yml", - "includes/varnish.yml", - "includes/rabbitmq.yml", - "includes/livereload.yml", - }, - }, -} +// FrameworkConfigs is populated by framework registration during startup. +var FrameworkConfigs = map[string]FrameworkConfig{} func GetFrameworkConfig(name string) (FrameworkConfig, bool) { - name = strings.ToLower(strings.TrimSpace(name)) - if name == "magento" { - name = "magento2" - } - config, ok := FrameworkConfigs[name] + config, ok := FrameworkConfigs[NormalizeFrameworkAlias(name)] return config, ok } diff --git a/internal/engine/framework_family.go b/internal/engine/framework_family.go deleted file mode 100644 index 0aed95b3..00000000 --- a/internal/engine/framework_family.go +++ /dev/null @@ -1,25 +0,0 @@ -package engine - -import "strings" - -// IsMagento2Family reports whether framework uses Magento 2-compatible -// commands, configuration, and runtime behavior. Mage-OS is a drop-in fork -// of Magento 2 and therefore belongs to this family. -func IsMagento2Family(framework string) bool { - switch strings.ToLower(strings.TrimSpace(framework)) { - case "magento2", "magento", "mageos": - return true - default: - return false - } -} - -// Magento2FamilyDisplayName returns the user-facing name for the framework -// distribution. Callers should first ensure the framework is in the Magento 2 -// family with IsMagento2Family. -func Magento2FamilyDisplayName(framework string) string { - if strings.EqualFold(strings.TrimSpace(framework), "mageos") { - return "Mage-OS" - } - return "Magento 2" -} diff --git a/internal/engine/framework_manifest.go b/internal/engine/framework_manifest.go index 2acfb43d..a6b642c4 100644 --- a/internal/engine/framework_manifest.go +++ b/internal/engine/framework_manifest.go @@ -86,15 +86,7 @@ func loadFrameworkTablesManifest() { } func normalizeFrameworkManifestKey(framework string) string { - normalized := strings.ToLower(strings.TrimSpace(framework)) - switch normalized { - case "magento": - return "magento2" - case "wp": - return "wordpress" - default: - return normalized - } + return NormalizeFrameworkAlias(framework) } func getFrameworkManifestConfig(framework string) (FrameworkManifestConfig, bool) { @@ -141,8 +133,7 @@ func GetFrameworkIgnoredTables(framework string, noNoise bool, noPII bool) []str config, ok := getFrameworkManifestConfig(framework) if !ok { - // Fallback to magento2 standard if framework not recognized - config = frameworkManifest.Frameworks["magento2"] + return nil } tables := make([]string, 0) diff --git a/internal/engine/framework_manifest.json b/internal/engine/framework_manifest.json index 6cd66890..3aab1e84 100644 --- a/internal/engine/framework_manifest.json +++ b/internal/engine/framework_manifest.json @@ -62,19 +62,5 @@ ] } }, - "frameworks": { - "custom": { - "paths": { - "local_media": "public/media", - "remote_media": "public/media", - "web_root_candidates": [] - }, - "features": { - "requires_running_env_for_fresh_install": true, - "supports_post_clone": false - }, - "ignored": [], - "sensitive": [] - } - } + "frameworks": {} } diff --git a/internal/engine/framework_runtime_policy.go b/internal/engine/framework_runtime_policy.go new file mode 100644 index 00000000..4788741f --- /dev/null +++ b/internal/engine/framework_runtime_policy.go @@ -0,0 +1,22 @@ +package engine + +import "strings" + +var nodeImageFlavors = map[string]string{} +var varnishTemplateFrameworks = map[string]string{} + +func RegisterNodeImageFlavor(framework, flavor string) { + nodeImageFlavors[strings.ToLower(strings.TrimSpace(framework))] = strings.TrimSpace(flavor) +} + +func NodeImageFlavorForFramework(framework string) string { + return nodeImageFlavors[strings.ToLower(strings.TrimSpace(framework))] +} + +func RegisterVarnishTemplateFramework(framework, templateFramework string) { + varnishTemplateFrameworks[strings.ToLower(strings.TrimSpace(framework))] = strings.ToLower(strings.TrimSpace(templateFramework)) +} + +func VarnishTemplateFrameworkForFramework(framework string) string { + return varnishTemplateFrameworks[strings.ToLower(strings.TrimSpace(framework))] +} diff --git a/internal/engine/local_image_fallback.go b/internal/engine/local_image_fallback.go index 4d547974..653084a6 100644 --- a/internal/engine/local_image_fallback.go +++ b/internal/engine/local_image_fallback.go @@ -301,42 +301,6 @@ func localBuildSpecForGovardService(service string, tag string, repoPrefix strin {Name: conventions.EnvPHPVersion, Value: tag}, }, }, nil - case "php-magento1": - if isDebug { - return localImageBuildSpec{ - ContextRel: "php", - DockerfileRel: filepath.Join("php", "debug", "Dockerfile"), - BuildArgs: debugBuildArgs(repoPrefix+"php-magento1:"+baseTag, xdebugVersionOverride), - Dependencies: []string{repoPrefix + "php-magento1:" + baseTag}, - }, nil - } - return localImageBuildSpec{ - ContextRel: "php", - DockerfileRel: filepath.Join("php", "magento1", "Dockerfile"), - BuildArgs: []localImageBuildArg{ - {Name: conventions.EnvPHPVersion, Value: tag}, - {Name: "GOVARD_IMAGE_REPOSITORY", Value: repoPrefix}, - }, - Dependencies: []string{repoPrefix + "php:" + tag}, - }, nil - case "php-magento2": - if isDebug { - return localImageBuildSpec{ - ContextRel: "php", - DockerfileRel: filepath.Join("php", "debug", "Dockerfile"), - BuildArgs: debugBuildArgs(repoPrefix+"php-magento2:"+baseTag, xdebugVersionOverride), - Dependencies: []string{repoPrefix + "php-magento2:" + baseTag}, - }, nil - } - return localImageBuildSpec{ - ContextRel: "php", - DockerfileRel: filepath.Join("php", "magento2", "Dockerfile"), - BuildArgs: []localImageBuildArg{ - {Name: conventions.EnvPHPVersion, Value: tag}, - {Name: "GOVARD_IMAGE_REPOSITORY", Value: repoPrefix}, - }, - Dependencies: []string{repoPrefix + "php:" + tag}, - }, nil case "mariadb": return localImageBuildSpec{ ContextRel: "mariadb", @@ -407,10 +371,48 @@ func localBuildSpecForGovardService(service string, tag string, repoPrefix strin ContextRel: "dnsmasq", }, nil default: + if variant, ok := phpVariantFromServiceName(service); ok { + serviceImage := "php-" + variant + if isDebug { + return localImageBuildSpec{ + ContextRel: "php", + DockerfileRel: filepath.Join("php", "debug", "Dockerfile"), + BuildArgs: debugBuildArgs(repoPrefix+serviceImage+":"+baseTag, xdebugVersionOverride), + Dependencies: []string{repoPrefix + serviceImage + ":" + baseTag}, + }, nil + } + return localImageBuildSpec{ + ContextRel: "php", + DockerfileRel: filepath.Join("php", variant, "Dockerfile"), + BuildArgs: []localImageBuildArg{ + {Name: conventions.EnvPHPVersion, Value: tag}, + {Name: "GOVARD_IMAGE_REPOSITORY", Value: repoPrefix}, + }, + Dependencies: []string{repoPrefix + "php:" + tag}, + }, nil + } return localImageBuildSpec{}, fmt.Errorf("unsupported Govard image service %q", service) } } +// phpVariantFromServiceName reports whether service is a registered PHP +// image variant's service name (e.g. "php-magento1"), returning the bare +// variant ("magento1"). Lets this switch recognize any registered variant +// generically instead of one literal case per framework family. +func phpVariantFromServiceName(service string) (string, bool) { + const prefix = "php-" + if !strings.HasPrefix(service, prefix) { + return "", false + } + variant := strings.TrimPrefix(service, prefix) + for _, v := range registeredPHPImageVariants() { + if v == variant { + return variant, true + } + } + return "", false +} + // splitDebugTag splits a Govard image tag into its base (non-debug) version, // whether it targets the debug variant, and an optional Xdebug version // override encoded as a "-xdebug-" suffix (see base.yml, which diff --git a/internal/engine/migrate.go b/internal/engine/migrate.go index a935ca91..167af7d9 100644 --- a/internal/engine/migrate.go +++ b/internal/engine/migrate.go @@ -193,43 +193,11 @@ func MigrateFromWarden(root string) (MigrationResult, error) { } func mapDDEVTypeToFramework(ddevType string) string { - switch ddevType { - case "magento2": - return "magento2" - case "magento": - return "magento1" - case "laravel": - return "laravel" - case "drupal7", "drupal8", "drupal9", "drupal10", "drupal11": - return "drupal" - case "symfony": - return "symfony" - case "shopware6": - return "shopware" - case "wordpress": - return "wordpress" - default: - return ddevType - } + return lookupMigrationFramework("ddev", ddevType) } func mapWardenTypeToFramework(wardenType string) string { - switch wardenType { - case "magento2": - return "magento2" - case "magento1": - return "magento1" - case "laravel": - return "laravel" - case "symfony": - return "symfony" - case "shopware": - return "shopware" - case "wordpress": - return "wordpress" - default: - return wardenType - } + return lookupMigrationFramework("warden", wardenType) } func ParseDotEnv(path string) map[string]string { diff --git a/internal/engine/migration_registry.go b/internal/engine/migration_registry.go new file mode 100644 index 00000000..fff7a631 --- /dev/null +++ b/internal/engine/migration_registry.go @@ -0,0 +1,29 @@ +package engine + +import "strings" + +var migrationFrameworks = map[string]map[string]string{} + +// RegisterMigrationFramework associates an external migration-tool type with +// a framework's canonical name. Framework packages register these mappings at +// startup; engine keeps only generic source/type lookup. +func RegisterMigrationFramework(source, externalType, framework string) { + source = strings.ToLower(strings.TrimSpace(source)) + externalType = strings.ToLower(strings.TrimSpace(externalType)) + framework = strings.ToLower(strings.TrimSpace(framework)) + if source == "" || externalType == "" || framework == "" { + return + } + if migrationFrameworks[source] == nil { + migrationFrameworks[source] = map[string]string{} + } + migrationFrameworks[source][externalType] = framework +} + +func lookupMigrationFramework(source, externalType string) string { + normalized := strings.ToLower(strings.TrimSpace(externalType)) + if framework := migrationFrameworks[strings.ToLower(strings.TrimSpace(source))][normalized]; framework != "" { + return framework + } + return externalType +} diff --git a/internal/engine/php_image_variant.go b/internal/engine/php_image_variant.go new file mode 100644 index 00000000..5d53e062 --- /dev/null +++ b/internal/engine/php_image_variant.go @@ -0,0 +1,80 @@ +package engine + +import "strings" + +// phpImageVariantsByFramework maps a framework name to the Docker image +// variant suffix its PHP container needs (e.g. "magento1", "magento2"); +// frameworks with no entry (or an empty variant) use the plain "php" image. +var phpImageVariantsByFramework = map[string]string{} + +// RegisterPHPImageVariant registers variant as the PHP image suffix for +// framework, keyed the same way PHPImageVariantForFramework looks it up. +// Called from frameworks.Register (alongside RegisterDetection/ +// RegisterFrameworkConfig/RegisterFrameworkManifest) so a framework package +// can declare its own image variant instead of a literal case in +// RequiredRuntimeImages/local_image_fallback.go's switch. A blank variant +// is a no-op (nothing to register). Not safe for concurrent calls; intended +// usage is registration during package init(), before RequiredRuntimeImages +// is ever called. +func RegisterPHPImageVariant(framework string, variant string) { + framework = strings.ToLower(strings.TrimSpace(framework)) + variant = strings.TrimSpace(variant) + if variant == "" { + return + } + phpImageVariantsByFramework[framework] = variant +} + +// PHPImageVariantForFramework returns the registered PHP image variant +// suffix for framework (e.g. "magento1"), or "" if framework uses the plain +// "php" image. +func PHPImageVariantForFramework(framework string) string { + return phpImageVariantsByFramework[strings.ToLower(strings.TrimSpace(framework))] +} + +// registeredPHPImageVariants returns the distinct set of registered PHP +// image variant suffixes (e.g. {"magento1", "magento2"}), used by +// local_image_fallback.go to recognize a "php-" service name +// generically instead of one literal case per variant. +func registeredPHPImageVariants() []string { + seen := make(map[string]struct{}, len(phpImageVariantsByFramework)) + variants := make([]string, 0, len(phpImageVariantsByFramework)) + for _, v := range phpImageVariantsByFramework { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + variants = append(variants, v) + } + return variants +} + +// dbDriverCategoriesByFramework maps a framework name to its phpMyAdmin +// DB-driver category label (e.g. "magento"); frameworks with no entry fall +// back to "app" in the generated PHP (see buildPMAConfigContent). +var dbDriverCategoriesByFramework = map[string]string{} + +// RegisterDBDriverCategory registers category as the phpMyAdmin DB-driver +// category for framework, keyed the same way DBDriverCategoryForFramework +// looks it up. Called from frameworks.Register (alongside +// RegisterDetection/RegisterFrameworkConfig/RegisterFrameworkManifest/ +// RegisterPHPImageVariant) so a framework package can declare its own +// category instead of a literal entry in proxy.go's $dbMap PHP array. A +// blank category is a no-op (nothing to register). Not safe for concurrent +// calls; intended usage is registration during package init(), before +// buildPMAConfigContent is ever called. +func RegisterDBDriverCategory(framework string, category string) { + framework = strings.ToLower(strings.TrimSpace(framework)) + category = strings.TrimSpace(category) + if category == "" { + return + } + dbDriverCategoriesByFramework[framework] = category +} + +// DBDriverCategoryForFramework returns the registered phpMyAdmin DB-driver +// category for framework, or "" if it has none (the generated PHP treats +// that the same as an absent $dbMap entry - falls back to "app"). +func DBDriverCategoryForFramework(framework string) string { + return dbDriverCategoriesByFramework[strings.ToLower(strings.TrimSpace(framework))] +} diff --git a/internal/engine/profile.go b/internal/engine/profile.go index 61a2706e..381bac6d 100644 --- a/internal/engine/profile.go +++ b/internal/engine/profile.go @@ -36,7 +36,13 @@ type RuntimeProfileResult struct { Warnings []string } -type runtimeProfileOverride struct { +// VersionProfileOverride carries the runtime-profile fields a +// framework-version-specific resolver wants to override on top of +// framework defaults (empty string = "don't override this field"). +// Exported (renamed from the former unexported runtimeProfileOverride) so +// framework packages can construct/return it via their registered +// FrameworkDefinition.VersionProfileResolver. +type VersionProfileOverride struct { PHPVersion string NodeVersion string DB string @@ -57,13 +63,9 @@ type runtimeProfileOverride struct { var majorVersionPattern = regexp.MustCompile(`\d+`) var majorMinorPattern = regexp.MustCompile(`\d+\.\d+`) -var magentoVersionPattern = regexp.MustCompile(`\d+\.\d+\.\d+(?:-p\d+)?`) func ResolveRuntimeProfile(framework string, version string) (RuntimeProfileResult, error) { - framework = strings.TrimSpace(strings.ToLower(framework)) - if framework == "magento" { - framework = "magento2" - } + framework = NormalizeFrameworkAlias(framework) version = strings.TrimSpace(version) if framework == "" { return RuntimeProfileResult{}, fmt.Errorf("framework is required") @@ -115,8 +117,8 @@ func ResolveRuntimeProfile(framework string, version string) (RuntimeProfileResu return result, nil } - if framework == "magento2" { - override, source, ok := resolveMagento2Override(version) + if resolver, ok := GetVersionProfileResolver(framework); ok { + override, source, ok := resolver(version) if !ok { result.Warnings = append(result.Warnings, fmt.Sprintf("No version-specific profile for %s version %q. Using framework defaults.", framework, version)) return result, nil @@ -150,7 +152,7 @@ func ExtractMajorVersion(version string) (int, bool) { return major, true } -func applyRuntimeProfileOverride(profile *RuntimeProfile, override runtimeProfileOverride) { +func applyRuntimeProfileOverride(profile *RuntimeProfile, override VersionProfileOverride) { if profile == nil { return } @@ -237,54 +239,29 @@ func normalizeProfileValue(raw string, fallback string) string { return value } -func resolveMagento2Override(version string) (runtimeProfileOverride, string, bool) { - major, minor, patch, pPatch, ok := parseMagentoVersion(version) - if !ok { - return runtimeProfileOverride{}, "", false - } - - return resolveMagentoProfileFromRegistry(major, minor, patch, pPatch) -} +// VersionProfileResolver resolves version-specific runtime-profile +// overrides for one framework. Returns ok=false if version has no +// version-specific profile (caller falls back to framework defaults). +// Framework packages register this when they own patch-level compatibility +// data that cannot be expressed by the generic major/minor profile registry. +type VersionProfileResolver func(version string) (VersionProfileOverride, string, bool) -func parseMagentoVersion(version string) (major int, minor int, patch int, pPatch int, ok bool) { - version = strings.TrimSpace(strings.TrimPrefix(version, "v")) - match := magentoVersionPattern.FindString(version) - if match != "" { - version = match - } - if version == "" { - return 0, 0, 0, 0, false - } - - parts := strings.SplitN(version, "-p", 2) - core := parts[0] - coreParts := strings.Split(core, ".") - if len(coreParts) < 3 { - return 0, 0, 0, 0, false - } +var versionProfileResolvers = map[string]VersionProfileResolver{} - major, err := strconv.Atoi(coreParts[0]) - if err != nil { - return 0, 0, 0, 0, false - } - minor, err = strconv.Atoi(coreParts[1]) - if err != nil { - return 0, 0, 0, 0, false - } - patch, err = strconv.Atoi(coreParts[2]) - if err != nil { - return 0, 0, 0, 0, false - } - - pPatch = 0 - if len(parts) == 2 && parts[1] != "" { - pPatch, err = strconv.Atoi(parts[1]) - if err != nil { - return 0, 0, 0, 0, false - } - } +// RegisterVersionProfileResolver registers resolver as the +// version-specific profile resolver for framework. +// Called from frameworks.Register. Not safe for concurrent calls; +// intended usage is registration during package init(), before +// ResolveRuntimeProfile is ever called. Frameworks without one fall through +// to the generic JSON-driven major/minor profile registry. +func RegisterVersionProfileResolver(framework string, resolver VersionProfileResolver) { + versionProfileResolvers[strings.ToLower(strings.TrimSpace(framework))] = resolver +} - return major, minor, patch, pPatch, true +// GetVersionProfileResolver looks up the registered resolver for framework. +func GetVersionProfileResolver(framework string) (VersionProfileResolver, bool) { + resolver, ok := versionProfileResolvers[strings.ToLower(strings.TrimSpace(framework))] + return resolver, ok } func parseMajorMinor(version string) (major int, minor int, ok bool) { diff --git a/internal/engine/profile_registry.go b/internal/engine/profile_registry.go index 0ee60afb..806a1017 100644 --- a/internal/engine/profile_registry.go +++ b/internal/engine/profile_registry.go @@ -21,53 +21,6 @@ type RuntimeProfileFixture struct { ExpectError bool `json:"expect_error"` } -type profileStack struct { - SearchVersion string `json:"search_version"` - VarnishVersion string `json:"varnish_version"` - NginxVersion string `json:"nginx_version"` - QueueVersion string `json:"queue_version"` - CacheVersion string `json:"cache_version"` -} - -type profileRule struct { - Min int `json:"min"` - Stack string `json:"stack"` - PHPVersion string `json:"php_version"` - DBVersion string `json:"db_version"` - Cache string `json:"cache,omitempty"` - Search string `json:"search,omitempty"` - SearchVersion string `json:"search_version,omitempty"` - VarnishVersion string `json:"varnish_version"` - NginxVersion string `json:"nginx_version"` - QueueVersion string `json:"queue_version"` - CacheVersion string `json:"cache_version"` - ComposerVersion string `json:"composer_version,omitempty"` -} - -type patchVariant struct { - Patch *int `json:"patch,omitempty"` - PatchMin *int `json:"patch_min,omitempty"` - PatchMax *int `json:"patch_max,omitempty"` - PHPVersion string `json:"php_version"` - DBVersion string `json:"db_version"` - Cache string `json:"cache,omitempty"` - CacheVersion string `json:"cache_version,omitempty"` - Search string `json:"search,omitempty"` - SearchVersion string `json:"search_version,omitempty"` - QueueVersion string `json:"queue_version"` - VarnishVersion string `json:"varnish_version"` - NginxVersion string `json:"nginx_version"` - ComposerVersion string `json:"composer_version,omitempty"` - Rules []profileRule `json:"rules"` -} - -type versionGroup struct { - Major int `json:"major"` - Minor int `json:"minor"` - Defaults map[string]string `json:"defaults"` - Patches []patchVariant `json:"patches"` -} - type frameworkRule struct { Major int `json:"major"` MinorMin *int `json:"minor_min,omitempty"` @@ -78,10 +31,6 @@ type frameworkRule struct { } type profileRegistryData struct { - Magento struct { - Stacks map[string]profileStack `json:"stacks"` - Versions []versionGroup `json:"versions"` - } `json:"magento2"` Frameworks map[string][]frameworkRule `json:"frameworks"` TestFixtures []RuntimeProfileFixture `json:"test_fixtures"` } @@ -100,10 +49,10 @@ func GetFrameworkTestFixtures() []RuntimeProfileFixture { } // resolveFrameworkProfileFromRegistry looks up the technology stack for other frameworks. -func resolveFrameworkProfileFromRegistry(framework string, major int, minor int) (runtimeProfileOverride, string, bool) { +func resolveFrameworkProfileFromRegistry(framework string, major int, minor int) (VersionProfileOverride, string, bool) { rules, ok := registry.Frameworks[framework] if !ok { - return runtimeProfileOverride{}, "", false + return VersionProfileOverride{}, "", false } for _, rule := range rules { @@ -115,7 +64,7 @@ func resolveFrameworkProfileFromRegistry(framework string, major int, minor int) continue } - override := runtimeProfileOverride{ + override := VersionProfileOverride{ PHPVersion: rule.PHPVersion, DB: rule.DB, DBVersion: rule.DBVersion, @@ -132,142 +81,5 @@ func resolveFrameworkProfileFromRegistry(framework string, major int, minor int) return override, source, true } - return runtimeProfileOverride{}, "", false -} - -// resolveMagentoProfileFromRegistry looks up the technology stack for a given Magento version using the JSON registry. -func resolveMagentoProfileFromRegistry(major, minor, patch, pPatch int) (runtimeProfileOverride, string, bool) { - for _, group := range registry.Magento.Versions { - if group.Major != major || group.Minor != minor { - continue - } - - for _, v := range group.Patches { - if v.Patch != nil && *v.Patch != patch { - continue - } - if v.PatchMin != nil && patch < *v.PatchMin { - continue - } - if v.PatchMax != nil && patch > *v.PatchMax { - continue - } - - // Found the specific patch group (e.g., 2.4.7) - override := runtimeProfileOverride{ - DB: group.Defaults["db"], - Cache: group.Defaults["cache"], - Search: group.Defaults["search"], - Queue: group.Defaults["queue"], - WebRoot: group.Defaults["web_root"], - PHPVersion: v.PHPVersion, - DBVersion: v.DBVersion, - } - - // Apply baseline patch versions - applyPatchBaselines(&override, v) - - // Resolve rule (find the highest min pPatch that matches) - var activeRule *profileRule - for i := range v.Rules { - if pPatch >= v.Rules[i].Min { - activeRule = &v.Rules[i] - break - } - } - - if activeRule != nil { - // Apply stack if defined - if activeRule.Stack != "" { - if stack, ok := registry.Magento.Stacks[activeRule.Stack]; ok { - applyStackToOverride(&override, stack) - } - } - // Apply rule-specific overrides - applyRuleOverrides(&override, *activeRule) - } - - return override, fmt.Sprintf("version-specific:magento2@%d.%d.%d-p%d", major, minor, patch, pPatch), true - } - } - - return runtimeProfileOverride{}, "", false -} - -func applyPatchBaselines(o *runtimeProfileOverride, v patchVariant) { - if v.Cache != "" { - o.Cache = v.Cache - } - if v.Search != "" { - o.Search = v.Search - } - if v.CacheVersion != "" { - o.CacheVersion = v.CacheVersion - } - if v.SearchVersion != "" { - o.SearchVersion = v.SearchVersion - } - if v.QueueVersion != "" { - o.QueueVersion = v.QueueVersion - } - if v.VarnishVersion != "" { - o.VarnishVersion = v.VarnishVersion - } - if v.NginxVersion != "" { - o.NginxVersion = v.NginxVersion - } - if v.ComposerVersion != "" { - o.ComposerVersion = v.ComposerVersion - } -} - -func applyStackToOverride(o *runtimeProfileOverride, stack profileStack) { - if stack.SearchVersion != "" { - o.SearchVersion = stack.SearchVersion - } - if stack.VarnishVersion != "" { - o.VarnishVersion = stack.VarnishVersion - } - if stack.NginxVersion != "" { - o.NginxVersion = stack.NginxVersion - } - if stack.QueueVersion != "" { - o.QueueVersion = stack.QueueVersion - } - if stack.CacheVersion != "" { - o.CacheVersion = stack.CacheVersion - } -} - -func applyRuleOverrides(o *runtimeProfileOverride, rule profileRule) { - if rule.PHPVersion != "" { - o.PHPVersion = rule.PHPVersion - } - if rule.DBVersion != "" { - o.DBVersion = rule.DBVersion - } - if rule.Cache != "" { - o.Cache = rule.Cache - } - if rule.Search != "" { - o.Search = rule.Search - } - if rule.SearchVersion != "" { - o.SearchVersion = rule.SearchVersion - } - if rule.ComposerVersion != "" { - o.ComposerVersion = rule.ComposerVersion - } - if rule.VarnishVersion != "" { - o.VarnishVersion = rule.VarnishVersion - } - if rule.NginxVersion != "" { - o.NginxVersion = rule.NginxVersion - } - if rule.QueueVersion != "" { - o.QueueVersion = rule.QueueVersion - } - if rule.CacheVersion != "" { - o.CacheVersion = rule.CacheVersion - } + return VersionProfileOverride{}, "", false } diff --git a/internal/engine/profile_shift.go b/internal/engine/profile_shift.go index 814ee2b2..61fa58f7 100644 --- a/internal/engine/profile_shift.go +++ b/internal/engine/profile_shift.go @@ -168,7 +168,7 @@ func ResolveEffectiveProfile(projectPath, explicitProfile string) string { return "" } -func checkProfileShiftCleanup(config Config) (bool, string) { +func CheckProfileShiftCleanup(config Config) (bool, string) { cwd, err := os.Getwd() if err != nil { return false, "" diff --git a/internal/engine/profiles.json b/internal/engine/profiles.json index 82b36b88..8f39cede 100644 --- a/internal/engine/profiles.json +++ b/internal/engine/profiles.json @@ -1,408 +1,4 @@ { - "magento2": { - "stacks": { - "ultra": { - "search_version": "2.19", - "varnish_version": "7.7", - "nginx_version": "1.28", - "queue_version": "3.13", - "cache_version": "7.2" - }, - "very_modern": { - "search_version": "2.19", - "varnish_version": "7.6", - "nginx_version": "1.26", - "queue_version": "3.13", - "cache_version": "7.2" - }, - "modern": { - "search_version": "2.12", - "varnish_version": "7.5", - "nginx_version": "1.24", - "queue_version": "3.12", - "cache_version": "7.2" - } - }, - "versions": [ - { - "major": 2, - "minor": 4, - "defaults": { - "db": "mariadb", - "cache": "redis", - "search": "opensearch", - "queue": "rabbitmq", - "web_root": "/pub" - }, - "patches": [ - { - "patch": 9, - "min_p_patch": 0, - "php_version": "8.5", - "db_version": "11.8", - "cache": "valkey", - "cache_version": "9.0", - "search_version": "3.0", - "varnish_version": "8.0", - "queue_version": "4.2", - "nginx_version": "1.28" - }, - { - "patch": 8, - "php_version": "8.4", - "db_version": "11.4", - "cache_version": "7.2", - "rules": [ - { - "min": 2, - "stack": "ultra", - "search_version": "3.0" - }, - { - "min": 0, - "stack": "very_modern" - } - ] - }, - { - "patch": 7, - "php_version": "8.3", - "db_version": "10.6", - "rules": [ - { - "min": 6, - "stack": "ultra", - "db_version": "10.11" - }, - { - "min": 5, - "stack": "very_modern" - }, - { - "min": 0, - "stack": "modern" - } - ] - }, - { - "patch": 6, - "php_version": "8.2", - "db_version": "10.6", - "cache_version": "7.0", - "search_version": "2.5", - "queue_version": "3.11", - "varnish_version": "7.1", - "nginx_version": "1.22", - "composer_version": "2.2", - "rules": [ - { - "min": 11, - "stack": "ultra", - "db_version": "10.11" - }, - { - "min": 10, - "stack": "very_modern" - }, - { - "min": 8, - "stack": "modern" - }, - { - "min": 5, - "stack": "modern", - "varnish_version": "7.1", - "cache_version": "7.0" - } - ] - }, - { - "patch": 5, - "php_version": "8.1", - "db_version": "10.4", - "cache_version": "6.2", - "search_version": "1.2", - "queue_version": "3.11", - "varnish_version": "7.0", - "nginx_version": "1.22", - "composer_version": "2.2", - "rules": [ - { - "min": 13, - "stack": "ultra" - }, - { - "min": 12, - "stack": "ultra", - "cache_version": "6.2" - }, - { - "min": 10, - "stack": "very_modern", - "search_version": "1.3" - }, - { - "min": 8, - "stack": "modern", - "search_version": "1.3", - "queue_version": "3.13" - }, - { - "min": 7, - "stack": "modern", - "search_version": "1.3", - "queue_version": "3.13", - "varnish_version": "7.0", - "cache_version": "7.0" - } - ] - }, - { - "patch": 4, - "php_version": "8.1", - "db_version": "10.4", - "cache_version": "6.2", - "search_version": "1.2", - "queue_version": "3.9", - "varnish_version": "7.0", - "nginx_version": "1.20", - "composer_version": "2.2", - "rules": [ - { - "min": 16, - "stack": "ultra" - }, - { - "min": 13, - "stack": "very_modern" - }, - { - "min": 11, - "stack": "modern", - "search_version": "1.3", - "queue_version": "3.13", - "cache_version": "7.2" - }, - { - "min": 8, - "stack": "modern", - "search_version": "1.3", - "queue_version": "3.13", - "cache_version": "7.0" - } - ] - }, - { - "patch": 3, - "php_version": "7.4", - "db_version": "10.4", - "nginx_version": "1.18", - "varnish_version": "6.0", - "queue_version": "3.8", - "composer_version": "2.2", - "rules": [ - { - "min": 2, - "search": "opensearch", - "search_version": "1.2", - "cache_version": "6.2" - }, - { - "min": 0, - "search": "elasticsearch", - "search_version": "7.10", - "cache_version": "6.0" - } - ] - }, - { - "patch": 2, - "php_version": "7.4", - "db_version": "10.4", - "cache": "redis", - "cache_version": "6.0", - "search": "elasticsearch", - "search_version": "7.9", - "queue_version": "3.8", - "varnish_version": "6.0", - "nginx_version": "1.18", - "composer_version": "2.2" - }, - { - "patch": 1, - "php_version": "7.4", - "db_version": "10.4", - "cache": "redis", - "cache_version": "6.0", - "search": "elasticsearch", - "search_version": "7.9", - "queue_version": "3.8", - "varnish_version": "6.0", - "nginx_version": "1.18", - "composer_version": "2.2" - }, - { - "patch": 0, - "php_version": "7.4", - "db_version": "10.4", - "cache": "redis", - "cache_version": "5.0", - "search": "elasticsearch", - "search_version": "7.6", - "queue_version": "3.8", - "varnish_version": "6.0", - "nginx_version": "1.18", - "composer_version": "2.2" - } - ] - }, - { - "major": 2, - "minor": 3, - "defaults": { - "db": "mariadb", - "cache": "redis", - "search": "elasticsearch", - "queue": "rabbitmq", - "web_root": "/" - }, - "patches": [ - { - "patch": 0, - "php_version": "7.1", - "db_version": "10.1", - "cache_version": "5.0", - "search_version": "5.6", - "queue_version": "3.7", - "varnish_version": "6.0", - "nginx_version": "1.18" - }, - { - "patch_min": 1, - "patch_max": 2, - "php_version": "7.2", - "db_version": "10.2", - "cache_version": "5.0", - "search_version": "6.8", - "queue_version": "3.7", - "varnish_version": "6.0", - "nginx_version": "1.18" - }, - { - "patch_min": 3, - "patch_max": 4, - "php_version": "7.2", - "db_version": "10.2", - "cache_version": "5.0", - "search_version": "6.8", - "queue_version": "3.8", - "varnish_version": "6.0", - "nginx_version": "1.18" - }, - { - "patch_min": 5, - "patch_max": 6, - "php_version": "7.3", - "db_version": "10.4", - "cache_version": "5.0", - "search_version": "7.6", - "queue_version": "3.8", - "varnish_version": "6.0", - "nginx_version": "1.18" - }, - { - "patch_min": 7, - "php_version": "7.4", - "db_version": "10.4", - "cache_version": "5.0", - "search_version": "7.9", - "queue_version": "3.8", - "varnish_version": "6.0", - "nginx_version": "1.18" - } - ] - }, - { - "major": 2, - "minor": 2, - "defaults": { - "db": "mariadb", - "cache": "redis", - "search": "elasticsearch", - "queue": "rabbitmq", - "web_root": "/" - }, - "patches": [ - { - "patch": 0, - "php_version": "7.1", - "db_version": "10.0", - "cache_version": "5.0", - "search_version": "5.6", - "queue_version": "3.7", - "varnish_version": "6.0", - "nginx_version": "1.18" - }, - { - "patch_min": 1, - "php_version": "7.1", - "db_version": "10.1", - "cache_version": "5.0", - "search_version": "5.6", - "queue_version": "3.7", - "varnish_version": "6.0", - "nginx_version": "1.18" - } - ] - }, - { - "major": 2, - "minor": 1, - "defaults": { - "db": "mariadb", - "cache": "redis", - "search": "elasticsearch", - "queue": "rabbitmq", - "web_root": "/" - }, - "patches": [ - { - "patch_min": 0, - "php_version": "7.1", - "db_version": "10.0", - "cache_version": "5.0", - "search_version": "2.4", - "queue_version": "3.7", - "varnish_version": "6.0", - "nginx_version": "1.18" - } - ] - }, - { - "major": 2, - "minor": 0, - "defaults": { - "db": "mariadb", - "cache": "redis", - "search": "elasticsearch", - "queue": "rabbitmq", - "web_root": "/" - }, - "patches": [ - { - "patch_min": 0, - "php_version": "7.1", - "db_version": "10.0", - "cache_version": "5.0", - "search_version": "2.4", - "queue_version": "3.7", - "varnish_version": "6.0", - "nginx_version": "1.18" - } - ] - } - ] - }, "frameworks": { "laravel": [ { @@ -598,87 +194,6 @@ ] }, "test_fixtures": [ - { - "name": "Magento 2.1.18 legacy mapping", - "framework": "magento2", - "version": "2.1.18", - "source_prefix": "version-specific:magento2@2.1.18", - "expected": { - "php_version": "7.1", - "db_version": "10.0", - "cache": "redis", - "cache_version": "5.0", - "search": "elasticsearch", - "search_version": "2.4", - "queue_version": "3.7" - } - }, - { - "name": "Magento 2.3.7 legacy mapping", - "framework": "magento2", - "version": "2.3.7", - "source_prefix": "version-specific:magento2@2.3.7", - "expected": { - "php_version": "7.4", - "db_version": "10.4", - "cache": "redis", - "cache_version": "5.0", - "search": "elasticsearch", - "search_version": "7.9", - "queue_version": "3.8" - } - }, - { - "name": "Magento 2.4.6-p13 maps to PHP 8.2 and MariaDB 10.11", - "framework": "magento2", - "version": "2.4.6-p13", - "source_prefix": "version-specific:magento2@2.4.6", - "expected": { - "php_version": "8.2", - "db_version": "10.11", - "search_version": "2.19", - "queue_version": "3.13" - } - }, - { - "name": "Magento 2 uses search and cache profile", - "framework": "magento2", - "version": "2.4.7", - "source_prefix": "version-specific:magento2@2.4.7", - "expected": { - "php_version": "8.3", - "db_version": "10.6", - "cache": "redis", - "search": "opensearch", - "search_version": "2.12", - "queue": "rabbitmq" - } - }, - { - "name": "Magento version constraints are parsed", - "framework": "magento2", - "version": "^2.4.7-p3", - "source_prefix": "version-specific:magento2@2.4.7", - "expected": { - "php_version": "8.3", - "db_version": "10.6", - "cache": "redis", - "search_version": "2.12", - "queue_version": "3.12" - } - }, - { - "name": "Magento 2.4.8-p3 prefers PHP 8.4 and OpenSearch 3", - "framework": "magento2", - "version": "2.4.8-p3", - "source_prefix": "version-specific:magento2@2.4.8", - "expected": { - "php_version": "8.4", - "db_version": "11.4", - "search_version": "3.0", - "queue_version": "3.13" - } - }, { "name": "Drupal 11 uses PHP 8.4", "framework": "drupal", @@ -820,4 +335,4 @@ } } ] -} \ No newline at end of file +} diff --git a/internal/engine/proxy.go b/internal/engine/proxy.go index 052166df..93eed8da 100644 --- a/internal/engine/proxy.go +++ b/internal/engine/proxy.go @@ -230,16 +230,7 @@ $projectsJson = @file_get_contents('/govard-registry/projects.json'); $activeProjectsJson = @file_get_contents('/govard-registry/active-projects.json'); $dbMap = [ - 'magento1' => 'magento', - 'magento2' => 'magento', - 'laravel' => 'laravel', - 'symfony' => 'symfony', - 'shopware' => 'shopware', - 'wordpress' => 'wordpress', - 'drupal' => 'drupal', - 'cakephp' => 'cakephp', - 'openmage' => 'openmage', - 'prestashop' => 'prestashop' +` + dbDriverCategoryPHPArray() + ` ]; $activeProjects = []; @@ -326,6 +317,27 @@ if ($selectedProject !== '' && isset($projectToServer[$selectedProject])) { ` } +// dbDriverCategoryPHPArray renders the registered DB-driver categories as +// PHP array literal entries (sorted by framework name for determinism), +// e.g. " 'magento1' => 'magento',\n 'magento2' => 'magento'". +func dbDriverCategoryPHPArray() string { + names := make([]string, 0, len(dbDriverCategoriesByFramework)) + for name := range dbDriverCategoriesByFramework { + names = append(names, name) + } + sort.Strings(names) + lines := make([]string, 0, len(names)) + for _, name := range names { + lines = append(lines, fmt.Sprintf(" %s => %s", + phpSingleQuote(name), phpSingleQuote(dbDriverCategoriesByFramework[name]))) + } + return strings.Join(lines, ",\n") +} + +func phpSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "\\'") + "'" +} + func ActiveProjectNamesFromContainersForTest(containers []container.Summary) []string { return activeProjectNamesFromContainers(containers) } diff --git a/internal/engine/remote/magento_shared.go b/internal/engine/remote/magento_shared.go new file mode 100644 index 00000000..40895dbf --- /dev/null +++ b/internal/engine/remote/magento_shared.go @@ -0,0 +1,71 @@ +package remote + +import ( + "net" + "strconv" + "strings" + + "govard/internal/conventions" +) + +// RemoteDatabaseMetadata holds database connection details extracted from a +// remote probe. It is a transport DTO: framework packages determine how to +// extract it while generic orchestration consumes only its common fields. +type RemoteDatabaseMetadata struct { + Host string + Port int + Username string + Password string + Database string + TablePrefix string + // Private is framework-owned opaque metadata carried alongside the generic + // connection details. Core transport forwards it without interpreting it. + Private map[string]string +} + +// ParseDatabaseHostPort splits a raw host[:port] string into separate host and +// port values, applying conventions.DefaultDBHost/MySQLPort as fallbacks. +func ParseDatabaseHostPort(raw string) (string, int) { + hostRaw := strings.TrimSpace(raw) + if hostRaw == "" { + return conventions.DefaultDBHost, conventions.MySQLPort + } + + hostRaw = strings.TrimPrefix(hostRaw, "tcp://") + if hostRaw == "" { + return conventions.DefaultDBHost, conventions.MySQLPort + } + + if host, port, err := net.SplitHostPort(hostRaw); err == nil { + if parsed, parseErr := strconv.Atoi(port); parseErr == nil && parsed > 0 { + if strings.TrimSpace(host) == "" { + host = conventions.DefaultDBHost + } + return host, parsed + } + } + + if strings.Count(hostRaw, ":") == 1 { + parts := strings.SplitN(hostRaw, ":", 2) + portText := strings.TrimSpace(parts[1]) + if parsed, err := strconv.Atoi(portText); err == nil && parsed > 0 { + host := strings.TrimSpace(parts[0]) + if host == "" { + host = conventions.DefaultDBHost + } + return host, parsed + } + } + + return hostRaw, conventions.MySQLPort +} + +// BuildProjectRemoteCommand prefixes body with a `cd &&` if +// projectPath is non-empty, so a probe runs from the project's root directory. +func BuildProjectRemoteCommand(projectPath string, body string) string { + trimmedPath := strings.TrimSpace(projectPath) + if trimmedPath == "" { + return body + } + return "cd " + QuoteRemotePath(trimmedPath) + " && " + body +} diff --git a/internal/engine/remote/runner.go b/internal/engine/remote/runner.go index 479522ac..98463a10 100644 --- a/internal/engine/remote/runner.go +++ b/internal/engine/remote/runner.go @@ -23,7 +23,7 @@ func BuildSSHInteractiveArgs(remoteName string, remoteCfg engine.RemoteConfig, f return BuildSSHArgs(remoteName, remoteCfg, forwardAgent, true) } -func runRemoteCapture(remoteName string, remoteCfg engine.RemoteConfig, remoteCommand string) (string, error) { +func RunRemoteCapture(remoteName string, remoteCfg engine.RemoteConfig, remoteCommand string) (string, error) { cmd := BuildSSHExecCommand(remoteName, remoteCfg, true, remoteCommand) output, err := cmd.CombinedOutput() if err != nil { diff --git a/internal/engine/remote/snapshot.go b/internal/engine/remote/snapshot.go index 1c98d5cf..a5938495 100644 --- a/internal/engine/remote/snapshot.go +++ b/internal/engine/remote/snapshot.go @@ -215,7 +215,7 @@ func BuildRemoteSnapshotDeleteCommandForTest(remoteCfg engine.RemoteConfig, name func BuildRemoteSnapshotRestoreCommandForTest(remoteCfg engine.RemoteConfig, name string, framework string, dbOnly bool, mediaOnly bool) string { _, mediaPath := engine.ResolveRemotePathsForConfig(framework, remoteCfg) - dbImport := "mysql -u magento magento" + dbImport := "mysql -u app app" return BuildRemoteSnapshotRestoreCommand(remoteCfg, name, framework, dbImport, mediaPath, dbOnly, mediaOnly) } diff --git a/internal/engine/render.go b/internal/engine/render.go index d7e8a30b..c0b1c2ff 100644 --- a/internal/engine/render.go +++ b/internal/engine/render.go @@ -21,11 +21,29 @@ import ( "gopkg.in/yaml.v3" ) +// registeredTemplateFuncs accumulates every framework's TemplateFuncs +// contribution, merged into the FuncMap available to every rendered +// blueprint template. Populated via RegisterTemplateFunc, called from +// frameworks.Register. +var registeredTemplateFuncs = template.FuncMap{} + +// RegisterTemplateFunc registers fn under name in the FuncMap available to +// every rendered blueprint template (see renderTemplateFuncMap). Called +// from frameworks.Register so a framework package can contribute its own +// template functions (e.g. emdash's/nextjs's runtime-command builders) +// instead of a literal entry in this file's static FuncMap. Not safe for +// concurrent calls; intended usage is registration during package init(), +// before any blueprint is ever rendered. +func RegisterTemplateFunc(name string, fn any) { + registeredTemplateFuncs[name] = fn +} + func renderTemplateFuncMap() template.FuncMap { - return template.FuncMap{ - "emdashRuntimeCommand": buildEmdashRuntimeCommand, - "nextjsRuntimeCommand": buildNextJSRuntimeCommand, + merged := template.FuncMap{} + for k, v := range registeredTemplateFuncs { + merged[k] = v } + return merged } // RenderData holds all data needed for template rendering @@ -78,15 +96,20 @@ func findBlueprintsDir(startDir string) (string, error) { curr := abs for { - // Check both legacy root path and new internal path - candidates := []string{ - filepath.Join(curr, "blueprints"), - filepath.Join(curr, "internal", "blueprints", "files"), - } - for _, target := range candidates { - if _, err := os.Stat(target); err == nil { - return target, nil - } + // Legacy root path only: a full standalone blueprints/ directory next + // to the project root (e.g. an extracted release layout). This does + // NOT include internal/blueprints/files - since the framework + // consolidation refactor that directory holds only the shared + // remainder (includes/, proxy.yml, generic support/ assets) and is no + // longer a complete blueprints tree on its own; the complete tree + // lives solely in the compiled-in blueprints.FS (fallback plus every + // framework package's registered mount). Treating the on-disk + // internal/blueprints/files as a drop-in substitute here would + // silently serve an incomplete tree to any process running from + // within this repo checkout. + target := filepath.Join(curr, "blueprints") + if _, err := os.Stat(target); err == nil { + return target, nil } parent := filepath.Dir(curr) @@ -130,10 +153,30 @@ func findBlueprintsFS(startDir string) (fs.FS, error) { if err == nil { return os.DirFS(dir), nil } + if checkoutRoot, ok := findSourceCheckoutRoot(startDir); ok { + return blueprints.WithSourceOverrides(blueprints.FS, checkoutRoot), nil + } return blueprints.FS, nil } +func findSourceCheckoutRoot(startDir string) (string, bool) { + abs, err := filepath.Abs(startDir) + if err != nil { + return "", false + } + for current := abs; ; current = filepath.Dir(current) { + candidate := filepath.Join(current, "internal", "blueprints", "files") + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return current, true + } + parent := filepath.Dir(current) + if parent == current { + return "", false + } + } +} + func blueprintsFingerprint(blueprintsFS fs.FS) (string, error) { hasher := sha256.New() @@ -343,8 +386,8 @@ func RenderBlueprint(root string, config Config) error { // reuses magento2's, since Mage-OS is a drop-in fork with the same runtime // shape and no Varnish-relevant differences. func varnishTemplateFramework(framework string) string { - if framework == "mageos" { - return "magento2" + if inherited := VarnishTemplateFrameworkForFramework(framework); inherited != "" { + return inherited } return framework } @@ -445,8 +488,8 @@ func RenderBlueprintWithProfile(root string, config Config, profile string) erro renderData.ApacheCustomConfigDir = filepath.Join(root, ProjectApacheCustomDir) } - if nginxMapPath, apacheMapPath, err := prepareMagentoRunMappingAssets(config); err != nil { - return fmt.Errorf("failed to prepare Magento run mapping assets: %w", err) + if nginxMapPath, apacheMapPath, err := PrepareRunMappingAssets(config); err != nil { + return fmt.Errorf("failed to prepare framework run mapping assets: %w", err) } else { renderData.NginxMageRunMapPath = nginxMapPath renderData.ApacheMageRunMapPath = apacheMapPath @@ -647,34 +690,6 @@ func renderTemplateFS(bfs fs.FS, tmplPath string, data RenderData) (string, erro return buf.String(), nil } -func buildEmdashRuntimeCommand(packageManager string, domain string) string { - domain = strings.TrimSpace(domain) - - if packageManager == "pnpm" { - return strings.Join([]string{ - "corepack enable >/dev/null 2>&1 || true;", - "if ! command -v pnpm >/dev/null 2>&1; then corepack prepare pnpm@latest --activate >/dev/null 2>&1; fi;", - `if [ ! -d node_modules ] || [ -z "$$(ls -A node_modules 2>/dev/null)" ]; then pnpm install; fi;`, - fmt.Sprintf("exec pnpm dev --host 0.0.0.0 --port 80 --allowed-hosts %s;", domain), - }, " ") - } - - return strings.Join([]string{ - `if [ ! -d node_modules ] || [ -z "$$(ls -A node_modules 2>/dev/null)" ]; then npm install; fi;`, - fmt.Sprintf("exec npm run dev -- --host 0.0.0.0 --port 80 --allowed-hosts %s;", domain), - }, " ") -} - -// buildNextJSRuntimeCommand installs dependencies if node_modules is -// missing (e.g. wiped independently of a fresh bootstrap) before running -// the dev server, matching emdashRuntimeCommand's resilience. -func buildNextJSRuntimeCommand() string { - return strings.Join([]string{ - `if [ ! -d node_modules ] || [ -z "$$(ls -A node_modules 2>/dev/null)" ]; then npm install; fi;`, - `exec npm run dev -- --hostname 0.0.0.0 --port 80;`, - }, " ") -} - func buildXdebugSessionPattern(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/engine/run_mapping_assets.go b/internal/engine/run_mapping_assets.go new file mode 100644 index 00000000..be04eada --- /dev/null +++ b/internal/engine/run_mapping_assets.go @@ -0,0 +1,34 @@ +package engine + +import "strings" + +// RunMappingAssetPreparer prepares any framework-specific "run mapping" +// assets needed before rendering a blueprint (e.g. Magento's per-store +// nginx/apache map files), returning the nginx and apache asset paths +// ("" if this framework needs none). +type RunMappingAssetPreparer func(config Config) (nginxMapPath string, apacheMapPath string, err error) + +var runMappingAssetPreparers = map[string]RunMappingAssetPreparer{} + +// RegisterRunMappingAssetPreparer registers fn as the run-mapping-asset +// preparer for framework. Called from frameworks.Register so a framework +// package can own this instead of a hardcoded isMagentoFramework-style gate +// in this file. Not safe for concurrent calls; intended usage is +// registration during package init(), before PrepareRunMappingAssets is +// ever called. Not every framework needs an entry - only Magento-family +// frameworks register one. +func RegisterRunMappingAssetPreparer(framework string, fn RunMappingAssetPreparer) { + runMappingAssetPreparers[strings.ToLower(strings.TrimSpace(framework))] = fn +} + +// PrepareRunMappingAssets looks up and invokes the registered run-mapping- +// asset preparer for config.Framework, or is a no-op (empty paths, nil +// error) if none is registered - replacing the former isMagentoFramework +// gate in internal/engine/magento.go with registry-driven dispatch. +func PrepareRunMappingAssets(config Config) (string, string, error) { + fn, ok := runMappingAssetPreparers[strings.ToLower(strings.TrimSpace(config.Framework))] + if !ok { + return "", "", nil + } + return fn(config) +} diff --git a/internal/engine/runtime_images.go b/internal/engine/runtime_images.go index 085befd4..b089eb6c 100644 --- a/internal/engine/runtime_images.go +++ b/internal/engine/runtime_images.go @@ -27,7 +27,7 @@ func RequiredRuntimeImages(config Config, root string) []string { } if FrameworkUsesNodeRuntime(config.Framework) { - if config.Framework == "emdash" || config.Framework == "nextjs" { + if NodeImageFlavorForFramework(config.Framework) == "standard" { push(fmt.Sprintf("node:%s", config.Stack.NodeVersion)) } else { push(fmt.Sprintf("node:%s-alpine", config.Stack.NodeVersion)) @@ -44,12 +44,9 @@ func RequiredRuntimeImages(config Config, root string) []string { default: push(fmt.Sprintf("%snginx:%s", imageRepo, config.Stack.NginxVersion)) } - switch config.Framework { - case "magento2", "mageos": - push(fmt.Sprintf("%sphp-magento2:%s", imageRepo, config.Stack.PHPVersion)) - case "magento1", "openmage": - push(fmt.Sprintf("%sphp-magento1:%s", imageRepo, config.Stack.PHPVersion)) - default: + if variant := PHPImageVariantForFramework(config.Framework); variant != "" { + push(fmt.Sprintf("%sphp-%s:%s", imageRepo, variant, config.Stack.PHPVersion)) + } else { push(fmt.Sprintf("%sphp:%s", imageRepo, config.Stack.PHPVersion)) } } diff --git a/internal/engine/snapshot.go b/internal/engine/snapshot.go index 1af55465..161f70d8 100644 --- a/internal/engine/snapshot.go +++ b/internal/engine/snapshot.go @@ -281,9 +281,8 @@ func copyFileWithMode(src string, dst string, mode os.FileMode) (err error) { func resolveSnapshotDBCredentials(containerName string) snapshotDBCredentials { credentials := snapshotDBCredentials{ - Username: "magento", - Password: "magento", - Database: "magento", + Username: "root", + Password: "root", } inspectCommand := exec.Command("docker", "inspect", "-f", "{{range .Config.Env}}{{println .}}{{end}}", containerName) @@ -311,7 +310,12 @@ func buildSnapshotDumpCommand(containerName string, credentials snapshotDBCreden if strings.TrimSpace(credentials.Password) != "" { args = append(args, "-e", "MYSQL_PWD="+credentials.Password) } - args = append(args, containerName, "mysqldump", "-u", credentials.Username, credentials.Database) + args = append(args, containerName, "mysqldump", "-u", credentials.Username) + if strings.TrimSpace(credentials.Database) == "" { + args = append(args, "--all-databases") + } else { + args = append(args, credentials.Database) + } return exec.Command("docker", args...) } @@ -321,17 +325,17 @@ func buildSnapshotImportCommand(containerName string, credentials snapshotDBCred if strings.TrimSpace(credentials.Password) != "" { args = append(args, "-e", "MYSQL_PWD="+credentials.Password) } - args = append(args, containerName, "mysql", "-u", credentials.Username, credentials.Database) + args = append(args, containerName, "mysql", "-u", credentials.Username) + if strings.TrimSpace(credentials.Database) != "" { + args = append(args, credentials.Database) + } return exec.Command("docker", args...) } func normalizeSnapshotDBCredentials(credentials snapshotDBCredentials) snapshotDBCredentials { result := credentials if strings.TrimSpace(result.Username) == "" { - result.Username = "magento" - } - if strings.TrimSpace(result.Database) == "" { - result.Database = "magento" + result.Username = "root" } return result } diff --git a/internal/engine/store_domains.go b/internal/engine/store_domains.go index b2bfa46d..979fbdf8 100644 --- a/internal/engine/store_domains.go +++ b/internal/engine/store_domains.go @@ -74,7 +74,7 @@ func normalizeStoreDomainMappings(mappings StoreDomainMappings) StoreDomainMappi return normalized } -func sortedStoreDomainHosts(mappings StoreDomainMappings) []string { +func SortedStoreDomainHosts(mappings StoreDomainMappings) []string { hosts := make([]string, 0, len(mappings)) for host := range mappings { hosts = append(hosts, host) diff --git a/internal/engine/table_prefix.go b/internal/engine/table_prefix.go index d3cbdc73..94e1d656 100644 --- a/internal/engine/table_prefix.go +++ b/internal/engine/table_prefix.go @@ -1,17 +1,12 @@ package engine import ( - "encoding/xml" - "os" - "path/filepath" "regexp" "strings" ) var ( - tablePrefixPattern = regexp.MustCompile(`^[A-Za-z0-9_]*$`) - magentoEnvTablePrefixExpr = regexp.MustCompile(`(?i)['"]table_prefix['"]\s*=>\s*['"]([^'"]*)['"]`) - prestashopEnvTablePrefixExpr = regexp.MustCompile(`(?i)['"]database_prefix['"]\s*=>\s*['"]([^'"]*)['"]`) + tablePrefixPattern = regexp.MustCompile(`^[A-Za-z0-9_]*$`) ) func NormalizeTablePrefix(prefix string) string { @@ -30,72 +25,43 @@ func SafeTablePrefix(prefix string) string { return normalized } -func FrameworkSupportsTablePrefix(framework string) bool { - switch normalizeFrameworkManifestKey(framework) { - case "magento2", "magento1", "openmage", "prestashop", "mageos": - return true - default: - return false - } -} +// TablePrefixDetector inspects a project root and returns its configured +// database table prefix, or "" if none is set/detectable. Implementations +// live with the framework that owns the corresponding config format. +type TablePrefixDetector func(root string) string -func DetectMagentoTablePrefix(root string, framework string) string { - if !FrameworkSupportsTablePrefix(framework) { - return "" - } - switch normalizeFrameworkManifestKey(framework) { - case "magento2", "mageos": - return DetectMagento2TablePrefix(root) - case "magento1", "openmage": - return DetectMagento1TablePrefix(root) - case "prestashop": - return DetectPrestaShopTablePrefix(root) - default: - return "" - } -} +var tablePrefixDetectors = map[string]TablePrefixDetector{} -func DetectMagento2TablePrefix(root string) string { - data, err := os.ReadFile(filepath.Join(root, "app", "etc", "env.php")) - if err != nil { - return "" - } - matches := magentoEnvTablePrefixExpr.FindStringSubmatch(string(data)) - if len(matches) != 2 { - return "" - } - return NormalizeTablePrefix(matches[1]) +// RegisterTablePrefixDetector registers detector as the table-prefix +// detector for framework, keyed the same way FrameworkSupportsTablePrefix/ +// DetectFrameworkTablePrefix look it up (post normalizeFrameworkManifestKey +// aliasing). Called from frameworks.Register (alongside RegisterDetection/ +// RegisterFrameworkConfig/RegisterFrameworkManifest) so a framework package +// can own its own table-prefix detection instead of a case in this file's +// switch. Not safe for concurrent calls; intended usage is registration +// during package init(), before FrameworkSupportsTablePrefix/ +// DetectFrameworkTablePrefix are ever called. Not every framework needs an +// entry - only ones with table-prefix support register here. +func RegisterTablePrefixDetector(framework string, detector TablePrefixDetector) { + tablePrefixDetectors[normalizeFrameworkManifestKey(framework)] = detector } -func DetectMagento1TablePrefix(root string) string { - data, err := os.ReadFile(filepath.Join(root, "app", "etc", "local.xml")) - if err != nil { - return "" - } +// GetTablePrefixDetector looks up the registered table-prefix detector for +// framework (post normalizeFrameworkManifestKey aliasing). +func GetTablePrefixDetector(framework string) (TablePrefixDetector, bool) { + detector, ok := tablePrefixDetectors[normalizeFrameworkManifestKey(framework)] + return detector, ok +} - var localXML struct { - Global struct { - Resources struct { - DB struct { - TablePrefix string `xml:"table_prefix"` - } `xml:"db"` - } `xml:"resources"` - } `xml:"global"` - } - if err := xml.Unmarshal(data, &localXML); err != nil { - return "" - } - return NormalizeTablePrefix(localXML.Global.Resources.DB.TablePrefix) +func FrameworkSupportsTablePrefix(framework string) bool { + _, ok := GetTablePrefixDetector(framework) + return ok } -func DetectPrestaShopTablePrefix(root string) string { - data, err := os.ReadFile(filepath.Join(root, "app", "config", "parameters.php")) - if err != nil { - return "" - } - matches := prestashopEnvTablePrefixExpr.FindStringSubmatch(string(data)) - if len(matches) != 2 { +func DetectFrameworkTablePrefix(root string, framework string) string { + detector, ok := GetTablePrefixDetector(framework) + if !ok { return "" } - return NormalizeTablePrefix(matches[1]) + return detector(root) } diff --git a/internal/engine/upgrade.go b/internal/engine/upgrade.go index 9e9d3b75..673b7e26 100644 --- a/internal/engine/upgrade.go +++ b/internal/engine/upgrade.go @@ -20,26 +20,46 @@ type UpgradeOptions struct { ProjectName string } +// UpgradeFunc runs one framework's upgrade pipeline (dependency bump, +// migrations, cache flush, etc.) for a target version. Implementations +// live in each framework's own package (internal/frameworks//upgrade.go). +type UpgradeFunc func(ctx context.Context, config Config, opts UpgradeOptions) error + +var upgraders = map[string]UpgradeFunc{} + +// RegisterUpgrader registers fn as the upgrade pipeline for framework, +// keyed the same way UpgradeFramework looks it up ("magento" alias +// included explicitly - see GetUpgrader). Called from frameworks.Register +// so a framework package can own its own upgrade pipeline instead of a +// case in this file's switch. Not safe for concurrent calls; intended +// usage is registration during package init(), before UpgradeFramework is +// ever called. Not every framework needs an entry - upgrade is +// unimplemented for most of the 14. +func RegisterUpgrader(framework string, fn UpgradeFunc) { + upgraders[strings.ToLower(strings.TrimSpace(framework))] = fn +} + +// GetUpgrader looks up the registered upgrade pipeline for framework, +// resolving the pre-existing "magento"/"wp" bare aliases the same way the +// old switch's case lists did (UpgradeFramework itself keyed on +// strings.ToLower(config.Framework) directly, not +// normalizeFrameworkManifestKey, so this mirrors that exactly rather than +// introducing new alias behavior). +func GetUpgrader(framework string) (UpgradeFunc, bool) { + key := NormalizeFrameworkAlias(framework) + fn, ok := upgraders[key] + return fn, ok +} + func UpgradeFramework(ctx context.Context, config Config, opts UpgradeOptions) error { pterm.Info.Printf("%s Upgrade Pipeline\n", strings.ToUpper(config.Framework)) - switch strings.ToLower(config.Framework) { - case "magento2", "magento": - return upgradeMagento2(ctx, config, opts, magento2UpgradeVariant) - case "mageos": - return upgradeMagento2(ctx, config, opts, mageOSUpgradeVariant) - case "magento1": - return upgradeMagento1(ctx, config, opts) - case "laravel": - return upgradeLaravel(ctx, config, opts) - case "symfony": - return upgradeSymfony(ctx, config, opts) - case "wordpress", "wp": - return upgradeWordPress(ctx, config, opts) - default: + fn, ok := GetUpgrader(config.Framework) + if !ok { pterm.Warning.Printf("Upgrade for %s is not implemented yet.\n", config.Framework) return nil } + return fn(ctx, config, opts) } // UpgradeFrameworkForTest exposes UpgradeFramework for tests in /tests. diff --git a/internal/frameworks/admin_path.go b/internal/frameworks/admin_path.go new file mode 100644 index 00000000..758a7d35 --- /dev/null +++ b/internal/frameworks/admin_path.go @@ -0,0 +1,45 @@ +package frameworks + +import ( + "strings" + + "govard/internal/conventions" + "govard/internal/engine" +) + +// ResolveRemoteAdminPath resolves the route to open for a framework's remote +// admin panel. Definitions may provide a non-standard default and, when +// needed, a remote probe. Unknown frameworks retain the generic route. +func ResolveRemoteAdminPath(framework, remoteName string, remoteCfg engine.RemoteConfig) (string, error) { + path := DefaultAdminPath(framework) + definition, ok := Get(framework) + if !ok { + return path, nil + } + if configured := strings.Trim(strings.TrimSpace(definition.DefaultAdminPath), "/"); configured != "" { + path = configured + } + if definition.ResolveRemoteAdminPath == nil { + return path, nil + } + + resolved, err := definition.ResolveRemoteAdminPath(remoteName, remoteCfg) + if configured := strings.Trim(strings.TrimSpace(resolved), "/"); configured != "" { + path = configured + } + return path, err +} + +// DefaultAdminPath returns a framework's declared local admin route, falling +// back to the generic route for unknown frameworks. +func DefaultAdminPath(framework string) string { + path := conventions.DefaultAdminPath + definition, ok := Get(framework) + if !ok { + return path + } + if configured := strings.Trim(strings.TrimSpace(definition.DefaultAdminPath), "/"); configured != "" { + return configured + } + return path +} diff --git a/internal/frameworks/all_generated.go b/internal/frameworks/all_generated.go index e6592735..2ed55b39 100644 --- a/internal/frameworks/all_generated.go +++ b/internal/frameworks/all_generated.go @@ -4,6 +4,7 @@ package frameworks import ( "govard/internal/frameworks/cakephp" + "govard/internal/frameworks/custom" "govard/internal/frameworks/django" "govard/internal/frameworks/drupal" "govard/internal/frameworks/emdash" @@ -16,22 +17,26 @@ import ( "govard/internal/frameworks/prestashop" "govard/internal/frameworks/shopware" "govard/internal/frameworks/symfony" + "govard/internal/frameworks/types" "govard/internal/frameworks/wordpress" ) func init() { - Register(emdash.Definition()) - Register(cakephp.Definition()) - Register(django.Definition()) - Register(drupal.Definition()) - Register(laravel.Definition()) - Register(magento1.Definition()) - Register(magento2.Definition()) - Register(mageos.Definition()) - Register(nextjs.Definition()) - Register(openmage.Definition()) - Register(prestashop.Definition()) - Register(shopware.Definition()) - Register(symfony.Definition()) - Register(wordpress.Definition()) + RegisterSpecs([]types.FrameworkSpec{ + emdash.Spec(), + cakephp.Spec(), + custom.Spec(), + django.Spec(), + drupal.Spec(), + laravel.Spec(), + magento1.Spec(), + magento2.Spec(), + mageos.Spec(), + nextjs.Spec(), + openmage.Spec(), + prestashop.Spec(), + shopware.Spec(), + symfony.Spec(), + wordpress.Spec(), + }) } diff --git a/internal/blueprints/files/support/nginx/templates/cakephp.conf b/internal/frameworks/cakephp/blueprint/cakephp.conf similarity index 100% rename from internal/blueprints/files/support/nginx/templates/cakephp.conf rename to internal/frameworks/cakephp/blueprint/cakephp.conf diff --git a/internal/frameworks/cakephp/cakephp.go b/internal/frameworks/cakephp/cakephp.go index a363a247..b0eb46a0 100644 --- a/internal/frameworks/cakephp/cakephp.go +++ b/internal/frameworks/cakephp/cakephp.go @@ -1,8 +1,11 @@ package cakephp import ( + "govard/internal/conventions" "govard/internal/engine" "govard/internal/engine/bootstrap" + "govard/internal/engine/remote" + "govard/internal/frameworks/shared/dotenv" "govard/internal/frameworks/types" ) @@ -12,13 +15,36 @@ func Definition() types.FrameworkDefinition { DisplayName: "CakePHP", Config: config, Manifest: manifest, + DefaultDBCredentials: types.DefaultDBCredentials{ + Port: conventions.MySQLPort, + Username: conventions.DefaultDBUser, + Password: conventions.DefaultDBPass, + Database: conventions.DefaultDBName, + }, Detect: engine.DetectionSpec{ ComposerPackages: []string{"cakephp/cakephp"}, }, + ToolCommands: []types.ToolCommand{ + {Name: "cake", Short: "Run CakePHP CLI commands", Binary: "bin/cake"}, + }, Bootstrap: func(opts bootstrap.Options) bootstrap.FrameworkBootstrap { return NewCakePHPBootstrap(opts) }, FreshInstall: freshInstall, SupportsFreshInstall: true, + DBDriverCategory: "cakephp", + ProbeRemoteDB: func(remoteName string, remoteCfg engine.RemoteConfig) (remote.RemoteDatabaseMetadata, error) { + metadata, err := dotenv.ProbeEnvironment(remoteName, remoteCfg) + if err != nil { + return remote.RemoteDatabaseMetadata{}, err + } + return remote.RemoteDatabaseMetadata{ + Host: metadata.DB.Host, + Port: metadata.DB.Port, + Username: metadata.DB.Username, + Password: metadata.DB.Password, + Database: metadata.DB.Database, + }, nil + }, } } diff --git a/internal/frameworks/cakephp/embed.go b/internal/frameworks/cakephp/embed.go new file mode 100644 index 00000000..b573ebe3 --- /dev/null +++ b/internal/frameworks/cakephp/embed.go @@ -0,0 +1,29 @@ +package cakephp + +import ( + "embed" + "io/fs" + + "govard/internal/blueprints" +) + +//go:embed all:blueprint +var blueprintFiles embed.FS + +// BlueprintFS is cakephp's embedded blueprint sub-filesystem. +var BlueprintFS fs.FS + +func init() { + var err error + BlueprintFS, err = fs.Sub(blueprintFiles, "blueprint") + if err != nil { + panic(err) + } + + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "cakephp", + FS: BlueprintFS, + HasDir: false, + NginxTemplate: "cakephp.conf", + }) +} diff --git a/internal/frameworks/cakephp/spec.go b/internal/frameworks/cakephp/spec.go new file mode 100644 index 00000000..b61f4102 --- /dev/null +++ b/internal/frameworks/cakephp/spec.go @@ -0,0 +1,6 @@ +package cakephp + +import "govard/internal/frameworks/types" + +// Spec declares CakePHP as a root framework. +func Spec() types.FrameworkSpec { return types.FrameworkSpec{Definition: Definition()} } diff --git a/internal/frameworks/custom/config.go b/internal/frameworks/custom/config.go new file mode 100644 index 00000000..7aef3846 --- /dev/null +++ b/internal/frameworks/custom/config.go @@ -0,0 +1,40 @@ +package custom + +import ( + "govard/internal/conventions" + "govard/internal/engine" +) + +var config = engine.FrameworkConfig{ + Name: "custom", + Runtime: "php", + AppService: "php", + AppWorkdir: conventions.DefaultWorkDir, + NGINXPUBLIC: "", + NGINXTemplate: "default.conf", + DatabaseName: "app", + DefaultPHP: "", + DefaultNodeVer: "", + DefaultDB: "none", + DefaultDBVer: "", + DefaultMySQLVer: "", + DefaultNginxVer: "1.28", + DefaultApacheVer: "2.4", + DefaultCacheVer: "7.4", + DefaultSearchVer: "3.0", + DefaultVarnishVer: "8.0", + DefaultQueueVer: "4.2", + DefaultWebServer: "nginx", + DefaultSearch: "none", + DefaultCache: "none", + DefaultQueue: "none", + DefaultComposerVer: "", + Includes: []string{ + "includes/base.yml", + "includes/redis.yml", + "includes/elasticsearch.yml", + "includes/varnish.yml", + "includes/rabbitmq.yml", + "includes/livereload.yml", + }, +} diff --git a/internal/frameworks/custom/custom.go b/internal/frameworks/custom/custom.go new file mode 100644 index 00000000..b1f79cf7 --- /dev/null +++ b/internal/frameworks/custom/custom.go @@ -0,0 +1,24 @@ +// Package custom describes a deliberately unopinionated project stack. +package custom + +import ( + "govard/internal/conventions" + "govard/internal/engine" + "govard/internal/frameworks/types" +) + +func Definition() types.FrameworkDefinition { + return types.FrameworkDefinition{ + Name: "custom", + DisplayName: "Custom", + Config: config, + Manifest: manifest, + DefaultDBCredentials: types.DefaultDBCredentials{ + Port: conventions.MySQLPort, + Username: conventions.DefaultDBUser, + Password: conventions.DefaultDBPass, + Database: conventions.DefaultDBName, + }, + Detect: engine.DetectionSpec{}, + } +} diff --git a/internal/frameworks/custom/manifest.go b/internal/frameworks/custom/manifest.go new file mode 100644 index 00000000..2c215bfa --- /dev/null +++ b/internal/frameworks/custom/manifest.go @@ -0,0 +1,17 @@ +package custom + +import "govard/internal/engine" + +var manifest = engine.FrameworkManifestConfig{ + Ignored: []string{}, + Sensitive: []string{}, + Paths: engine.FrameworkPathConfig{ + LocalMedia: "public/media", + RemoteMedia: "public/media", + WebRootCandidates: []engine.FrameworkWebRootCandidate{}, + }, + Features: engine.FrameworkFeatureConfig{ + RequiresRunningEnvForFreshInstall: true, + SupportsPostClone: false, + }, +} diff --git a/internal/frameworks/custom/spec.go b/internal/frameworks/custom/spec.go new file mode 100644 index 00000000..781ae0be --- /dev/null +++ b/internal/frameworks/custom/spec.go @@ -0,0 +1,6 @@ +package custom + +import "govard/internal/frameworks/types" + +// Spec declares Custom as a root framework. +func Spec() types.FrameworkSpec { return types.FrameworkSpec{Definition: Definition()} } diff --git a/internal/blueprints/files/django/services.yml b/internal/frameworks/django/blueprint/services.yml similarity index 100% rename from internal/blueprints/files/django/services.yml rename to internal/frameworks/django/blueprint/services.yml diff --git a/internal/frameworks/django/bootstrap.go b/internal/frameworks/django/bootstrap.go index 8987b7c3..94e367bf 100644 --- a/internal/frameworks/django/bootstrap.go +++ b/internal/frameworks/django/bootstrap.go @@ -125,7 +125,7 @@ var djangoContainerExecRunner = func(containerName string, script string) error // internal/cmd/bootstrap.go's ordering), so the container is available. func (d *DjangoBootstrap) installAndMigrate() error { containerName := d.Options.ProjectName + conventions.WebSuffix - // The container runs as root (internal/blueprints/files/django/ + // The container runs as root (internal/frameworks/django/blueprint/ // services.yml), so pip/migrate leave __pycache__ files root-owned on // the host's bind-mounted project directory. Reclaim ownership back // to whatever the mount point itself is owned by (the host user) @@ -217,7 +217,7 @@ func insertImportOsAfterDocstring(content string) string { // patchDjangoSettingsForPostgres rewires settings.py's default sqlite // DATABASES block to read the POSTGRES_* env vars that -// internal/blueprints/files/django/services.yml already injects into the +// internal/frameworks/django/blueprint/services.yml already injects into the // web container, so `manage.py migrate` targets the real project database // instead of a throwaway db.sqlite3. Returns an error (soft-fail, caller // decides whether to warn) if Django's template changed and the expected diff --git a/internal/frameworks/django/django.go b/internal/frameworks/django/django.go index 6903f36f..d8f4819c 100644 --- a/internal/frameworks/django/django.go +++ b/internal/frameworks/django/django.go @@ -1,6 +1,7 @@ package django import ( + "govard/internal/conventions" "govard/internal/engine" "govard/internal/engine/bootstrap" "govard/internal/frameworks/types" @@ -12,9 +13,18 @@ func Definition() types.FrameworkDefinition { DisplayName: "Django", Config: config, Manifest: manifest, + DefaultDBCredentials: types.DefaultDBCredentials{ + Port: conventions.PostgresPort, + Username: conventions.DefaultDjangoDBUser, + Password: conventions.DefaultDjangoDBPass, + Database: conventions.DefaultDjangoDBName, + }, Detect: engine.DetectionSpec{ FilePaths: []string{"manage.py"}, }, + ToolCommands: []types.ToolCommand{ + {Name: "manage", Short: "Run Django management commands", Binary: "python", PrependArgs: []string{"manage.py"}}, + }, Bootstrap: func(opts bootstrap.Options) bootstrap.FrameworkBootstrap { return NewDjangoBootstrap(opts) }, diff --git a/internal/frameworks/django/embed.go b/internal/frameworks/django/embed.go new file mode 100644 index 00000000..9a8b536a --- /dev/null +++ b/internal/frameworks/django/embed.go @@ -0,0 +1,27 @@ +package django + +import ( + "embed" + "io/fs" + + "govard/internal/blueprints" +) + +//go:embed all:blueprint +var blueprintFiles embed.FS + +var BlueprintFS fs.FS + +func init() { + var err error + BlueprintFS, err = fs.Sub(blueprintFiles, "blueprint") + if err != nil { + panic(err) + } + + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "django", + FS: BlueprintFS, + HasDir: true, + }) +} diff --git a/internal/frameworks/django/spec.go b/internal/frameworks/django/spec.go new file mode 100644 index 00000000..a8c2693b --- /dev/null +++ b/internal/frameworks/django/spec.go @@ -0,0 +1,6 @@ +package django + +import "govard/internal/frameworks/types" + +// Spec declares Django as a root framework. +func Spec() types.FrameworkSpec { return types.FrameworkSpec{Definition: Definition()} } diff --git a/internal/blueprints/files/support/nginx/templates/drupal.conf b/internal/frameworks/drupal/blueprint/drupal.conf similarity index 100% rename from internal/blueprints/files/support/nginx/templates/drupal.conf rename to internal/frameworks/drupal/blueprint/drupal.conf diff --git a/internal/frameworks/drupal/drupal.go b/internal/frameworks/drupal/drupal.go index 4b635d7a..711ee0f9 100644 --- a/internal/frameworks/drupal/drupal.go +++ b/internal/frameworks/drupal/drupal.go @@ -1,24 +1,52 @@ package drupal import ( + "govard/internal/conventions" "govard/internal/engine" "govard/internal/engine/bootstrap" + "govard/internal/engine/remote" + "govard/internal/frameworks/shared/dotenv" "govard/internal/frameworks/types" ) func Definition() types.FrameworkDefinition { return types.FrameworkDefinition{ - Name: "drupal", - DisplayName: "Drupal", - Config: config, - Manifest: manifest, + Name: "drupal", + DisplayName: "Drupal", + MigrationTypes: types.MigrationTypes{DDEV: []string{"drupal7", "drupal8", "drupal9", "drupal10", "drupal11"}}, + Config: config, + Manifest: manifest, + DefaultDBCredentials: types.DefaultDBCredentials{ + Port: conventions.MySQLPort, + Username: conventions.DefaultDBUser, + Password: conventions.DefaultDBPass, + Database: conventions.DefaultDBName, + }, Detect: engine.DetectionSpec{ ComposerPackages: []string{"drupal/core"}, }, + ComposerCodingStandard: types.ComposerCodingStandard{Package: "drupal/coder", Standard: "Drupal"}, + ToolCommands: []types.ToolCommand{ + {Name: "drush", Short: "Run Drupal Drush commands", Binary: "drush"}, + }, Bootstrap: func(opts bootstrap.Options) bootstrap.FrameworkBootstrap { return NewDrupalBootstrap(opts) }, FreshInstall: freshInstall, SupportsFreshInstall: true, + DBDriverCategory: "drupal", + ProbeRemoteDB: func(remoteName string, remoteCfg engine.RemoteConfig) (remote.RemoteDatabaseMetadata, error) { + metadata, err := dotenv.ProbeEnvironment(remoteName, remoteCfg) + if err != nil { + return remote.RemoteDatabaseMetadata{}, err + } + return remote.RemoteDatabaseMetadata{ + Host: metadata.DB.Host, + Port: metadata.DB.Port, + Username: metadata.DB.Username, + Password: metadata.DB.Password, + Database: metadata.DB.Database, + }, nil + }, } } diff --git a/internal/frameworks/drupal/embed.go b/internal/frameworks/drupal/embed.go new file mode 100644 index 00000000..48a18bcb --- /dev/null +++ b/internal/frameworks/drupal/embed.go @@ -0,0 +1,28 @@ +package drupal + +import ( + "embed" + "io/fs" + + "govard/internal/blueprints" +) + +//go:embed all:blueprint +var blueprintFiles embed.FS + +var BlueprintFS fs.FS + +func init() { + var err error + BlueprintFS, err = fs.Sub(blueprintFiles, "blueprint") + if err != nil { + panic(err) + } + + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "drupal", + FS: BlueprintFS, + HasDir: false, + NginxTemplate: "drupal.conf", + }) +} diff --git a/internal/frameworks/drupal/spec.go b/internal/frameworks/drupal/spec.go new file mode 100644 index 00000000..fb1f97f1 --- /dev/null +++ b/internal/frameworks/drupal/spec.go @@ -0,0 +1,6 @@ +package drupal + +import "govard/internal/frameworks/types" + +// Spec declares Drupal as a root framework. +func Spec() types.FrameworkSpec { return types.FrameworkSpec{Definition: Definition()} } diff --git a/internal/blueprints/files/emdash/services.yml b/internal/frameworks/emdash/blueprint/services.yml similarity index 100% rename from internal/blueprints/files/emdash/services.yml rename to internal/frameworks/emdash/blueprint/services.yml diff --git a/internal/frameworks/emdash/embed.go b/internal/frameworks/emdash/embed.go new file mode 100644 index 00000000..6cd58fc9 --- /dev/null +++ b/internal/frameworks/emdash/embed.go @@ -0,0 +1,27 @@ +package emdash + +import ( + "embed" + "io/fs" + + "govard/internal/blueprints" +) + +//go:embed all:blueprint +var blueprintFiles embed.FS + +var BlueprintFS fs.FS + +func init() { + var err error + BlueprintFS, err = fs.Sub(blueprintFiles, "blueprint") + if err != nil { + panic(err) + } + + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "emdash", + FS: BlueprintFS, + HasDir: true, + }) +} diff --git a/internal/frameworks/emdash/emdash.go b/internal/frameworks/emdash/emdash.go index 6f271dba..66923559 100644 --- a/internal/frameworks/emdash/emdash.go +++ b/internal/frameworks/emdash/emdash.go @@ -1,6 +1,9 @@ package emdash import ( + "text/template" + + "govard/internal/conventions" "govard/internal/engine" "govard/internal/engine/bootstrap" "govard/internal/frameworks/types" @@ -8,10 +11,18 @@ import ( func Definition() types.FrameworkDefinition { return types.FrameworkDefinition{ - Name: "emdash", - DisplayName: "Emdash", - Config: config, - Manifest: manifest, + Name: "emdash", + DisplayName: "Emdash", + Config: config, + Manifest: manifest, + NodeImageFlavor: "standard", + DefaultAdminPath: "_emdash/admin", + DefaultDBCredentials: types.DefaultDBCredentials{ + Port: conventions.MySQLPort, + Username: conventions.DefaultDBUser, + Password: conventions.DefaultDBPass, + Database: conventions.DefaultDBName, + }, Detect: engine.DetectionSpec{ PackageJSONDeps: []string{"emdash"}, }, @@ -20,5 +31,6 @@ func Definition() types.FrameworkDefinition { }, FreshInstall: freshInstall, SupportsFreshInstall: true, + TemplateFuncs: template.FuncMap{"emdashRuntimeCommand": BuildRuntimeCommand}, } } diff --git a/internal/frameworks/emdash/render.go b/internal/frameworks/emdash/render.go new file mode 100644 index 00000000..a8a5d852 --- /dev/null +++ b/internal/frameworks/emdash/render.go @@ -0,0 +1,27 @@ +package emdash + +import ( + "fmt" + "strings" +) + +// BuildRuntimeCommand builds the dev-server startup command for emdash's +// runtime container, moved verbatim from internal/engine/render.go's +// buildEmdashRuntimeCommand. +func BuildRuntimeCommand(packageManager string, domain string) string { + domain = strings.TrimSpace(domain) + + if packageManager == "pnpm" { + return strings.Join([]string{ + "corepack enable >/dev/null 2>&1 || true;", + "if ! command -v pnpm >/dev/null 2>&1; then corepack prepare pnpm@latest --activate >/dev/null 2>&1; fi;", + `if [ ! -d node_modules ] || [ -z "$$(ls -A node_modules 2>/dev/null)" ]; then pnpm install; fi;`, + fmt.Sprintf("exec pnpm dev --host 0.0.0.0 --port 80 --allowed-hosts %s;", domain), + }, " ") + } + + return strings.Join([]string{ + `if [ ! -d node_modules ] || [ -z "$$(ls -A node_modules 2>/dev/null)" ]; then npm install; fi;`, + fmt.Sprintf("exec npm run dev -- --host 0.0.0.0 --port 80 --allowed-hosts %s;", domain), + }, " ") +} diff --git a/internal/frameworks/emdash/spec.go b/internal/frameworks/emdash/spec.go new file mode 100644 index 00000000..6445e1d7 --- /dev/null +++ b/internal/frameworks/emdash/spec.go @@ -0,0 +1,6 @@ +package emdash + +import "govard/internal/frameworks/types" + +// Spec declares Emdash as a root framework. +func Spec() types.FrameworkSpec { return types.FrameworkSpec{Definition: Definition()} } diff --git a/internal/frameworks/gen/generator/discover.go b/internal/frameworks/gen/generator/discover.go index 513fd71a..3bddcfa6 100644 --- a/internal/frameworks/gen/generator/discover.go +++ b/internal/frameworks/gen/generator/discover.go @@ -15,8 +15,9 @@ import ( // support code, not a framework's own Definition() - DiscoverFrameworkDirs // skips them. var excludedDirs = map[string]bool{ - "gen": true, - "types": true, + "gen": true, + "shared": true, + "types": true, } // DiscoverFrameworkDirs lists the framework package directories directly diff --git a/internal/frameworks/gen/generator/render.go b/internal/frameworks/gen/generator/render.go index c3773fad..f19da550 100644 --- a/internal/frameworks/gen/generator/render.go +++ b/internal/frameworks/gen/generator/render.go @@ -15,12 +15,15 @@ import ( {{- range .Imports }} "govard/internal/frameworks/{{ . }}" {{- end }} + "govard/internal/frameworks/types" ) func init() { + RegisterSpecs([]types.FrameworkSpec{ {{- range .Order }} - Register({{ . }}.Definition()) + {{ . }}.Spec(), {{- end }} + }) } ` @@ -30,8 +33,8 @@ type generatedSourceData struct { } // RenderSource builds the formatted contents of all_generated.go. -// alphabetical drives the import block; order drives the sequence of -// Register() calls in the generated init() (the two differ only when +// alphabetical drives the import block; order drives the sequence of specs +// projected into engine's detection registry (the two differ only when // PriorityOverrides reorders a package ahead of its alphabetical // position). func RenderSource(alphabetical []string, order []string) ([]byte, error) { diff --git a/internal/blueprints/files/support/nginx/templates/laravel.conf b/internal/frameworks/laravel/blueprint/laravel.conf similarity index 100% rename from internal/blueprints/files/support/nginx/templates/laravel.conf rename to internal/frameworks/laravel/blueprint/laravel.conf diff --git a/internal/blueprints/files/laravel/services.yml b/internal/frameworks/laravel/blueprint/services.yml similarity index 100% rename from internal/blueprints/files/laravel/services.yml rename to internal/frameworks/laravel/blueprint/services.yml diff --git a/internal/frameworks/laravel/embed.go b/internal/frameworks/laravel/embed.go new file mode 100644 index 00000000..5f917cde --- /dev/null +++ b/internal/frameworks/laravel/embed.go @@ -0,0 +1,28 @@ +package laravel + +import ( + "embed" + "io/fs" + + "govard/internal/blueprints" +) + +//go:embed all:blueprint +var blueprintFiles embed.FS + +var BlueprintFS fs.FS + +func init() { + var err error + BlueprintFS, err = fs.Sub(blueprintFiles, "blueprint") + if err != nil { + panic(err) + } + + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "laravel", + FS: BlueprintFS, + HasDir: true, + NginxTemplate: "laravel.conf", + }) +} diff --git a/internal/frameworks/laravel/laravel.go b/internal/frameworks/laravel/laravel.go index cd1ee401..ee278fab 100644 --- a/internal/frameworks/laravel/laravel.go +++ b/internal/frameworks/laravel/laravel.go @@ -1,21 +1,35 @@ package laravel import ( + "govard/internal/conventions" "govard/internal/engine" "govard/internal/engine/bootstrap" + "govard/internal/engine/remote" "govard/internal/engine/tunnel" + "govard/internal/frameworks/shared/dotenv" "govard/internal/frameworks/types" ) func Definition() types.FrameworkDefinition { return types.FrameworkDefinition{ - Name: "laravel", - DisplayName: "Laravel", - Config: config, - Manifest: manifest, + Name: "laravel", + DisplayName: "Laravel", + MigrationTypes: types.MigrationTypes{DDEV: []string{"laravel"}, Warden: []string{"laravel"}}, + Config: config, + Manifest: manifest, + DefaultDBCredentials: types.DefaultDBCredentials{ + Port: conventions.MySQLPort, + Username: conventions.DefaultLaravelDBUser, + Password: conventions.DefaultLaravelDBPass, + Database: conventions.DefaultLaravelDBName, + }, Detect: engine.DetectionSpec{ ComposerPackages: []string{"laravel/framework"}, }, + ToolCommands: []types.ToolCommand{ + {Name: "artisan", Short: "Run Laravel Artisan commands", Binary: "php", PrependArgs: []string{"artisan"}}, + }, + DefaultTestCommand: types.TestCommand{Binary: "php", Args: []string{"artisan", "test"}}, Bootstrap: func(opts bootstrap.Options) bootstrap.FrameworkBootstrap { return NewLaravelBootstrap(opts) }, @@ -26,5 +40,20 @@ func Definition() types.FrameworkDefinition { FreshInstallNeedsDB: true, SupportsBootstrap: true, SupportsFreshInstall: true, + DBDriverCategory: "laravel", + Upgrade: Upgrade, + ProbeRemoteDB: func(remoteName string, remoteCfg engine.RemoteConfig) (remote.RemoteDatabaseMetadata, error) { + metadata, err := dotenv.ProbeEnvironment(remoteName, remoteCfg) + if err != nil { + return remote.RemoteDatabaseMetadata{}, err + } + return remote.RemoteDatabaseMetadata{ + Host: metadata.DB.Host, + Port: metadata.DB.Port, + Username: metadata.DB.Username, + Password: metadata.DB.Password, + Database: metadata.DB.Database, + }, nil + }, } } diff --git a/internal/frameworks/laravel/spec.go b/internal/frameworks/laravel/spec.go new file mode 100644 index 00000000..d8dfbdde --- /dev/null +++ b/internal/frameworks/laravel/spec.go @@ -0,0 +1,6 @@ +package laravel + +import "govard/internal/frameworks/types" + +// Spec declares Laravel as a root framework. +func Spec() types.FrameworkSpec { return types.FrameworkSpec{Definition: Definition()} } diff --git a/internal/engine/upgrade_laravel.go b/internal/frameworks/laravel/upgrade.go similarity index 91% rename from internal/engine/upgrade_laravel.go rename to internal/frameworks/laravel/upgrade.go index 896e1a36..e65bf235 100644 --- a/internal/engine/upgrade_laravel.go +++ b/internal/frameworks/laravel/upgrade.go @@ -1,4 +1,4 @@ -package engine +package laravel import ( "context" @@ -7,9 +7,12 @@ import ( "github.com/pterm/pterm" "govard/internal/conventions" + "govard/internal/engine" ) -func upgradeLaravel(ctx context.Context, config Config, opts UpgradeOptions) error { +// Upgrade is laravel's engine.UpgradeFunc, moved verbatim from +// engine.upgradeLaravel. +func Upgrade(ctx context.Context, config engine.Config, opts engine.UpgradeOptions) error { pterm.Info.Println("Laravel Upgrade Pipeline") containerName := fmt.Sprintf("%s%s", opts.ProjectName, conventions.PHPSuffix) diff --git a/internal/blueprints/files/support/nginx/templates/magento1.conf b/internal/frameworks/magento1/blueprint/magento1.conf similarity index 100% rename from internal/blueprints/files/support/nginx/templates/magento1.conf rename to internal/frameworks/magento1/blueprint/magento1.conf diff --git a/internal/blueprints/files/magento1/services.yml b/internal/frameworks/magento1/blueprint/services.yml similarity index 100% rename from internal/blueprints/files/magento1/services.yml rename to internal/frameworks/magento1/blueprint/services.yml diff --git a/internal/frameworks/magento1/bootstrap.go b/internal/frameworks/magento1/bootstrap.go index 672fac15..bb5034a0 100644 --- a/internal/frameworks/magento1/bootstrap.go +++ b/internal/frameworks/magento1/bootstrap.go @@ -88,13 +88,13 @@ func (m *Magento1Bootstrap) CreateAdmin(projectDir string) error { containerName := fmt.Sprintf("%s%s", m.Options.ProjectName, conventions.DBSuffix) pterm.Info.Println("Creating Magento 1 admin user...") - return bootstrap.RunMagento1AdminUserSQL(containerName, m.Options.DBUser, m.Options.DBPass, m.Options.DBName, strings.TrimSpace(m.Options.TablePrefix), adminEmail) + return RunAdminUserSQL(containerName, m.Options.DBUser, m.Options.DBPass, m.Options.DBName, strings.TrimSpace(m.Options.TablePrefix), adminEmail) } // createLocalXml generates app/etc/local.xml with a random 32-hex crypt key and // the default local Warden database credentials. func (m *Magento1Bootstrap) createLocalXml(projectDir string) error { - cryptKey, err := bootstrap.GenerateMagento1CryptKey() + cryptKey, err := GenerateCryptKey() if err != nil { return fmt.Errorf("failed to generate crypt key: %w", err) } diff --git a/internal/frameworks/magento1/embed.go b/internal/frameworks/magento1/embed.go new file mode 100644 index 00000000..bdb79110 --- /dev/null +++ b/internal/frameworks/magento1/embed.go @@ -0,0 +1,28 @@ +package magento1 + +import ( + "embed" + "io/fs" + + "govard/internal/blueprints" +) + +//go:embed all:blueprint +var blueprintFiles embed.FS + +var BlueprintFS fs.FS + +func init() { + var err error + BlueprintFS, err = fs.Sub(blueprintFiles, "blueprint") + if err != nil { + panic(err) + } + + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "magento1", + FS: BlueprintFS, + HasDir: true, + NginxTemplate: "magento1.conf", + }) +} diff --git a/internal/frameworks/magento1/legacy.go b/internal/frameworks/magento1/legacy.go new file mode 100644 index 00000000..68323846 --- /dev/null +++ b/internal/frameworks/magento1/legacy.go @@ -0,0 +1,49 @@ +package magento1 + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + + "govard/internal/conventions" + "govard/internal/engine/bootstrap" +) + +// GenerateCryptKey creates the random 32-character key required by Magento +// 1-family app/etc/local.xml files. OpenMage inherits this behavior. +func GenerateCryptKey() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// RunAdminUserSQL creates the Magento 1-family default admin user. OpenMage +// reuses the schema and calls this parent-family implementation. +func RunAdminUserSQL(containerName, dbUser, dbPassword, dbName, dbPrefix, adminEmail string) error { + insertSQL := fmt.Sprintf(` +-- Magento 1/OpenMage requires the legacy MD5:salt password representation. +SET @govard_admin_password := CONCAT(MD5(CONCAT(%q, %q)), ':', %q); + +INSERT INTO %sadmin_user(username, firstname, lastname, email, password, created, lognum, reload_acl_flag, is_active, extra, rp_token, rp_token_created_at) +VALUES (%q, "Admin", "User", %q, @govard_admin_password, NOW(), 0, 0, 1, NULL, NULL, NOW()) +ON DUPLICATE KEY UPDATE password = @govard_admin_password, is_active = 1; + +INSERT IGNORE INTO %sadmin_role (parent_id, tree_level, sort_order, role_type, user_id, role_name) +VALUES (0, 1, 1, 'G', 0, 'Administrators'); + +INSERT IGNORE INTO %sadmin_rule (role_id, resource_id, privileges, assert_id, role_type, permission) +SELECT role_id, 'all', NULL, 0, 'G', 'allow' FROM %sadmin_role WHERE role_type = 'G' AND role_name = 'Administrators' LIMIT 1; + +INSERT INTO %sadmin_role (parent_id, tree_level, sort_order, role_type, user_id, role_name) +SELECT role_id, 2, 0, 'U', (SELECT user_id FROM %sadmin_user WHERE username = %q LIMIT 1), %q +FROM %sadmin_role WHERE role_type = 'G' AND role_name = 'Administrators' LIMIT 1 + ON DUPLICATE KEY UPDATE parent_id = VALUES(parent_id); + `, + conventions.DefaultAdminUser, conventions.DefaultAdminPassword, conventions.DefaultAdminUser, + dbPrefix, conventions.DefaultAdminUser, adminEmail, + dbPrefix, dbPrefix, dbPrefix, dbPrefix, dbPrefix, conventions.DefaultAdminUser, conventions.DefaultAdminUser, dbPrefix) + + return bootstrap.RunSQLViaDockerExec(containerName, dbUser, dbPassword, dbName, insertSQL) +} diff --git a/internal/frameworks/magento1/magento1.go b/internal/frameworks/magento1/magento1.go index 888187fd..1c5cec81 100644 --- a/internal/frameworks/magento1/magento1.go +++ b/internal/frameworks/magento1/magento1.go @@ -3,18 +3,31 @@ package magento1 import ( "fmt" + "govard/internal/conventions" "govard/internal/engine" "govard/internal/engine/bootstrap" + "govard/internal/engine/remote" "govard/internal/engine/tunnel" + "govard/internal/frameworks/magento2" "govard/internal/frameworks/types" + + "github.com/spf13/cobra" ) func Definition() types.FrameworkDefinition { return types.FrameworkDefinition{ - Name: "magento1", - DisplayName: "Magento 1", - Config: config, - Manifest: Manifest, + Name: "magento1", + Aliases: []string{"m1"}, + DisplayName: "Magento 1", + MigrationTypes: types.MigrationTypes{DDEV: []string{"magento"}, Warden: []string{"magento1"}}, + Config: config, + Manifest: Manifest, + DefaultDBCredentials: types.DefaultDBCredentials{ + Port: conventions.MySQLPort, + Username: conventions.DefaultMagentoDBUser, + Password: conventions.DefaultMagentoDBPass, + Database: conventions.DefaultMagentoDBName, + }, // ComposerPackages intentionally includes openmage/magento-lts and // magento-hackathon/magento-composer-installer - this is the exact, // pre-existing behavior of internal/engine/discovery.go (a project @@ -22,6 +35,9 @@ func Definition() types.FrameworkDefinition { // "openmage"; openmage has no detection heuristic of its own). // This looks like it could be a bug, but changing it is out of // scope - Global Constraints require zero detection behavior change. + ToolCommands: []types.ToolCommand{ + {Name: "magerun", Aliases: []string{"mr"}, Short: "Run n98-magerun commands", Binary: "n98-magerun"}, + }, Detect: engine.DetectionSpec{ ComposerPackages: []string{"openmage/magento-lts", "magento-hackathon/magento-composer-installer"}, FilePaths: []string{"app/Mage.php", "app/etc/local.xml"}, @@ -42,7 +58,23 @@ func Definition() types.FrameworkDefinition { FreshInstall: func(opts bootstrap.Options, projectDir string, helpers bootstrap.CmdHelpers) error { return fmt.Errorf("fresh install not supported for magento1 (use openmage instead)") }, - SupportsBootstrap: true, - SupportsFreshInstall: true, + SupportsBootstrap: true, + SupportsFreshInstall: true, + PHPImageVariant: "magento1", + DBDriverCategory: "magento", + RunMappingAssetPreparer: magento2.PrepareRunMappingAssets, + TablePrefixDetector: DetectTablePrefix, + BootstrapPlanSteps: func(bool) []types.BootstrapPlanStep { + return []types.BootstrapPlanStep{{Description: "Configuring framework environment...", Command: "govard config auto"}} + }, + Upgrade: Upgrade, + ProbeRemoteDB: func(remoteName string, remoteCfg engine.RemoteConfig) (remote.RemoteDatabaseMetadata, error) { + metadata, err := ProbeMagento1Environment(remoteName, remoteCfg) + return metadata.DB, err + }, + RemoteDBUsesConfigTablePrefix: true, + AutoConfigure: func(cmd *cobra.Command, config engine.Config) error { + return magento2.ConfigureMagento1(config.ProjectName, config) + }, } } diff --git a/internal/engine/remote/magento1_metadata.go b/internal/frameworks/magento1/metadata.go similarity index 88% rename from internal/engine/remote/magento1_metadata.go rename to internal/frameworks/magento1/metadata.go index 643d9865..dcf4a1f2 100644 --- a/internal/engine/remote/magento1_metadata.go +++ b/internal/frameworks/magento1/metadata.go @@ -1,4 +1,4 @@ -package remote +package magento1 import ( "encoding/base64" @@ -7,19 +7,20 @@ import ( "strings" "govard/internal/engine" + remote "govard/internal/engine/remote" ) // Magento1Environment holds DB credentials extracted remotely from a Magento 1 local.xml. type Magento1Environment struct { - DB MagentoDBInfo + DB remote.RemoteDatabaseMetadata CryptKey string } // ProbeMagento1Environment SSHs to the remote environment and reads the local.xml via PHP // to extract DB connection credentials. Returns Magento1Environment with filled DB fields. func ProbeMagento1Environment(remoteName string, remoteCfg engine.RemoteConfig) (Magento1Environment, error) { - remoteCommand := buildMagentoRemoteCommand(remoteCfg.Path, `php -r `+engine.ShellQuote(magento1LocalXMLProbePHP)) - encoded, err := runRemoteCapture(remoteName, remoteCfg, remoteCommand) + remoteCommand := remote.BuildProjectRemoteCommand(remoteCfg.Path, `php -r `+engine.ShellQuote(magento1LocalXMLProbePHP)) + encoded, err := remote.RunRemoteCapture(remoteName, remoteCfg, remoteCommand) if err != nil { return Magento1Environment{}, err } @@ -53,7 +54,7 @@ func decodeMagento1EnvironmentPayload(encoded string) (Magento1Environment, erro return Magento1Environment{}, fmt.Errorf("parse remote probe payload: %w", err) } - host, port := ParseMagentoDBHostPort(payload.Host) + host, port := remote.ParseDatabaseHostPort(payload.Host) username := strings.TrimSpace(payload.Username) database := strings.TrimSpace(payload.DBName) if username == "" || database == "" { @@ -61,7 +62,7 @@ func decodeMagento1EnvironmentPayload(encoded string) (Magento1Environment, erro } return Magento1Environment{ - DB: MagentoDBInfo{ + DB: remote.RemoteDatabaseMetadata{ Host: host, Port: port, Username: username, diff --git a/internal/frameworks/magento1/spec.go b/internal/frameworks/magento1/spec.go new file mode 100644 index 00000000..86164449 --- /dev/null +++ b/internal/frameworks/magento1/spec.go @@ -0,0 +1,6 @@ +package magento1 + +import "govard/internal/frameworks/types" + +// Spec declares Magento 1 as a root framework. +func Spec() types.FrameworkSpec { return types.FrameworkSpec{Definition: Definition()} } diff --git a/internal/frameworks/magento1/table_prefix.go b/internal/frameworks/magento1/table_prefix.go new file mode 100644 index 00000000..a93de1dc --- /dev/null +++ b/internal/frameworks/magento1/table_prefix.go @@ -0,0 +1,32 @@ +package magento1 + +import ( + "encoding/xml" + "os" + "path/filepath" + + "govard/internal/engine" +) + +// DetectTablePrefix reads the Magento 1 local.xml table-prefix setting. +// OpenMage inherits this detector through its resolved Magento 1 definition. +func DetectTablePrefix(root string) string { + data, err := os.ReadFile(filepath.Join(root, "app", "etc", "local.xml")) + if err != nil { + return "" + } + + var localXML struct { + Global struct { + Resources struct { + DB struct { + TablePrefix string `xml:"table_prefix"` + } `xml:"db"` + } `xml:"resources"` + } `xml:"global"` + } + if err := xml.Unmarshal(data, &localXML); err != nil { + return "" + } + return engine.NormalizeTablePrefix(localXML.Global.Resources.DB.TablePrefix) +} diff --git a/internal/engine/upgrade_magento1.go b/internal/frameworks/magento1/upgrade.go similarity index 93% rename from internal/engine/upgrade_magento1.go rename to internal/frameworks/magento1/upgrade.go index a7f0036c..6b97e2a7 100644 --- a/internal/engine/upgrade_magento1.go +++ b/internal/frameworks/magento1/upgrade.go @@ -1,4 +1,4 @@ -package engine +package magento1 import ( "context" @@ -7,9 +7,12 @@ import ( "github.com/pterm/pterm" "govard/internal/conventions" + "govard/internal/engine" ) -func upgradeMagento1(ctx context.Context, config Config, opts UpgradeOptions) error { +// Upgrade is magento1's engine.UpgradeFunc, moved verbatim from +// engine.upgradeMagento1. +func Upgrade(ctx context.Context, config engine.Config, opts engine.UpgradeOptions) error { pterm.Info.Println("Magento 1 Upgrade Pipeline") containerName := fmt.Sprintf("%s%s", opts.ProjectName, conventions.PHPSuffix) diff --git a/internal/frameworks/magento2/admin_path.go b/internal/frameworks/magento2/admin_path.go new file mode 100644 index 00000000..445f010b --- /dev/null +++ b/internal/frameworks/magento2/admin_path.go @@ -0,0 +1,40 @@ +package magento2 + +import ( + "fmt" + "strings" + + "govard/internal/conventions" + "govard/internal/engine" + engineremote "govard/internal/engine/remote" +) + +// remoteAdminProbeScript is a PHP one-liner reading app/etc/env.php's +// backend.frontName, falling back to conventions.DefaultAdminPath. +const remoteAdminProbeScript = `$c=@include "app/etc/env.php"; if(!is_array($c)){fwrite(STDERR,"env.php not found"); exit(2);} echo (string)($c["backend"]["frontName"] ?? "` + conventions.DefaultAdminPath + `");` + +// DetectRemoteAdminPath SSHs to remoteName and probes its app/etc/env.php +// for the configured admin frontName, falling back to +// conventions.DefaultAdminPath if the probe fails or returns nothing. This +// is the single source of truth for both internal/cmd/open_targets.go +// (govard open admin) and internal/desktop/remotes.go (desktop remote +// admin resolution), which previously each carried their own byte-identical +// copy of this probe. +func DetectRemoteAdminPath(remoteName string, remoteCfg engine.RemoteConfig) (string, error) { + remoteCommand := "php -r " + engine.ShellQuote(remoteAdminProbeScript) + if path := strings.TrimSpace(remoteCfg.Path); path != "" { + remoteCommand = "cd " + engineremote.QuoteRemotePath(path) + " && " + remoteCommand + } + + probeCmd := engineremote.BuildSSHExecCommand(remoteName, remoteCfg, true, remoteCommand) + output, err := probeCmd.CombinedOutput() + if err != nil { + return conventions.DefaultAdminPath, fmt.Errorf("probe failed: %w", err) + } + + value := strings.Trim(strings.TrimSpace(string(output)), "/") + if value == "" { + value = conventions.DefaultAdminPath + } + return value, nil +} diff --git a/internal/blueprints/files/support/nginx/templates/magento2.conf b/internal/frameworks/magento2/blueprint/magento2.conf similarity index 100% rename from internal/blueprints/files/support/nginx/templates/magento2.conf rename to internal/frameworks/magento2/blueprint/magento2.conf diff --git a/internal/blueprints/files/magento2/varnish/default.vcl b/internal/frameworks/magento2/blueprint/varnish/default.vcl similarity index 100% rename from internal/blueprints/files/magento2/varnish/default.vcl rename to internal/frameworks/magento2/blueprint/varnish/default.vcl diff --git a/internal/frameworks/magento2/bootstrap.go b/internal/frameworks/magento2/bootstrap.go index 46befc6d..6101d7cc 100644 --- a/internal/frameworks/magento2/bootstrap.go +++ b/internal/frameworks/magento2/bootstrap.go @@ -7,12 +7,15 @@ import ( "govard/internal/conventions" "govard/internal/engine" "govard/internal/engine/bootstrap" + "govard/internal/frameworks/types" + + "github.com/pterm/pterm" ) // FamilyVariant parameterizes the shared Magento-2-family fresh-install/ // clone-workflow pipeline for a specific distribution - the same pattern -// internal/engine/upgrade_magento2.go's magentoUpgradeVariant already uses -// for `govard upgrade`. Magento 2 owns this type and the generic logic +// this package's own upgrade.go's UpgradeVariant already uses for `govard +// upgrade`. Magento 2 owns this type and the generic logic // built on it below, since Magento 2 is the real, primary implementation; // a sibling distribution (currently only Mage-OS, in // internal/frameworks/mageos/bootstrap.go) imports this package and @@ -132,8 +135,8 @@ func BuildFreshCreateProjectCommand(variant FamilyVariant, opts bootstrap.Option // list for variant - moved verbatim from internal/cmd/ // bootstrap_post_install.go's runBootstrapPostInstall (the "build // setupArgs" half; the "run it" half is now -// internal/cmd/bootstrap_fresh_install.go's runBootstrapMagentoSetupInstall, -// wired through CmdHelpers.RunMagentoSetupInstall). +// internal/cmd/bootstrap_fresh_install.go's generic setup-install runner, +// wired through CmdHelpers.RunFrameworkSetupInstall). // // The legacy elasticsearch7-vs-opensearch version gate is intentionally // magento2-only (variant.Name == "magento2"), matching the pre-existing @@ -195,7 +198,7 @@ func BuildSetupInstallArgs(variant FamilyVariant, version string, adminEmail str // HyvaInstall) Hyva theme install -> setup:install -> `govard config auto` // -> (if IncludeSample) sample data. Moved from internal/cmd/ // bootstrap_fresh_install.go's runBootstrapFreshInstall, parameterized by -// variant instead of an engine.Magento2FamilyDisplayName string check. +// variant instead of a core framework-name check. func FreshInstall(variant FamilyVariant, opts bootstrap.Options, projectDir string, helpers bootstrap.CmdHelpers) error { if err := helpers.EnsureAuthJSON(); err != nil { return err @@ -215,13 +218,13 @@ func FreshInstall(variant FamilyVariant, opts bootstrap.Options, projectDir stri } } - tablePrefix, err := helpers.ResolveMagentoTablePrefix() + tablePrefix, err := helpers.ResolveFrameworkTablePrefix() if err != nil { return err } adminEmail := conventions.AdminEmailForDomain(opts.Domain) setupArgs := BuildSetupInstallArgs(variant, opts.Version, adminEmail, tablePrefix) - if err := helpers.RunMagentoSetupInstall(setupArgs); err != nil { + if err := RunSetupInstall(helpers, setupArgs); err != nil { return err } @@ -230,7 +233,7 @@ func FreshInstall(variant FamilyVariant, opts bootstrap.Options, projectDir stri } if opts.IncludeSample { - if err := helpers.RunMagentoSampleData(); err != nil { + if err := RunSampleData(helpers); err != nil { return err } } @@ -238,6 +241,38 @@ func FreshInstall(variant FamilyVariant, opts bootstrap.Options, projectDir stri return nil } +func RunSetupInstall(helpers bootstrap.CmdHelpers, args []string) error { + if helpers.IsPHPContainerRunning != nil && helpers.IsPHPContainerRunning() { + fix := []string{"exec", "-T", "php", "sh", "-c", "curl -s -X PUT 'http://elasticsearch:9200/_all/_settings' -H 'Content-Type: application/json' -d'{\"index.blocks.read_only_allow_delete\": null}' > /dev/null 2>&1 || true"} + if err := helpers.RunEnvironmentCommand(fix); err != nil { + fmt.Printf("Warning: failed to apply Elasticsearch block fix: %v\n", err) + } + } + if err := helpers.RunTool("magento", args); err != nil { + return fmt.Errorf("magento setup:install failed: %w", err) + } + return nil +} + +// RunSampleData executes Magento's ordered sample-data workflow through the +// generic tool transport owned by bootstrap.CmdHelpers. +func RunSampleData(helpers bootstrap.CmdHelpers) error { + for _, args := range [][]string{{"sample:deploy"}, {"setup:upgrade"}, {"indexer:reindex"}, {"cache:flush"}} { + if err := helpers.RunTool("magento", args); err != nil { + return fmt.Errorf("sample data step failed (%s): %w", strings.Join(args, " "), err) + } + } + return nil +} + +func BootstrapPlanSteps(createAdmin bool) []types.BootstrapPlanStep { + steps := []types.BootstrapPlanStep{{Description: "Configuring framework environment...", Command: "govard config auto"}} + if createAdmin { + steps = append(steps, types.BootstrapPlanStep{Description: "Creating framework admin user...", Command: "govard tool magento admin:user:create ..."}) + } + return append(steps, types.BootstrapPlanStep{Description: "Reindexing framework data...", Command: "govard tool magento indexer:reindex"}) +} + // PreConfigure is Magento 2/Mage-OS's PreConfigureHook: it just generates // app/etc/env.php before `govard config auto` runs. No variant // parameterization needed - env.php generation (crypt key, DB connection @@ -246,22 +281,36 @@ func FreshInstall(variant FamilyVariant, opts bootstrap.Options, projectDir stri // right local DB credentials and probes the remote independently of which // Magento distribution this is). func PreConfigure(opts bootstrap.Options, projectDir string, helpers bootstrap.CmdHelpers) error { - return helpers.EnsureMagentoEnvPHP() + return helpers.EnsureFrameworkEnvironment() } // PostClone is Magento 2/Mage-OS's PostCloneHook: create the admin user // (if requested), then reindex. Moved verbatim from internal/cmd/ // bootstrap_remote.go's runBootstrapRemote (the -// `if opts.AdminCreate && engine.IsMagento2Family(...)` / -// `if engine.IsMagento2Family(...)` pair near the end of that function), +// former core family conditional near the end of that function), // same order, same error propagation (admin-create failures are -// swallowed by RunMagentoAdminCreate itself and never reach here; a -// reindex failure does propagate). +// swallowed by RunAdminCreate itself and never reach here; a reindex +// failure does propagate). func PostClone(opts bootstrap.Options, projectDir string, helpers bootstrap.CmdHelpers) error { if opts.AdminCreate { - if err := helpers.RunMagentoAdminCreate(); err != nil { - return err - } + RunAdminCreate(opts, helpers) + } + return RunReindex(helpers) +} + +func RunReindex(helpers bootstrap.CmdHelpers) error { + pterm.Info.Println("Reindexing Magento data...") + if helpers.IsPHPContainerRunning != nil && !helpers.IsPHPContainerRunning() { + return nil + } + return helpers.RunTool("magento", []string{"indexer:reindex"}) +} + +func RunAdminCreate(opts bootstrap.Options, helpers bootstrap.CmdHelpers) { + if helpers.IsPHPContainerRunning != nil && !helpers.IsPHPContainerRunning() { + return + } + if helpers.RunToolSilent != nil { + _ = helpers.RunToolSilent("magento", []string{"admin:user:create", "--admin-user=" + conventions.DefaultAdminUser, "--admin-password=" + conventions.DefaultAdminPassword, "--admin-firstname=Govard", "--admin-lastname=Admin", "--admin-email=" + conventions.AdminEmailForDomain(opts.Domain)}) } - return helpers.RunMagentoReindex() } diff --git a/internal/frameworks/magento2/bootstrap_environment.go b/internal/frameworks/magento2/bootstrap_environment.go new file mode 100644 index 00000000..16fbd2b3 --- /dev/null +++ b/internal/frameworks/magento2/bootstrap_environment.go @@ -0,0 +1,73 @@ +package magento2 + +import ( + "fmt" + + "govard/internal/conventions" + "govard/internal/engine" +) + +// BootstrapEnvironmentDatabase is the local database connection data required +// to render Magento's app/etc/env.php during a clone bootstrap. +type BootstrapEnvironmentDatabase struct { + Database string + Username string + Password string +} + +// BuildBootstrapEnvironment renders Magento's app/etc/env.php. Its contents +// are framework-owned; generic bootstrap orchestration only obtains the local +// database values and writes the declared environment file. +func BuildBootstrapEnvironment(cryptKey string, localDB BootstrapEnvironmentDatabase, tablePrefix string) string { + tablePrefix = engine.NormalizeTablePrefix(tablePrefix) + + return fmt.Sprintf(` [ + 'frontName' => %q + ], + 'crypt' => [ + 'key' => %q + ], + 'db' => [ + 'table_prefix' => %q, + 'connection' => [ + 'default' => [ + 'host' => %q, + 'dbname' => %q, + 'username' => %q, + 'password' => %q, + 'active' => '1' + ], + 'indexer' => [ + 'host' => %q, + 'dbname' => %q, + 'username' => %q, + 'password' => %q, + 'active' => '1' + ] + ] + ], + 'resource' => [ + 'default_setup' => [ + 'connection' => 'default' + ] + ], + 'x-frame-options' => 'SAMEORIGIN', + 'MAGE_MODE' => 'developer', + 'session' => [ + 'save' => 'files' + ], + 'install' => [ + 'date' => 'Mon, 01 May 2023 00:00:00 +0000' + ] +]; +`, conventions.DefaultAdminPath, + cryptKey, + tablePrefix, + conventions.DefaultMagentoDBHost, + localDB.Database, localDB.Username, localDB.Password, + conventions.DefaultMagentoDBHost, + localDB.Database, localDB.Username, localDB.Password, + ) +} diff --git a/internal/frameworks/magento2/deploy.go b/internal/frameworks/magento2/deploy.go new file mode 100644 index 00000000..38741ad5 --- /dev/null +++ b/internal/frameworks/magento2/deploy.go @@ -0,0 +1,6 @@ +package magento2 + +// BuildDeployLocalesQuery owns Magento 2's locale discovery query. +func BuildDeployLocalesQuery(tablePrefix string) string { + return "SELECT DISTINCT value FROM " + tablePrefix + "core_config_data WHERE path IN ('general/locale/code','general/locale/timezone') AND value REGEXP '^[a-z]{2}_[A-Z]{2}$';" +} diff --git a/internal/frameworks/magento2/embed.go b/internal/frameworks/magento2/embed.go new file mode 100644 index 00000000..509110e5 --- /dev/null +++ b/internal/frameworks/magento2/embed.go @@ -0,0 +1,37 @@ +package magento2 + +import ( + "embed" + "io/fs" + + "govard/internal/blueprints" +) + +// blueprintFiles holds magento2's own blueprint assets: the varnish VCL +// (relocated from internal/blueprints/files/magento2/varnish/default.vcl) +// and the nginx vhost template (relocated from +// internal/blueprints/files/support/nginx/templates/magento2.conf). +// +//go:embed all:blueprint +var blueprintFiles embed.FS + +// BlueprintFS is magento2's embedded blueprint sub-filesystem, rooted so +// paths match the legacy layout under internal/blueprints/files/magento2/ +// (e.g. "varnish/default.vcl", "magento2.conf" - not +// "blueprint/varnish/default.vcl"). +var BlueprintFS fs.FS + +func init() { + var err error + BlueprintFS, err = fs.Sub(blueprintFiles, "blueprint") + if err != nil { + panic(err) + } + + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "magento2", + FS: BlueprintFS, + HasDir: true, + NginxTemplate: "magento2.conf", + }) +} diff --git a/internal/frameworks/magento2/local_admin.go b/internal/frameworks/magento2/local_admin.go new file mode 100644 index 00000000..1cdea58e --- /dev/null +++ b/internal/frameworks/magento2/local_admin.go @@ -0,0 +1,96 @@ +package magento2 + +import ( + "os" + "path/filepath" + "regexp" + "strings" + + "govard/internal/conventions" +) + +var ( + frontNamePattern = regexp.MustCompile(`(?i)['"]frontName['"]\s*=>\s*['"]([^'"]+)['"]`) + tablePrefixPattern = regexp.MustCompile(`(?i)['"]table_prefix['"]\s*=>\s*['"]([^'"]*)['"]`) +) + +// DetectLocalAdminMetadata reads Magento's env.php backend front name and +// table prefix. Missing/unreadable configuration has no metadata. +func DetectLocalAdminMetadata(projectRoot string) (string, string) { + content, err := os.ReadFile(filepath.Join(projectRoot, "app", "etc", "env.php")) + if err != nil { + return "", "" + } + + raw := string(content) + frontName := "" + tablePrefix := "" + if match := frontNamePattern.FindStringSubmatch(raw); len(match) == 2 { + frontName = strings.Trim(strings.TrimSpace(match[1]), "/") + } + if match := tablePrefixPattern.FindStringSubmatch(raw); len(match) == 2 { + tablePrefix = strings.TrimSpace(match[1]) + } + return frontName, tablePrefix +} + +// BuildLocalAdminSettingsQuery returns Magento's core_config_data query for +// locally configured admin URL overrides. +func BuildLocalAdminSettingsQuery(tablePrefix string) string { + return "SELECT path, value FROM " + tablePrefix + "core_config_data" + + " WHERE path IN ('admin/url/use_custom','admin/url/use_custom_path','admin/url/custom','admin/url/custom_path')" +} + +// ResolveLocalAdminURL applies Magento's env.php and core_config_data admin +// routing rules to a local base URL. +func ResolveLocalAdminURL(baseURL string, envFrontName string, dbValues map[string]string) string { + frontName := strings.Trim(strings.TrimSpace(envFrontName), "/") + if frontName == "" { + frontName = conventions.DefaultAdminPath + } + + if truthyConfig(dbValues["admin/url/use_custom_path"]) { + if customPath := normalizeAdminTarget(dbValues["admin/url/custom_path"]); customPath != "" { + if isURLTarget(customPath) { + return customPath + } + return joinURLWithPath(baseURL, customPath) + } + } + + if truthyConfig(dbValues["admin/url/use_custom"]) { + if custom := normalizeAdminTarget(dbValues["admin/url/custom"]); custom != "" { + if isURLTarget(custom) { + return custom + } + return joinURLWithPath(baseURL, custom) + } + } + + return joinURLWithPath(baseURL, frontName) +} + +func truthyConfig(raw string) bool { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func normalizeAdminTarget(raw string) string { return strings.Trim(strings.TrimSpace(raw), "/") } + +func isURLTarget(raw string) bool { + value := strings.ToLower(strings.TrimSpace(raw)) + return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") +} + +func joinURLWithPath(baseURL string, path string) string { + base := strings.TrimRight(strings.TrimSpace(baseURL), "/") + trimmedPath := strings.Trim(strings.TrimSpace(path), "/") + if trimmedPath == "" { + return base + } + return base + "/" + trimmedPath +} diff --git a/internal/frameworks/magento2/magento2.go b/internal/frameworks/magento2/magento2.go index 78f1df06..9c1ec3c8 100644 --- a/internal/frameworks/magento2/magento2.go +++ b/internal/frameworks/magento2/magento2.go @@ -1,19 +1,44 @@ package magento2 import ( + "strings" + + "govard/internal/conventions" "govard/internal/engine" "govard/internal/engine/bootstrap" + "govard/internal/engine/remote" "govard/internal/engine/tunnel" "govard/internal/frameworks/types" + + "github.com/spf13/cobra" ) func Definition() types.FrameworkDefinition { return types.FrameworkDefinition{ - Name: "magento2", - Aliases: []string{"magento"}, - DisplayName: "Magento 2", - Config: config, - Manifest: Manifest, + Name: "magento2", + Aliases: []string{"magento", "m2"}, + DisplayName: "Magento 2", + MigrationTypes: types.MigrationTypes{DDEV: []string{"magento2"}, Warden: []string{"magento2"}}, + Config: config, + Manifest: Manifest, + DefaultDBCredentials: types.DefaultDBCredentials{ + Port: conventions.MySQLPort, + Username: conventions.DefaultMagentoDBUser, + Password: conventions.DefaultMagentoDBPass, + Database: conventions.DefaultMagentoDBName, + }, + DefaultChownDirectories: []string{conventions.DefaultWorkDir, conventions.HomeWWWData + "/.cache/composer"}, + PHPStanPaths: []string{"app/code", "app/design"}, + ComposerCodingStandard: types.ComposerCodingStandard{Package: "magento/magento-coding-standard", Standard: "Magento2"}, + ComposerAuth: types.ComposerAuthRequirement{Repository: "repo.magento.com", DisplayName: "Magento 2", CredentialURL: "https://marketplace.magento.com/customer/accessKeys/"}, + ToolCommands: []types.ToolCommand{ + {Name: "magento", Short: "Run Magento CLI commands", Binary: "php", PrependArgs: []string{"bin/magento"}}, + {Name: "magerun", Aliases: []string{"mr"}, Short: "Run n98-magerun commands", Binary: "n98-magerun"}, + }, + TestSuiteCommands: map[string]types.TestCommand{ + "mftf": {Label: "MFTF Tests", Binary: "php", Args: []string{"vendor/bin/mftf", "run:group"}}, + "integration": {Label: "Magento 2 Integration Tests", Binary: "php", Args: []string{"-c", "dev/tests/integration/phpunit.xml", "vendor/bin/phpunit"}}, + }, Detect: engine.DetectionSpec{ ComposerPackages: []string{"magento/product-community-edition", "magento/product-enterprise-edition", "magento/framework"}, AuthJSONHosts: []string{"repo.magento.com"}, @@ -24,11 +49,68 @@ func Definition() types.FrameworkDefinition { BaseURLManager: func() tunnel.BaseURLManager { return &Magento2Manager{} }, - FreshInstall: freshInstall, - PreConfigureHook: PreConfigure, - PostCloneHook: PostClone, - FreshInstallNeedsDomain: true, - SupportsBootstrap: true, - SupportsFreshInstall: true, + FreshInstall: freshInstall, + PreConfigureHook: PreConfigure, + PostCloneHook: PostClone, + FreshInstallNeedsDomain: true, + SupportsBootstrap: true, + SupportsFreshInstall: true, + MinimumBootstrapVersion: "2.0.0", + DefaultFreshMetaPackage: "magento/project-community-edition", + PHPImageVariant: "magento2", + DBDriverCategory: "magento", + Upgrade: Upgrade, + RunMappingAssetPreparer: PrepareRunMappingAssets, + TablePrefixDetector: DetectTablePrefix, + ResolveBootstrapTablePrefix: ResolveBootstrapTablePrefix, + BuildDeployLocalesQuery: BuildDeployLocalesQuery, + BootstrapPlanSteps: BootstrapPlanSteps, + EnableVarnishOnInit: true, + VersionProfileResolver: ResolveVersionProfile, + ProbeRemoteDB: func(remoteName string, remoteCfg engine.RemoteConfig) (remote.RemoteDatabaseMetadata, error) { + metadata, err := ProbeMagento2Environment(remoteName, remoteCfg) + return metadata.DB, err + }, + ProbeRemoteBootstrapMetadata: func(remoteName string, remoteCfg engine.RemoteConfig) (remote.RemoteDatabaseMetadata, error) { + metadata, err := ProbeMagento2Environment(remoteName, remoteCfg) + if err != nil { + return remote.RemoteDatabaseMetadata{}, err + } + metadata.DB.Private = map[string]string{"crypt_key": metadata.CryptKey} + return metadata.DB, nil + }, + RemoteDBUsesConfigTablePrefix: true, + ResolveRemoteAdminPath: DetectRemoteAdminPath, + DetectLocalAdminMetadata: DetectLocalAdminMetadata, + BuildLocalAdminSettingsQuery: BuildLocalAdminSettingsQuery, + ResolveLocalAdminURL: ResolveLocalAdminURL, + ConfigureAfterProfileShift: func(config engine.Config, shift *engine.ProfileShiftInfo) error { + return ConfigureMagento(config.ProjectName, config, false, shift) + }, + PostSync: func(config engine.Config) error { + return FixProjectPermissions(config.ProjectName, config) + }, + UnblockSearchIndex: func(config engine.Config) error { + return FixElasticsearchIndexBlock(config.ProjectName, config) + }, + BuildSearchHostFixSQL: func(config engine.Config) string { + host := "elasticsearch" + if configured := strings.ToLower(strings.TrimSpace(config.Stack.Services.Search)); configured != "" && configured != "none" { + host = configured + } + return BuildMagentoSearchHostFixSQL(host, ResolveMagentoSearchEngine(config)) + }, + BootstrapEnvironmentPath: "app/etc/env.php", + BootstrapEnvironmentMetadataKey: "crypt_key", + RenderBootstrapEnvironment: func(secret string, database types.BootstrapEnvironmentDatabase, tablePrefix string) string { + return BuildBootstrapEnvironment(secret, BootstrapEnvironmentDatabase{ + Database: database.Database, + Username: database.Username, + Password: database.Password, + }, tablePrefix) + }, + AutoConfigure: func(cmd *cobra.Command, config engine.Config) error { + return ConfigureMagento(config.ProjectName, config, true, nil) + }, } } diff --git a/internal/engine/remote/magento_metadata.go b/internal/frameworks/magento2/metadata.go similarity index 68% rename from internal/engine/remote/magento_metadata.go rename to internal/frameworks/magento2/metadata.go index b56e3876..f24a8a5d 100644 --- a/internal/engine/remote/magento_metadata.go +++ b/internal/frameworks/magento2/metadata.go @@ -1,46 +1,39 @@ -package remote +package magento2 import ( "encoding/base64" "encoding/json" "fmt" - "net" "regexp" - "strconv" "strings" - "govard/internal/conventions" "govard/internal/engine" + remote "govard/internal/engine/remote" ) var magentoVersionPattern = regexp.MustCompile(`\d+\.\d+(?:\.\d+)?(?:-p\d+)?`) -type MagentoDBInfo struct { - Host string - Port int - Username string - Password string - Database string - TablePrefix string -} - type Magento2Environment struct { - DB MagentoDBInfo + DB remote.RemoteDatabaseMetadata CryptKey string } func ProbeMagento2Environment(remoteName string, remoteCfg engine.RemoteConfig) (Magento2Environment, error) { - remoteCommand := buildMagentoRemoteCommand(remoteCfg.Path, `php -r `+engine.ShellQuote(magentoDBProbePHP)) - encoded, err := runRemoteCapture(remoteName, remoteCfg, remoteCommand) + remoteCommand := remote.BuildProjectRemoteCommand(remoteCfg.Path, `php -r `+engine.ShellQuote(magentoDBProbePHP)) + encoded, err := remote.RunRemoteCapture(remoteName, remoteCfg, remoteCommand) if err != nil { return Magento2Environment{}, err } return decodeMagento2EnvironmentPayload(encoded) } +// DetectMagento2Version is currently unused anywhere in the codebase +// (confirmed by a whole-repo grep this session) - kept during this move +// since deleting dead-but-exported code is a separate decision from this +// refactor's scope, not bundled in here. func DetectMagento2Version(remoteName string, remoteCfg engine.RemoteConfig) (string, error) { - remoteCommand := buildMagentoRemoteCommand(remoteCfg.Path, `php -r `+engine.ShellQuote(magentoVersionProbePHP)) - output, err := runRemoteCapture(remoteName, remoteCfg, remoteCommand) + remoteCommand := remote.BuildProjectRemoteCommand(remoteCfg.Path, `php -r `+engine.ShellQuote(magentoVersionProbePHP)) + output, err := remote.RunRemoteCapture(remoteName, remoteCfg, remoteCommand) if err != nil { return "", err } @@ -51,41 +44,6 @@ func DetectMagento2Version(remoteName string, remoteCfg engine.RemoteConfig) (st return version, nil } -func ParseMagentoDBHostPort(raw string) (string, int) { - hostRaw := strings.TrimSpace(raw) - if hostRaw == "" { - return conventions.DefaultDBHost, conventions.MySQLPort - } - - hostRaw = strings.TrimPrefix(hostRaw, "tcp://") - if hostRaw == "" { - return conventions.DefaultDBHost, conventions.MySQLPort - } - - if host, port, err := net.SplitHostPort(hostRaw); err == nil { - if parsed, parseErr := strconv.Atoi(port); parseErr == nil && parsed > 0 { - if strings.TrimSpace(host) == "" { - host = conventions.DefaultDBHost - } - return host, parsed - } - } - - if strings.Count(hostRaw, ":") == 1 { - parts := strings.SplitN(hostRaw, ":", 2) - portText := strings.TrimSpace(parts[1]) - if parsed, err := strconv.Atoi(portText); err == nil && parsed > 0 { - host := strings.TrimSpace(parts[0]) - if host == "" { - host = conventions.DefaultDBHost - } - return host, parsed - } - } - - return hostRaw, conventions.MySQLPort -} - func NormalizeMagentoVersion(raw string) string { return normalizeMagentoVersion(raw) } @@ -117,7 +75,7 @@ func decodeMagento2EnvironmentPayload(encoded string) (Magento2Environment, erro return Magento2Environment{}, fmt.Errorf("parse remote probe payload: %w", err) } - host, port := ParseMagentoDBHostPort(payload.Host) + host, port := remote.ParseDatabaseHostPort(payload.Host) username := strings.TrimSpace(payload.Username) database := strings.TrimSpace(payload.DBName) if username == "" || database == "" { @@ -125,7 +83,7 @@ func decodeMagento2EnvironmentPayload(encoded string) (Magento2Environment, erro } return Magento2Environment{ - DB: MagentoDBInfo{ + DB: remote.RemoteDatabaseMetadata{ Host: host, Port: port, Username: username, @@ -137,14 +95,6 @@ func decodeMagento2EnvironmentPayload(encoded string) (Magento2Environment, erro }, nil } -func buildMagentoRemoteCommand(projectPath string, body string) string { - trimmedPath := strings.TrimSpace(projectPath) - if trimmedPath == "" { - return body - } - return "cd " + QuoteRemotePath(trimmedPath) + " && " + body -} - func normalizeMagentoVersion(raw string) string { cleaned := strings.TrimSpace(raw) if cleaned == "" { diff --git a/internal/engine/magento.go b/internal/frameworks/magento2/postinstall.go similarity index 93% rename from internal/engine/magento.go rename to internal/frameworks/magento2/postinstall.go index a819bfef..464e4bcf 100644 --- a/internal/engine/magento.go +++ b/internal/frameworks/magento2/postinstall.go @@ -1,4 +1,4 @@ -package engine +package magento2 import ( "bytes" @@ -13,6 +13,7 @@ import ( "time" "govard/internal/conventions" + "govard/internal/engine" "github.com/pterm/pterm" ) @@ -29,14 +30,14 @@ const ( ) // MagentoConfigCommandsForTest exposes command planning for tests. -func MagentoConfigCommandsForTest(projectName string, config Config) []magentoCommand { +func MagentoConfigCommandsForTest(projectName string, config engine.Config) []magentoCommand { return buildFrameworkAutoConfigurationCommands(projectName, config) } // ConfigureMagento runs post-startup Magento configuration. If shiftInfo is provided // and indicates a shift, it will perform cleanup and reconfiguration. If shiftInfo // is nil, it will auto-detect (legacy behavior). -func ConfigureMagento(projectName string, config Config, force bool, shiftInfo *ProfileShiftInfo) error { +func ConfigureMagento(projectName string, config engine.Config, force bool, shiftInfo *engine.ProfileShiftInfo) error { shifted := force reason := "manual trigger" @@ -48,14 +49,14 @@ func ConfigureMagento(projectName string, config Config, force bool, shiftInfo * } } else if !force { // Legacy: auto-detect if no pre-detected info provided - shifted, reason = checkProfileShiftCleanup(config) + shifted, reason = engine.CheckProfileShiftCleanup(config) } if !shifted { return nil } - frameworkName := Magento2FamilyDisplayName(config.Framework) + frameworkName := displayNameForDistribution(config.Framework) pterm.Info.Printf("Configuring %s environment (%s)...\n", frameworkName, reason) if err := FixProjectPermissions(projectName, config); err != nil { @@ -201,6 +202,13 @@ func ConfigureMagento(projectName string, config Config, force bool, shiftInfo * return nil } +func displayNameForDistribution(framework string) string { + if strings.EqualFold(strings.TrimSpace(framework), "mageos") { + return "Mage-OS" + } + return "Magento 2" +} + func needsConfigImport(output string) bool { output = strings.ToLower(output) @@ -235,7 +243,7 @@ func IsElasticsearchIndexBlockError(output string) bool { (strings.Contains(output, "403") && (strings.Contains(output, "blocked") || strings.Contains(output, "forbidden"))) } -func FixElasticsearchIndexBlock(projectName string, config Config) error { +func FixElasticsearchIndexBlock(projectName string, config engine.Config) error { containerName := fmt.Sprintf("%s-elasticsearch-1", projectName) // We use curl inside the elasticsearch container to reset the read-only setting. // This approach works for both Elasticsearch and OpenSearch. @@ -270,7 +278,7 @@ func isMagentoConfigPathUnavailable(output string) bool { return false } -func runMagentoConfigImport(containerName string, config Config) error { +func runMagentoConfigImport(containerName string, config engine.Config) error { args := magentoDockerExecArgs(containerName, config, conventions.BinMagento, "app:config:import", "--no-interaction") output, err := exec.Command("docker", args...).CombinedOutput() if err != nil { @@ -279,7 +287,7 @@ func runMagentoConfigImport(containerName string, config Config) error { return nil } -func runMagentoSetupUpgrade(containerName string, config Config) error { +func runMagentoSetupUpgrade(containerName string, config engine.Config) error { args := magentoDockerExecArgs(containerName, config, conventions.BinMagento, "setup:upgrade", "--no-interaction") output, err := exec.Command("docker", args...).CombinedOutput() if err != nil { @@ -288,7 +296,7 @@ func runMagentoSetupUpgrade(containerName string, config Config) error { return nil } -func runMagentoComposerDumpAutoload(containerName string, config Config) error { +func runMagentoComposerDumpAutoload(containerName string, config engine.Config) error { args := magentoDockerExecArgs(containerName, config, "composer", "dump-autoload") output, err := exec.Command("docker", args...).CombinedOutput() if err != nil { @@ -297,7 +305,7 @@ func runMagentoComposerDumpAutoload(containerName string, config Config) error { return nil } -func ensureMagentoLocalWritableDirs(containerName string, config Config) error { +func ensureMagentoLocalWritableDirs(containerName string, config engine.Config) error { script := strings.Join([]string{ "set -e", `fix_dir() { p="$1"; if [ -L "$p" ]; then rm -f "$p"; fi; mkdir -p "$p"; }`, @@ -321,7 +329,7 @@ func ensureMagentoLocalWritableDirs(containerName string, config Config) error { return nil } -func buildFrameworkAutoConfigurationCommands(projectName string, config Config) []magentoCommand { +func buildFrameworkAutoConfigurationCommands(projectName string, config engine.Config) []magentoCommand { switch strings.ToLower(config.Framework) { case "magento1", "openmage": return buildMagento1Commands(projectName, config) @@ -330,7 +338,7 @@ func buildFrameworkAutoConfigurationCommands(projectName string, config Config) } } -func buildMagento2Commands(projectName string, config Config, lockedKeys map[string]bool) []magentoCommand { +func buildMagento2Commands(projectName string, config engine.Config, lockedKeys map[string]bool) []magentoCommand { containerName := fmt.Sprintf("%s%s", projectName, conventions.PHPSuffix) searchEngine := ResolveMagentoSearchEngine(config) @@ -346,7 +354,7 @@ func buildMagento2Commands(projectName string, config Config, lockedKeys map[str "--db-user=" + dbUser, "--db-password=" + dbPass, } - if tablePrefix := NormalizeTablePrefix(config.TablePrefix); tablePrefix != "" { + if tablePrefix := engine.NormalizeTablePrefix(config.TablePrefix); tablePrefix != "" { configSetArgs = append(configSetArgs, "--db-prefix="+tablePrefix) } configSetArgs = append(configSetArgs, "--no-interaction") @@ -532,7 +540,7 @@ func buildMagento2Commands(projectName string, config Config, lockedKeys map[str return commands } -func ConfigureMagento1(projectName string, config Config) error { +func ConfigureMagento1(projectName string, config engine.Config) error { pterm.Info.Println("Configuring Magento 1 environment...") commands := buildMagento1Commands(projectName, config) @@ -555,10 +563,10 @@ func ConfigureMagento1(projectName string, config Config) error { return nil } -func buildMagento1Commands(projectName string, config Config) []magentoCommand { +func buildMagento1Commands(projectName string, config engine.Config) []magentoCommand { containerName := fmt.Sprintf("%s%s", projectName, conventions.DBSuffix) commands := make([]magentoCommand, 0) - tablePrefix := NormalizeTablePrefix(config.TablePrefix) + tablePrefix := engine.NormalizeTablePrefix(config.TablePrefix) if config.Domain != "" { baseURL := fmt.Sprintf("https://%s/", config.Domain) @@ -598,7 +606,7 @@ func buildMagento1Commands(projectName string, config Config) []magentoCommand { return commands } -func buildMagentoSearchConfigSetCommands(containerName string, config Config, engineName string) []magentoCommand { +func buildMagentoSearchConfigSetCommands(containerName string, config engine.Config, engineName string) []magentoCommand { prefix := resolveMagentoSearchConfigPrefix(engineName) if prefix == "" { return nil @@ -646,7 +654,7 @@ func resolveMagentoSearchConfigPrefix(engineName string) string { } } -func ResolveMagentoSearchEngine(config Config) string { +func ResolveMagentoSearchEngine(config engine.Config) string { // ElasticSuite must remain the selected engine when the module is present. // Forcing elasticsearch7/opensearch breaks Smile query objects on Magento 2.4.7 stacks. if isMagentoElasticsuiteProject() { @@ -694,7 +702,7 @@ func isMagentoElasticsuiteProject() bool { } func isMagentoVersionAtLeast(raw string, minimum string) bool { - return IsNumericDotVersionAtLeast(raw, minimum) + return engine.IsNumericDotVersionAtLeast(raw, minimum) } // BuildMagentoSearchHostFixSQL returns the SQL query needed to fix the search host in the database. @@ -731,7 +739,7 @@ func BuildMagentoSearchHostFixSQL(host string, searchEngine string) string { return sql } -func magentoDockerExecArgs(containerName string, config Config, args ...string) []string { +func magentoDockerExecArgs(containerName string, config engine.Config, args ...string) []string { result := []string{"exec"} if user := resolveMagentoExecUser(config); strings.TrimSpace(user) != "" { result = append(result, "-u", user) @@ -744,7 +752,7 @@ func magentoDockerExecArgs(containerName string, config Config, args ...string) func magento1DockerSQLExecArgs(containerName string, sql string) []string { script := fmt.Sprintf( `if command -v mysql >/dev/null 2>&1; then DB_CLI=mysql; elif command -v mariadb >/dev/null 2>&1; then DB_CLI=mariadb; else exit 1; fi && echo %s | "$DB_CLI" -u %s %s -f`, - ShellQuote(sql), ShellQuote(conventions.DefaultMagentoDBUser), ShellQuote(conventions.DefaultMagentoDBName), + engine.ShellQuote(sql), engine.ShellQuote(conventions.DefaultMagentoDBUser), engine.ShellQuote(conventions.DefaultMagentoDBName), ) return []string{ @@ -755,14 +763,14 @@ func magento1DockerSQLExecArgs(containerName string, sql string) []string { } } -func resolveMagentoExecUser(config Config) string { +func resolveMagentoExecUser(config engine.Config) string { if config.Stack.UserID > 0 && config.Stack.GroupID > 0 { return fmt.Sprintf("%d:%d", config.Stack.UserID, config.Stack.GroupID) } return conventions.UserWWWData } -func FixProjectPermissions(projectName string, config Config) error { +func FixProjectPermissions(projectName string, config engine.Config) error { containerName := fmt.Sprintf("%s%s", projectName, conventions.PHPSuffix) if len(config.Stack.ChownDirList) == 0 { return nil @@ -772,7 +780,7 @@ func FixProjectPermissions(projectName string, config Config) error { dirs := "" for _, d := range config.Stack.ChownDirList { - dirs += fmt.Sprintf("%s ", ShellQuote(d)) + dirs += fmt.Sprintf("%s ", engine.ShellQuote(d)) } // We build a robust shell loop that forces root privileges and complies with Adobe best practices (including bin/magento executable). @@ -800,7 +808,7 @@ func IsMagentoConfigPathUnavailableForTest(output string) bool { } // CheckMagentoEnvPHPLockedKeys inspects app/etc/env.php for keys that are hardcoded (locked). -func CheckMagentoEnvPHPLockedKeys(containerName string, config Config) (map[string]bool, error) { +func CheckMagentoEnvPHPLockedKeys(containerName string, config engine.Config) (map[string]bool, error) { // PHP snippet to check for locked keys in env.php script := ` $env = @include 'app/etc/env.php'; @@ -840,7 +848,7 @@ echo implode(',', $found); return results, nil } -func wipeMagentoGeneratedCaches(projectName string, config Config) error { +func wipeMagentoGeneratedCaches(projectName string, config engine.Config) error { containerName := fmt.Sprintf("%s%s", projectName, conventions.PHPSuffix) // We wipe generated and cache dirs. // rm -rf is safe because ensureMagentoLocalWritableDirs will recreate them if needed. @@ -859,7 +867,7 @@ var runMagentoComposerInstallFn = runMagentoComposerInstall // SetMagentoComposerInstallRunnerForTest overrides the composer-install runner used by // maybeRunMagentoComposerInstall for tests. -func SetMagentoComposerInstallRunnerForTest(fn func(projectName string, config Config, stdout, stderr io.Writer) error) func() { +func SetMagentoComposerInstallRunnerForTest(fn func(projectName string, config engine.Config, stdout, stderr io.Writer) error) func() { prev := runMagentoComposerInstallFn runMagentoComposerInstallFn = fn return func() { @@ -868,7 +876,7 @@ func SetMagentoComposerInstallRunnerForTest(fn func(projectName string, config C } // MaybeRunMagentoComposerInstallForTest exposes maybeRunMagentoComposerInstall for tests in /tests. -func MaybeRunMagentoComposerInstallForTest(projectName string, config Config) { +func MaybeRunMagentoComposerInstallForTest(projectName string, config engine.Config) { maybeRunMagentoComposerInstall(projectName, config) } @@ -876,10 +884,10 @@ func MaybeRunMagentoComposerInstallForTest(projectName string, config Config) { // current working directory's vendor/ already satisfies composer.lock, in which case it is // skipped (this is what previously made `govard config auto` retry a private-repo // authentication failure that a remote vendor-sync fallback had already recovered from). -func maybeRunMagentoComposerInstall(projectName string, config Config) { +func maybeRunMagentoComposerInstall(projectName string, config engine.Config) { skipComposerInstall := false if projectRoot, rootErr := os.Getwd(); rootErr == nil { - skipComposerInstall, _ = VendorSatisfiesComposerLock(projectRoot) + skipComposerInstall, _ = engine.VendorSatisfiesComposerLock(projectRoot) } if skipComposerInstall { @@ -895,7 +903,7 @@ func maybeRunMagentoComposerInstall(projectName string, config Config) { } } -func runMagentoComposerInstall(projectName string, config Config, stdout, stderr io.Writer) error { +func runMagentoComposerInstall(projectName string, config engine.Config, stdout, stderr io.Writer) error { containerName := fmt.Sprintf("%s%s", projectName, conventions.PHPSuffix) // Protect the current (presumably working) vendor/composer + vendor/autoload.php @@ -1017,7 +1025,7 @@ func runMagentoComposerInstall(projectName string, config Config, stdout, stderr // rolled back instead of leaving the environment without an autoloader. Best-effort: // if this fails, runMagentoComposerInstall proceeds without a safety net, exactly as // it did before this existed. -func backupMagentoComposerRuntime(containerName string, config Config) { +func backupMagentoComposerRuntime(containerName string, config engine.Config) { script := `rm -rf vendor/.composer-backup mkdir -p vendor/.composer-backup if [ -d vendor/composer ]; then mv vendor/composer vendor/.composer-backup/composer; fi @@ -1028,7 +1036,7 @@ if [ -f vendor/autoload.php ]; then mv vendor/autoload.php vendor/.composer-back // restoreMagentoComposerRuntimeBackup undoes backupMagentoComposerRuntime: it discards // whatever partial vendor/composer state a failed composer install left behind and // restores the pre-attempt vendor/composer and vendor/autoload.php. Best-effort. -func restoreMagentoComposerRuntimeBackup(containerName string, config Config) { +func restoreMagentoComposerRuntimeBackup(containerName string, config engine.Config) { script := `if [ -d vendor/.composer-backup ]; then rm -rf vendor/composer vendor/autoload.php if [ -d vendor/.composer-backup/composer ]; then mv vendor/.composer-backup/composer vendor/composer; fi @@ -1040,11 +1048,11 @@ fi` // discardMagentoComposerRuntimeBackup removes the backup taken by // backupMagentoComposerRuntime once composer install has succeeded and the backup is // no longer needed. Best-effort. -func discardMagentoComposerRuntimeBackup(containerName string, config Config) { +func discardMagentoComposerRuntimeBackup(containerName string, config engine.Config) { _ = exec.Command("docker", magentoDockerExecArgs(containerName, config, "sh", "-c", "rm -rf vendor/.composer-backup")...).Run() } -func flushMagentoRedisCache(projectName string, config Config) error { +func flushMagentoRedisCache(projectName string, config engine.Config) error { containerName := fmt.Sprintf("%s%s", projectName, conventions.RedisSuffix) // 1. Wait for Redis/Valkey to be ready (up to 10 seconds) @@ -1110,13 +1118,13 @@ func flushMagentoRedisCache(projectName string, config Config) error { return nil } -func prepareMagentoRunMappingAssets(config Config) (string, string, error) { - if !isMagentoFramework(config.Framework) || strings.TrimSpace(config.ProjectName) == "" { +func PrepareRunMappingAssets(config engine.Config) (string, string, error) { + if strings.TrimSpace(config.ProjectName) == "" { return "", "", nil } - nginxPath := filepath.Join(GovardHomeDir(), "nginx", config.ProjectName, "mage-run-map.conf") - apachePath := filepath.Join(GovardHomeDir(), "apache", config.ProjectName, "mage-run-map.conf") + nginxPath := filepath.Join(engine.GovardHomeDir(), "nginx", config.ProjectName, "mage-run-map.conf") + apachePath := filepath.Join(engine.GovardHomeDir(), "apache", config.ProjectName, "mage-run-map.conf") if err := os.MkdirAll(filepath.Dir(nginxPath), conventions.DefaultDirPerm); err != nil { return "", "", err @@ -1135,25 +1143,13 @@ func prepareMagentoRunMappingAssets(config Config) (string, string, error) { return nginxPath, apachePath, nil } -func isMagentoFramework(framework string) bool { - if IsMagento2Family(framework) { - return true - } - switch strings.ToLower(strings.TrimSpace(framework)) { - case "magento1", "openmage": - return true - default: - return false - } -} - -func buildMagentoNginxRunMap(mappings StoreDomainMappings) string { +func buildMagentoNginxRunMap(mappings engine.StoreDomainMappings) string { lines := []string{ "map $host $mage_run_code {", ` default "";`, } - typedHosts := sortedStoreDomainHosts(mappings) + typedHosts := engine.SortedStoreDomainHosts(mappings) for _, host := range typedHosts { mapping := mappings[host] if mapping.ScopeType() == "" || mapping.ScopeCode() == "" { @@ -1173,9 +1169,9 @@ func buildMagentoNginxRunMap(mappings StoreDomainMappings) string { return strings.Join(lines, "\n") } -func buildMagentoApacheRunMap(mappings StoreDomainMappings) string { +func buildMagentoApacheRunMap(mappings engine.StoreDomainMappings) string { lines := []string{"# Generated by Govard"} - for _, host := range sortedStoreDomainHosts(mappings) { + for _, host := range engine.SortedStoreDomainHosts(mappings) { mapping := mappings[host] if mapping.ScopeType() == "" || mapping.ScopeCode() == "" { continue @@ -1262,12 +1258,12 @@ func BuildMagento1StoreBaseURLSQLStatements(scopeCode string, baseURL string, db } } -// CheckProfileShiftCleanupForTest exposes checkProfileShiftCleanup for testing. -func CheckProfileShiftCleanupForTest(config Config) (bool, string) { - return checkProfileShiftCleanup(config) +// CheckProfileShiftCleanupForTest exposes engine.CheckProfileShiftCleanup for testing. +func CheckProfileShiftCleanupForTest(config engine.Config) (bool, string) { + return engine.CheckProfileShiftCleanup(config) } -// DetectProfileShiftForTest exposes DetectProfileShift for testing. -func DetectProfileShiftForTest(config Config) ProfileShiftInfo { - return DetectProfileShift(config) +// DetectProfileShiftForTest exposes engine.DetectProfileShift for testing. +func DetectProfileShiftForTest(config engine.Config) engine.ProfileShiftInfo { + return engine.DetectProfileShift(config) } diff --git a/internal/frameworks/magento2/profile.go b/internal/frameworks/magento2/profile.go new file mode 100644 index 00000000..77130954 --- /dev/null +++ b/internal/frameworks/magento2/profile.go @@ -0,0 +1,234 @@ +package magento2 + +import ( + "embed" + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + + "govard/internal/engine" +) + +//go:embed profiles.json +var profilesJSON embed.FS + +type profileStack struct { + SearchVersion string `json:"search_version"` + VarnishVersion string `json:"varnish_version"` + NginxVersion string `json:"nginx_version"` + QueueVersion string `json:"queue_version"` + CacheVersion string `json:"cache_version"` +} + +type profileRule struct { + Min int `json:"min"` + Stack string `json:"stack"` + PHPVersion string `json:"php_version"` + DBVersion string `json:"db_version"` + Cache string `json:"cache,omitempty"` + Search string `json:"search,omitempty"` + SearchVersion string `json:"search_version,omitempty"` + VarnishVersion string `json:"varnish_version"` + NginxVersion string `json:"nginx_version"` + QueueVersion string `json:"queue_version"` + CacheVersion string `json:"cache_version"` + ComposerVersion string `json:"composer_version,omitempty"` +} + +type patchVariant struct { + Patch *int `json:"patch,omitempty"` + PatchMin *int `json:"patch_min,omitempty"` + PatchMax *int `json:"patch_max,omitempty"` + PHPVersion string `json:"php_version"` + DBVersion string `json:"db_version"` + Cache string `json:"cache,omitempty"` + CacheVersion string `json:"cache_version,omitempty"` + Search string `json:"search,omitempty"` + SearchVersion string `json:"search_version,omitempty"` + QueueVersion string `json:"queue_version"` + VarnishVersion string `json:"varnish_version"` + NginxVersion string `json:"nginx_version"` + ComposerVersion string `json:"composer_version,omitempty"` + Rules []profileRule `json:"rules"` +} + +type versionGroup struct { + Major int `json:"major"` + Minor int `json:"minor"` + Defaults map[string]string `json:"defaults"` + Patches []patchVariant `json:"patches"` +} + +type profileRegistry struct { + Stacks map[string]profileStack `json:"stacks"` + Versions []versionGroup `json:"versions"` +} + +var versionPattern = regexp.MustCompile(`\d+\.\d+\.\d+(?:-p\d+)?`) +var profiles profileRegistry + +func init() { + if data, err := profilesJSON.ReadFile("profiles.json"); err == nil { + _ = json.Unmarshal(data, &profiles) + } +} + +// ResolveVersionProfile owns Magento 2's patch-level runtime compatibility +// matrix. Mage-OS intentionally clears this inherited resolver because its +// versioning is independent from Magento 2. +func ResolveVersionProfile(version string) (engine.VersionProfileOverride, string, bool) { + major, minor, patch, pPatch, ok := parseVersion(version) + if !ok { + return engine.VersionProfileOverride{}, "", false + } + + for _, group := range profiles.Versions { + if group.Major != major || group.Minor != minor { + continue + } + for _, variant := range group.Patches { + if variant.Patch != nil && *variant.Patch != patch { + continue + } + if variant.PatchMin != nil && patch < *variant.PatchMin { + continue + } + if variant.PatchMax != nil && patch > *variant.PatchMax { + continue + } + + override := engine.VersionProfileOverride{ + DB: group.Defaults["db"], + Cache: group.Defaults["cache"], + Search: group.Defaults["search"], + Queue: group.Defaults["queue"], + WebRoot: group.Defaults["web_root"], + PHPVersion: variant.PHPVersion, + DBVersion: variant.DBVersion, + } + applyPatchBaselines(&override, variant) + + for i := range variant.Rules { + if pPatch < variant.Rules[i].Min { + continue + } + if stack, exists := profiles.Stacks[variant.Rules[i].Stack]; exists { + applyStack(&override, stack) + } + applyRule(&override, variant.Rules[i]) + break + } + + return override, fmt.Sprintf("version-specific:magento2@%d.%d.%d-p%d", major, minor, patch, pPatch), true + } + } + return engine.VersionProfileOverride{}, "", false +} + +func parseVersion(version string) (major, minor, patch, pPatch int, ok bool) { + version = strings.TrimSpace(strings.TrimPrefix(version, "v")) + if match := versionPattern.FindString(version); match != "" { + version = match + } + parts := strings.SplitN(version, "-p", 2) + core := strings.Split(parts[0], ".") + if len(core) != 3 { + return 0, 0, 0, 0, false + } + var err error + if major, err = strconv.Atoi(core[0]); err != nil { + return 0, 0, 0, 0, false + } + if minor, err = strconv.Atoi(core[1]); err != nil { + return 0, 0, 0, 0, false + } + if patch, err = strconv.Atoi(core[2]); err != nil { + return 0, 0, 0, 0, false + } + if len(parts) == 2 && parts[1] != "" { + if pPatch, err = strconv.Atoi(parts[1]); err != nil { + return 0, 0, 0, 0, false + } + } + return major, minor, patch, pPatch, true +} + +func applyPatchBaselines(o *engine.VersionProfileOverride, variant patchVariant) { + if variant.Cache != "" { + o.Cache = variant.Cache + } + if variant.Search != "" { + o.Search = variant.Search + } + if variant.CacheVersion != "" { + o.CacheVersion = variant.CacheVersion + } + if variant.SearchVersion != "" { + o.SearchVersion = variant.SearchVersion + } + if variant.QueueVersion != "" { + o.QueueVersion = variant.QueueVersion + } + if variant.VarnishVersion != "" { + o.VarnishVersion = variant.VarnishVersion + } + if variant.NginxVersion != "" { + o.NginxVersion = variant.NginxVersion + } + if variant.ComposerVersion != "" { + o.ComposerVersion = variant.ComposerVersion + } +} + +func applyStack(o *engine.VersionProfileOverride, stack profileStack) { + if stack.SearchVersion != "" { + o.SearchVersion = stack.SearchVersion + } + if stack.VarnishVersion != "" { + o.VarnishVersion = stack.VarnishVersion + } + if stack.NginxVersion != "" { + o.NginxVersion = stack.NginxVersion + } + if stack.QueueVersion != "" { + o.QueueVersion = stack.QueueVersion + } + if stack.CacheVersion != "" { + o.CacheVersion = stack.CacheVersion + } +} + +func applyRule(o *engine.VersionProfileOverride, rule profileRule) { + if rule.PHPVersion != "" { + o.PHPVersion = rule.PHPVersion + } + if rule.DBVersion != "" { + o.DBVersion = rule.DBVersion + } + if rule.Cache != "" { + o.Cache = rule.Cache + } + if rule.Search != "" { + o.Search = rule.Search + } + if rule.SearchVersion != "" { + o.SearchVersion = rule.SearchVersion + } + if rule.ComposerVersion != "" { + o.ComposerVersion = rule.ComposerVersion + } + if rule.VarnishVersion != "" { + o.VarnishVersion = rule.VarnishVersion + } + if rule.NginxVersion != "" { + o.NginxVersion = rule.NginxVersion + } + if rule.QueueVersion != "" { + o.QueueVersion = rule.QueueVersion + } + if rule.CacheVersion != "" { + o.CacheVersion = rule.CacheVersion + } +} diff --git a/internal/frameworks/magento2/profiles.json b/internal/frameworks/magento2/profiles.json new file mode 100644 index 00000000..b1abee3a --- /dev/null +++ b/internal/frameworks/magento2/profiles.json @@ -0,0 +1,404 @@ +{ + "stacks": { + "ultra": { + "search_version": "2.19", + "varnish_version": "7.7", + "nginx_version": "1.28", + "queue_version": "3.13", + "cache_version": "7.2" + }, + "very_modern": { + "search_version": "2.19", + "varnish_version": "7.6", + "nginx_version": "1.26", + "queue_version": "3.13", + "cache_version": "7.2" + }, + "modern": { + "search_version": "2.12", + "varnish_version": "7.5", + "nginx_version": "1.24", + "queue_version": "3.12", + "cache_version": "7.2" + } + }, + "versions": [ + { + "major": 2, + "minor": 4, + "defaults": { + "db": "mariadb", + "cache": "redis", + "search": "opensearch", + "queue": "rabbitmq", + "web_root": "/pub" + }, + "patches": [ + { + "patch": 9, + "min_p_patch": 0, + "php_version": "8.5", + "db_version": "11.8", + "cache": "valkey", + "cache_version": "9.0", + "search_version": "3.0", + "varnish_version": "8.0", + "queue_version": "4.2", + "nginx_version": "1.28" + }, + { + "patch": 8, + "php_version": "8.4", + "db_version": "11.4", + "cache_version": "7.2", + "rules": [ + { + "min": 2, + "stack": "ultra", + "search_version": "3.0" + }, + { + "min": 0, + "stack": "very_modern" + } + ] + }, + { + "patch": 7, + "php_version": "8.3", + "db_version": "10.6", + "rules": [ + { + "min": 6, + "stack": "ultra", + "db_version": "10.11" + }, + { + "min": 5, + "stack": "very_modern" + }, + { + "min": 0, + "stack": "modern" + } + ] + }, + { + "patch": 6, + "php_version": "8.2", + "db_version": "10.6", + "cache_version": "7.0", + "search_version": "2.5", + "queue_version": "3.11", + "varnish_version": "7.1", + "nginx_version": "1.22", + "composer_version": "2.2", + "rules": [ + { + "min": 11, + "stack": "ultra", + "db_version": "10.11" + }, + { + "min": 10, + "stack": "very_modern" + }, + { + "min": 8, + "stack": "modern" + }, + { + "min": 5, + "stack": "modern", + "varnish_version": "7.1", + "cache_version": "7.0" + } + ] + }, + { + "patch": 5, + "php_version": "8.1", + "db_version": "10.4", + "cache_version": "6.2", + "search_version": "1.2", + "queue_version": "3.11", + "varnish_version": "7.0", + "nginx_version": "1.22", + "composer_version": "2.2", + "rules": [ + { + "min": 13, + "stack": "ultra" + }, + { + "min": 12, + "stack": "ultra", + "cache_version": "6.2" + }, + { + "min": 10, + "stack": "very_modern", + "search_version": "1.3" + }, + { + "min": 8, + "stack": "modern", + "search_version": "1.3", + "queue_version": "3.13" + }, + { + "min": 7, + "stack": "modern", + "search_version": "1.3", + "queue_version": "3.13", + "varnish_version": "7.0", + "cache_version": "7.0" + } + ] + }, + { + "patch": 4, + "php_version": "8.1", + "db_version": "10.4", + "cache_version": "6.2", + "search_version": "1.2", + "queue_version": "3.9", + "varnish_version": "7.0", + "nginx_version": "1.20", + "composer_version": "2.2", + "rules": [ + { + "min": 16, + "stack": "ultra" + }, + { + "min": 13, + "stack": "very_modern" + }, + { + "min": 11, + "stack": "modern", + "search_version": "1.3", + "queue_version": "3.13", + "cache_version": "7.2" + }, + { + "min": 8, + "stack": "modern", + "search_version": "1.3", + "queue_version": "3.13", + "cache_version": "7.0" + } + ] + }, + { + "patch": 3, + "php_version": "7.4", + "db_version": "10.4", + "nginx_version": "1.18", + "varnish_version": "6.0", + "queue_version": "3.8", + "composer_version": "2.2", + "rules": [ + { + "min": 2, + "search": "opensearch", + "search_version": "1.2", + "cache_version": "6.2" + }, + { + "min": 0, + "search": "elasticsearch", + "search_version": "7.10", + "cache_version": "6.0" + } + ] + }, + { + "patch": 2, + "php_version": "7.4", + "db_version": "10.4", + "cache": "redis", + "cache_version": "6.0", + "search": "elasticsearch", + "search_version": "7.9", + "queue_version": "3.8", + "varnish_version": "6.0", + "nginx_version": "1.18", + "composer_version": "2.2" + }, + { + "patch": 1, + "php_version": "7.4", + "db_version": "10.4", + "cache": "redis", + "cache_version": "6.0", + "search": "elasticsearch", + "search_version": "7.9", + "queue_version": "3.8", + "varnish_version": "6.0", + "nginx_version": "1.18", + "composer_version": "2.2" + }, + { + "patch": 0, + "php_version": "7.4", + "db_version": "10.4", + "cache": "redis", + "cache_version": "5.0", + "search": "elasticsearch", + "search_version": "7.6", + "queue_version": "3.8", + "varnish_version": "6.0", + "nginx_version": "1.18", + "composer_version": "2.2" + } + ] + }, + { + "major": 2, + "minor": 3, + "defaults": { + "db": "mariadb", + "cache": "redis", + "search": "elasticsearch", + "queue": "rabbitmq", + "web_root": "/" + }, + "patches": [ + { + "patch": 0, + "php_version": "7.1", + "db_version": "10.1", + "cache_version": "5.0", + "search_version": "5.6", + "queue_version": "3.7", + "varnish_version": "6.0", + "nginx_version": "1.18" + }, + { + "patch_min": 1, + "patch_max": 2, + "php_version": "7.2", + "db_version": "10.2", + "cache_version": "5.0", + "search_version": "6.8", + "queue_version": "3.7", + "varnish_version": "6.0", + "nginx_version": "1.18" + }, + { + "patch_min": 3, + "patch_max": 4, + "php_version": "7.2", + "db_version": "10.2", + "cache_version": "5.0", + "search_version": "6.8", + "queue_version": "3.8", + "varnish_version": "6.0", + "nginx_version": "1.18" + }, + { + "patch_min": 5, + "patch_max": 6, + "php_version": "7.3", + "db_version": "10.4", + "cache_version": "5.0", + "search_version": "7.6", + "queue_version": "3.8", + "varnish_version": "6.0", + "nginx_version": "1.18" + }, + { + "patch_min": 7, + "php_version": "7.4", + "db_version": "10.4", + "cache_version": "5.0", + "search_version": "7.9", + "queue_version": "3.8", + "varnish_version": "6.0", + "nginx_version": "1.18" + } + ] + }, + { + "major": 2, + "minor": 2, + "defaults": { + "db": "mariadb", + "cache": "redis", + "search": "elasticsearch", + "queue": "rabbitmq", + "web_root": "/" + }, + "patches": [ + { + "patch": 0, + "php_version": "7.1", + "db_version": "10.0", + "cache_version": "5.0", + "search_version": "5.6", + "queue_version": "3.7", + "varnish_version": "6.0", + "nginx_version": "1.18" + }, + { + "patch_min": 1, + "php_version": "7.1", + "db_version": "10.1", + "cache_version": "5.0", + "search_version": "5.6", + "queue_version": "3.7", + "varnish_version": "6.0", + "nginx_version": "1.18" + } + ] + }, + { + "major": 2, + "minor": 1, + "defaults": { + "db": "mariadb", + "cache": "redis", + "search": "elasticsearch", + "queue": "rabbitmq", + "web_root": "/" + }, + "patches": [ + { + "patch_min": 0, + "php_version": "7.1", + "db_version": "10.0", + "cache_version": "5.0", + "search_version": "2.4", + "queue_version": "3.7", + "varnish_version": "6.0", + "nginx_version": "1.18" + } + ] + }, + { + "major": 2, + "minor": 0, + "defaults": { + "db": "mariadb", + "cache": "redis", + "search": "elasticsearch", + "queue": "rabbitmq", + "web_root": "/" + }, + "patches": [ + { + "patch_min": 0, + "php_version": "7.1", + "db_version": "10.0", + "cache_version": "5.0", + "search_version": "2.4", + "queue_version": "3.7", + "varnish_version": "6.0", + "nginx_version": "1.18" + } + ] + } + ] +} diff --git a/internal/frameworks/magento2/spec.go b/internal/frameworks/magento2/spec.go new file mode 100644 index 00000000..73c4bc66 --- /dev/null +++ b/internal/frameworks/magento2/spec.go @@ -0,0 +1,6 @@ +package magento2 + +import "govard/internal/frameworks/types" + +// Spec declares Magento 2 as a root framework. +func Spec() types.FrameworkSpec { return types.FrameworkSpec{Definition: Definition()} } diff --git a/internal/frameworks/magento2/table_prefix.go b/internal/frameworks/magento2/table_prefix.go new file mode 100644 index 00000000..b63d141d --- /dev/null +++ b/internal/frameworks/magento2/table_prefix.go @@ -0,0 +1,37 @@ +package magento2 + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + + "govard/internal/engine" +) + +var tablePrefixExpr = regexp.MustCompile(`(?i)['"]table_prefix['"]\s*=>\s*['"]([^'"]*)['"]`) + +// DetectTablePrefix reads Magento 2-compatible env.php configuration. +// Mage-OS inherits this detector through its resolved Magento 2 definition. +func DetectTablePrefix(root string) string { + data, err := os.ReadFile(filepath.Join(root, "app", "etc", "env.php")) + if err != nil { + return "" + } + matches := tablePrefixExpr.FindStringSubmatch(string(data)) + if len(matches) != 2 { + return "" + } + return engine.NormalizeTablePrefix(matches[1]) +} + +func ResolveBootstrapTablePrefix(configuredPrefix string) (string, error) { + prefix := engine.NormalizeTablePrefix(configuredPrefix) + if prefix == "" { + prefix = engine.NormalizeTablePrefix(os.Getenv("TABLE_PREFIX")) + } + if !engine.ValidateTablePrefix(prefix) { + return "", fmt.Errorf("invalid table prefix %q (allowed: letters, numbers, and underscore)", prefix) + } + return prefix, nil +} diff --git a/internal/engine/upgrade_magento2.go b/internal/frameworks/magento2/upgrade.go similarity index 90% rename from internal/engine/upgrade_magento2.go rename to internal/frameworks/magento2/upgrade.go index e4168d22..1b069daf 100644 --- a/internal/engine/upgrade_magento2.go +++ b/internal/frameworks/magento2/upgrade.go @@ -1,10 +1,11 @@ -package engine +package magento2 import ( "context" "encoding/json" "fmt" "govard/internal/conventions" + "govard/internal/engine" "io" "os" "os/exec" @@ -17,31 +18,30 @@ import ( "gopkg.in/yaml.v3" ) -// magentoUpgradeVariant parameterizes the shared Magento-2-family upgrade +// UpgradeVariant parameterizes the shared Magento-2-family upgrade // pipeline for a specific distribution (Magento 2 Open Source/Commerce, or // Mage-OS). -type magentoUpgradeVariant struct { +type UpgradeVariant struct { DisplayName string Metapackage string RepositoryURL string PackagePrefix string } -var magento2UpgradeVariant = magentoUpgradeVariant{ +var defaultUpgradeVariant = UpgradeVariant{ DisplayName: "Magento 2", Metapackage: "magento/project-community-edition", RepositoryURL: "https://repo.magento.com/", PackagePrefix: "magento/", } -var mageOSUpgradeVariant = magentoUpgradeVariant{ - DisplayName: "Mage-OS", - Metapackage: "mage-os/project-community-edition", - RepositoryURL: "https://repo.mage-os.org/", - PackagePrefix: "mage-os/", +// Upgrade is magento2's engine.UpgradeFunc - registered via +// FrameworkDefinition.Upgrade in Definition(). +func Upgrade(ctx context.Context, config engine.Config, opts engine.UpgradeOptions) error { + return RunUpgrade(ctx, config, opts, defaultUpgradeVariant) } -func upgradeMagento2(ctx context.Context, config Config, opts UpgradeOptions, variant magentoUpgradeVariant) error { +func RunUpgrade(ctx context.Context, config engine.Config, opts engine.UpgradeOptions, variant UpgradeVariant) error { containerName := fmt.Sprintf("%s%s", opts.ProjectName, conventions.PHPSuffix) if opts.TargetVersion == "" { @@ -79,14 +79,14 @@ func upgradeMagento2(ctx context.Context, config Config, opts UpgradeOptions, va // Step 1: Env update if !opts.NoEnvUpdate { pterm.Info.Println("Step 1/6: Applying runtime profile for target version...") - targetProfile, err := ResolveRuntimeProfile(config.Framework, opts.TargetVersion) + targetProfile, err := engine.ResolveRuntimeProfile(config.Framework, opts.TargetVersion) if err != nil { pterm.Warning.Printf("Could not resolve specific profile for %s (continuing): %v\n", opts.TargetVersion, err) } else { - ApplyRuntimeProfileToConfig(&config, targetProfile.Profile) - NormalizeConfig(&config, opts.ProjectDir) + engine.ApplyRuntimeProfileToConfig(&config, targetProfile.Profile) + engine.NormalizeConfig(&config, opts.ProjectDir) // Ensure it writes to file - cleanConfig := PrepareConfigForWrite(config) + cleanConfig := engine.PrepareConfigForWrite(config) yamlOut, err := yaml.Marshal(&cleanConfig) if err != nil { return fmt.Errorf("failed to marshal config: %w", err) @@ -95,14 +95,14 @@ func upgradeMagento2(ctx context.Context, config Config, opts UpgradeOptions, va return fmt.Errorf("failed to write .govard.yml: %w", err) } - if err := RenderBlueprint(opts.ProjectDir, config); err != nil { + if err := engine.RenderBlueprint(opts.ProjectDir, config); err != nil { return fmt.Errorf("failed to render environment: %w", err) } } pterm.Info.Println("Step 2/6: Restarting environment (PHP, DB, Cache, Search)...") - composePath := ComposeFilePathWithProfile(opts.ProjectDir, opts.ProjectName, config.Profile) - if err := RunCompose(ctx, ComposeOptions{ + composePath := engine.ComposeFilePathWithProfile(opts.ProjectDir, opts.ProjectName, config.Profile) + if err := engine.RunCompose(ctx, engine.ComposeOptions{ ProjectDir: opts.ProjectDir, ProjectName: opts.ProjectName, ComposeFile: composePath, @@ -112,7 +112,7 @@ func upgradeMagento2(ctx context.Context, config Config, opts UpgradeOptions, va }); err != nil { pterm.Warning.Printf("Failed to stop environment: %v\n", err) } - if err := RunCompose(ctx, ComposeOptions{ + if err := engine.RunCompose(ctx, engine.ComposeOptions{ ProjectDir: opts.ProjectDir, ProjectName: opts.ProjectName, ComposeFile: composePath, @@ -127,7 +127,7 @@ func upgradeMagento2(ctx context.Context, config Config, opts UpgradeOptions, va checkDatabaseReady(ctx, config, containerName) } - if err := FixComposerCompatibility(config); err != nil { + if err := engine.FixComposerCompatibility(config); err != nil { return fmt.Errorf("failed to fix composer compatibility: %w", err) } @@ -217,7 +217,7 @@ func getMagentoCurrentVersion(containerName string) (string, error) { return "", fmt.Errorf("could not detect") } -func updateMagentoComposerJson(opts UpgradeOptions, containerName string, variant magentoUpgradeVariant) error { +func updateMagentoComposerJson(opts engine.UpgradeOptions, containerName string, variant UpgradeVariant) error { composerPath := filepath.Join(opts.ProjectDir, "composer.json") backupPath := filepath.Join(opts.ProjectDir, "composer.json.bak") @@ -400,7 +400,7 @@ func RelaxPackagesFromContentForTest(content string, containerName string) []str return append(toRelax, toRelaxDev...) } -func checkDatabaseReady(ctx context.Context, config Config, containerName string) { +func checkDatabaseReady(ctx context.Context, config engine.Config, containerName string) { for i := 0; i < 30; i++ { cmdArgs := []string{"exec", "-w", conventions.DefaultWorkDir, containerName, "php", "-r", "$m=new mysqli('db', 'magento', 'magento'); if($m->connect_error) exit(1); exit(0);"} out := exec.CommandContext(ctx, "docker", cmdArgs...) diff --git a/internal/frameworks/mageos/mageos.go b/internal/frameworks/mageos/mageos.go index 9af846a4..2c0bb018 100644 --- a/internal/frameworks/mageos/mageos.go +++ b/internal/frameworks/mageos/mageos.go @@ -1,11 +1,15 @@ package mageos import ( + "govard/internal/conventions" "govard/internal/engine" "govard/internal/engine/bootstrap" + "govard/internal/engine/remote" "govard/internal/engine/tunnel" "govard/internal/frameworks/magento2" "govard/internal/frameworks/types" + + "github.com/spf13/cobra" ) func Definition() types.FrameworkDefinition { @@ -14,6 +18,13 @@ func Definition() types.FrameworkDefinition { DisplayName: "Mage-OS", Config: config, Manifest: manifest, + DefaultDBCredentials: types.DefaultDBCredentials{ + Port: conventions.MySQLPort, + Username: conventions.DefaultMageOSDBUser, + Password: conventions.DefaultMageOSDBPass, + Database: conventions.DefaultMageOSDBName, + }, + PHPStanPaths: []string{"app/code", "app/design"}, Detect: engine.DetectionSpec{ ComposerPackages: []string{ "mage-os/product-community-edition", @@ -32,5 +43,45 @@ func Definition() types.FrameworkDefinition { FreshInstallNeedsDomain: true, SupportsBootstrap: true, SupportsFreshInstall: true, + PHPImageVariant: "magento2", + Upgrade: Upgrade, + RunMappingAssetPreparer: magento2.PrepareRunMappingAssets, + TablePrefixDetector: magento2.DetectTablePrefix, + ProbeRemoteDB: func(remoteName string, remoteCfg engine.RemoteConfig) (remote.RemoteDatabaseMetadata, error) { + metadata, err := magento2.ProbeMagento2Environment(remoteName, remoteCfg) + return metadata.DB, err + }, + AutoConfigure: func(cmd *cobra.Command, config engine.Config) error { + return magento2.ConfigureMagento(config.ProjectName, config, true, nil) + }, + } +} + +// Spec declares Mage-OS as a Magento 2 child. Every inherited behavior is +// intentionally omitted; only distribution-specific deltas remain here. +func Spec() types.FrameworkSpec { + def := Definition() + return types.FrameworkSpec{ + Parent: "magento2", + Definition: types.FrameworkDefinition{ + Name: def.Name, + Aliases: def.Aliases, + }, + Patch: types.FrameworkPatch{ + DisplayName: types.Set(def.DisplayName), + MigrationTypes: types.Clear[types.MigrationTypes](), + Config: types.Set(def.Config), + DefaultDBCredentials: types.Set(def.DefaultDBCredentials), + ComposerCodingStandard: types.Clear[types.ComposerCodingStandard](), + ComposerAuth: types.Clear[types.ComposerAuthRequirement](), + Detect: types.Set(def.Detect), + Bootstrap: types.Set(def.Bootstrap), + FreshInstall: types.Set(def.FreshInstall), + DefaultFreshMetaPackage: types.Set("mage-os/project-community-edition"), + DBDriverCategory: types.Clear[string](), + Upgrade: types.Set(def.Upgrade), + VersionProfileResolver: types.Clear[engine.VersionProfileResolver](), + VarnishTemplateFramework: types.Set("magento2"), + }, } } diff --git a/internal/frameworks/mageos/upgrade.go b/internal/frameworks/mageos/upgrade.go new file mode 100644 index 00000000..65ca9bc9 --- /dev/null +++ b/internal/frameworks/mageos/upgrade.go @@ -0,0 +1,24 @@ +package mageos + +import ( + "context" + + "govard/internal/engine" + "govard/internal/frameworks/magento2" +) + +// UpgradeVariant is Mage-OS's magento2.UpgradeVariant value, moved verbatim +// from engine.mageOSUpgradeVariant. +var UpgradeVariant = magento2.UpgradeVariant{ + DisplayName: "Mage-OS", + Metapackage: "mage-os/project-community-edition", + RepositoryURL: "https://repo.mage-os.org/", + PackagePrefix: "mage-os/", +} + +// Upgrade is Mage-OS's engine.UpgradeFunc - delegates to magento2's shared +// family pipeline, parameterized by Mage-OS's own variant, the same way +// mageos.freshInstall delegates to magento2.FreshInstall(Variant, ...). +func Upgrade(ctx context.Context, config engine.Config, opts engine.UpgradeOptions) error { + return magento2.RunUpgrade(ctx, config, opts, UpgradeVariant) +} diff --git a/internal/blueprints/files/nextjs/services.yml b/internal/frameworks/nextjs/blueprint/services.yml similarity index 100% rename from internal/blueprints/files/nextjs/services.yml rename to internal/frameworks/nextjs/blueprint/services.yml diff --git a/internal/frameworks/nextjs/embed.go b/internal/frameworks/nextjs/embed.go new file mode 100644 index 00000000..50c23819 --- /dev/null +++ b/internal/frameworks/nextjs/embed.go @@ -0,0 +1,27 @@ +package nextjs + +import ( + "embed" + "io/fs" + + "govard/internal/blueprints" +) + +//go:embed all:blueprint +var blueprintFiles embed.FS + +var BlueprintFS fs.FS + +func init() { + var err error + BlueprintFS, err = fs.Sub(blueprintFiles, "blueprint") + if err != nil { + panic(err) + } + + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "nextjs", + FS: BlueprintFS, + HasDir: true, + }) +} diff --git a/internal/frameworks/nextjs/nextjs.go b/internal/frameworks/nextjs/nextjs.go index 16fbaaed..a824d0d4 100644 --- a/internal/frameworks/nextjs/nextjs.go +++ b/internal/frameworks/nextjs/nextjs.go @@ -1,6 +1,9 @@ package nextjs import ( + "text/template" + + "govard/internal/conventions" "govard/internal/engine" "govard/internal/engine/bootstrap" "govard/internal/frameworks/types" @@ -8,10 +11,17 @@ import ( func Definition() types.FrameworkDefinition { return types.FrameworkDefinition{ - Name: "nextjs", - DisplayName: "Next.js", - Config: config, - Manifest: manifest, + Name: "nextjs", + DisplayName: "Next.js", + Config: config, + Manifest: manifest, + NodeImageFlavor: "standard", + DefaultDBCredentials: types.DefaultDBCredentials{ + Port: conventions.MySQLPort, + Username: conventions.DefaultDBUser, + Password: conventions.DefaultDBPass, + Database: conventions.DefaultDBName, + }, Detect: engine.DetectionSpec{ PackageJSONDeps: []string{"next"}, }, @@ -20,5 +30,6 @@ func Definition() types.FrameworkDefinition { }, FreshInstall: freshInstall, SupportsFreshInstall: true, + TemplateFuncs: template.FuncMap{"nextjsRuntimeCommand": BuildRuntimeCommand}, } } diff --git a/internal/frameworks/nextjs/render.go b/internal/frameworks/nextjs/render.go new file mode 100644 index 00000000..b76f2c5b --- /dev/null +++ b/internal/frameworks/nextjs/render.go @@ -0,0 +1,15 @@ +package nextjs + +import "strings" + +// BuildRuntimeCommand builds the dev-server startup command for nextjs's +// runtime container, moved verbatim from internal/engine/render.go's +// buildNextJSRuntimeCommand. Installs dependencies if node_modules is +// missing (e.g. wiped independently of a fresh bootstrap) before running +// the dev server, matching emdash.BuildRuntimeCommand's resilience. +func BuildRuntimeCommand() string { + return strings.Join([]string{ + `if [ ! -d node_modules ] || [ -z "$$(ls -A node_modules 2>/dev/null)" ]; then npm install; fi;`, + `exec npm run dev -- --hostname 0.0.0.0 --port 80;`, + }, " ") +} diff --git a/internal/frameworks/nextjs/spec.go b/internal/frameworks/nextjs/spec.go new file mode 100644 index 00000000..1dfbefdc --- /dev/null +++ b/internal/frameworks/nextjs/spec.go @@ -0,0 +1,6 @@ +package nextjs + +import "govard/internal/frameworks/types" + +// Spec declares Next.js as a root framework. +func Spec() types.FrameworkSpec { return types.FrameworkSpec{Definition: Definition()} } diff --git a/internal/frameworks/openmage/bootstrap.go b/internal/frameworks/openmage/bootstrap.go index 29519877..c405ded1 100644 --- a/internal/frameworks/openmage/bootstrap.go +++ b/internal/frameworks/openmage/bootstrap.go @@ -4,6 +4,7 @@ import ( "fmt" "govard/internal/conventions" "govard/internal/engine/bootstrap" + "govard/internal/frameworks/magento1" "os" "path/filepath" "strings" @@ -213,11 +214,11 @@ func (o *OpenMageBootstrap) CreateAdmin(projectDir string) error { containerName := fmt.Sprintf("%s%s", o.Options.ProjectName, conventions.DBSuffix) pterm.Info.Println("Creating OpenMage admin user...") - return bootstrap.RunMagento1AdminUserSQL(containerName, o.Options.DBUser, o.Options.DBPass, o.Options.DBName, strings.TrimSpace(o.Options.TablePrefix), adminEmail) + return magento1.RunAdminUserSQL(containerName, o.Options.DBUser, o.Options.DBPass, o.Options.DBName, strings.TrimSpace(o.Options.TablePrefix), adminEmail) } func (o *OpenMageBootstrap) createLocalXml(projectDir string) error { - cryptKey, err := bootstrap.GenerateMagento1CryptKey() + cryptKey, err := magento1.GenerateCryptKey() if err != nil { return fmt.Errorf("failed to generate crypt key: %w", err) } diff --git a/internal/frameworks/openmage/openmage.go b/internal/frameworks/openmage/openmage.go index ec7b712a..376eb10e 100644 --- a/internal/frameworks/openmage/openmage.go +++ b/internal/frameworks/openmage/openmage.go @@ -1,11 +1,16 @@ package openmage import ( + "govard/internal/conventions" "govard/internal/engine" "govard/internal/engine/bootstrap" + "govard/internal/engine/remote" "govard/internal/engine/tunnel" "govard/internal/frameworks/magento1" + "govard/internal/frameworks/magento2" "govard/internal/frameworks/types" + + "github.com/spf13/cobra" ) func Definition() types.FrameworkDefinition { @@ -14,6 +19,12 @@ func Definition() types.FrameworkDefinition { DisplayName: "OpenMage", Config: config, Manifest: manifest, + DefaultDBCredentials: types.DefaultDBCredentials{ + Port: conventions.MySQLPort, + Username: conventions.DefaultOpenMageDBUser, + Password: conventions.DefaultOpenMageDBPass, + Database: conventions.DefaultOpenMageDBName, + }, // Detect is intentionally the zero value - OpenMage has no // detection heuristic of its own. A project using // openmage/magento-lts is auto-detected as "magento1", not @@ -32,5 +43,43 @@ func Definition() types.FrameworkDefinition { FreshInstallNeedsDomain: true, SupportsBootstrap: true, SupportsFreshInstall: true, + PHPImageVariant: "magento1", + DBDriverCategory: "openmage", + RunMappingAssetPreparer: magento2.PrepareRunMappingAssets, + TablePrefixDetector: magento1.DetectTablePrefix, + ProbeRemoteDB: func(remoteName string, remoteCfg engine.RemoteConfig) (remote.RemoteDatabaseMetadata, error) { + metadata, err := magento1.ProbeMagento1Environment(remoteName, remoteCfg) + return metadata.DB, err + }, + AutoConfigure: func(cmd *cobra.Command, config engine.Config) error { + return magento2.ConfigureMagento1(config.ProjectName, config) + }, + } +} + +// Spec declares OpenMage as a Magento 1 child. OpenMage keeps its deliberate +// lack of auto-detection and its own fresh-install/runtime deltas while +// inheriting common Magento 1 behavior. +func Spec() types.FrameworkSpec { + def := Definition() + return types.FrameworkSpec{ + Parent: "magento1", + Definition: types.FrameworkDefinition{ + Name: def.Name, + Aliases: def.Aliases, + }, + Patch: types.FrameworkPatch{ + DisplayName: types.Set(def.DisplayName), + MigrationTypes: types.Clear[types.MigrationTypes](), + Config: types.Set(def.Config), + DefaultDBCredentials: types.Set(def.DefaultDBCredentials), + Detect: types.Clear[engine.DetectionSpec](), + Bootstrap: types.Set(def.Bootstrap), + FreshInstall: types.Set(def.FreshInstall), + FreshInstallNeedsDB: types.Set(def.FreshInstallNeedsDB), + FreshInstallNeedsDomain: types.Set(def.FreshInstallNeedsDomain), + DBDriverCategory: types.Set(def.DBDriverCategory), + Upgrade: types.Clear[engine.UpgradeFunc](), + }, } } diff --git a/internal/blueprints/files/support/nginx/templates/prestashop.conf b/internal/frameworks/prestashop/blueprint/prestashop.conf similarity index 100% rename from internal/blueprints/files/support/nginx/templates/prestashop.conf rename to internal/frameworks/prestashop/blueprint/prestashop.conf diff --git a/internal/blueprints/files/prestashop/services.yml b/internal/frameworks/prestashop/blueprint/services.yml similarity index 100% rename from internal/blueprints/files/prestashop/services.yml rename to internal/frameworks/prestashop/blueprint/services.yml diff --git a/internal/frameworks/prestashop/bootstrap.go b/internal/frameworks/prestashop/bootstrap.go index 13117f4e..8ef7c556 100644 --- a/internal/frameworks/prestashop/bootstrap.go +++ b/internal/frameworks/prestashop/bootstrap.go @@ -232,25 +232,29 @@ func (p *PrestaShopBootstrap) resolveDBConfig() (host, user, pass, name, prefix // Options when present (so any module data encrypted under the remote's keys stays // decryptable locally), falling back to a freshly generated random value otherwise. func (p *PrestaShopBootstrap) resolveSecrets() (secret, cookieKey, cookieIV, newCookieKey string, err error) { - secret, err = resolveOrGeneratePrestaShopSecret(p.Options.PrestaShopSecret) + secret, err = resolveOrGeneratePrestaShopSecret(p.remoteMetadata("prestashop.secret")) if err != nil { return "", "", "", "", fmt.Errorf("generate secret: %w", err) } - cookieKey, err = resolveOrGeneratePrestaShopSecret(p.Options.PrestaShopCookieKey) + cookieKey, err = resolveOrGeneratePrestaShopSecret(p.remoteMetadata("prestashop.cookie_key")) if err != nil { return "", "", "", "", fmt.Errorf("generate cookie_key: %w", err) } - cookieIV, err = resolveOrGeneratePrestaShopSecret(p.Options.PrestaShopCookieIV) + cookieIV, err = resolveOrGeneratePrestaShopSecret(p.remoteMetadata("prestashop.cookie_iv")) if err != nil { return "", "", "", "", fmt.Errorf("generate cookie_iv: %w", err) } - newCookieKey, err = resolveOrGeneratePrestaShopSecret(p.Options.PrestaShopNewCookieKey) + newCookieKey, err = resolveOrGeneratePrestaShopSecret(p.remoteMetadata("prestashop.new_cookie_key")) if err != nil { return "", "", "", "", fmt.Errorf("generate new_cookie_key: %w", err) } return secret, cookieKey, cookieIV, newCookieKey, nil } +func (p *PrestaShopBootstrap) remoteMetadata(key string) string { + return p.Options.RemoteMetadata[key] +} + func resolveOrGeneratePrestaShopSecret(remoteValue string) (string, error) { if trimmed := strings.TrimSpace(remoteValue); trimmed != "" { return trimmed, nil diff --git a/internal/frameworks/prestashop/embed.go b/internal/frameworks/prestashop/embed.go new file mode 100644 index 00000000..eff9beeb --- /dev/null +++ b/internal/frameworks/prestashop/embed.go @@ -0,0 +1,28 @@ +package prestashop + +import ( + "embed" + "io/fs" + + "govard/internal/blueprints" +) + +//go:embed all:blueprint +var blueprintFiles embed.FS + +var BlueprintFS fs.FS + +func init() { + var err error + BlueprintFS, err = fs.Sub(blueprintFiles, "blueprint") + if err != nil { + panic(err) + } + + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "prestashop", + FS: BlueprintFS, + HasDir: true, + NginxTemplate: "prestashop.conf", + }) +} diff --git a/internal/engine/remote/prestashop_metadata.go b/internal/frameworks/prestashop/metadata.go similarity index 57% rename from internal/engine/remote/prestashop_metadata.go rename to internal/frameworks/prestashop/metadata.go index a217aa29..da7e451f 100644 --- a/internal/engine/remote/prestashop_metadata.go +++ b/internal/frameworks/prestashop/metadata.go @@ -1,4 +1,4 @@ -package remote +package prestashop import ( "encoding/base64" @@ -7,50 +7,62 @@ import ( "strings" "govard/internal/engine" + "govard/internal/engine/remote" ) -// PrestaShopEnvironment holds DB credentials and encryption secrets extracted -// remotely from a PrestaShop app/config/parameters.php. -type PrestaShopEnvironment struct { - DB MagentoDBInfo - Secrets PrestaShopSecrets +// Environment holds database credentials and encryption secrets extracted +// remotely from this framework's app/config/parameters.php. +type Environment struct { + DB remote.RemoteDatabaseMetadata + Secrets Secrets } -// PrestaShopSecrets holds the encryption-related parameters.php keys. These are -// carried over (rather than regenerated) when fabricating a local parameters.php -// after a clone, so that any module data encrypted under the remote's keys stays -// decryptable locally. -type PrestaShopSecrets struct { +// Secrets holds the encryption-related parameters.php keys. They are carried +// over (rather than regenerated) when fabricating a local parameters.php so +// module data encrypted under the remote's keys stays decryptable locally. +type Secrets struct { Secret string CookieKey string CookieIV string NewCookieKey string } -// ProbePrestaShopEnvironment SSHs to the remote environment and includes -// app/config/parameters.php via PHP to extract DB connection credentials. -func ProbePrestaShopEnvironment(remoteName string, remoteCfg engine.RemoteConfig) (PrestaShopEnvironment, error) { - remoteCommand := buildMagentoRemoteCommand(remoteCfg.Path, `php -r `+engine.ShellQuote(prestashopParametersProbePHP)) - encoded, err := runRemoteCapture(remoteName, remoteCfg, remoteCommand) +func (s Secrets) metadata() map[string]string { + return map[string]string{ + "prestashop.secret": s.Secret, + "prestashop.cookie_key": s.CookieKey, + "prestashop.cookie_iv": s.CookieIV, + "prestashop.new_cookie_key": s.NewCookieKey, + } +} + +// ProbeEnvironment SSHs to the remote environment and includes this +// framework's app/config/parameters.php via PHP to extract connection +// credentials and encryption material. +func ProbeEnvironment(remoteName string, remoteCfg engine.RemoteConfig) (Environment, error) { + remoteCommand := remote.BuildProjectRemoteCommand(remoteCfg.Path, `php -r `+engine.ShellQuote(parametersProbePHP)) + encoded, err := remote.RunRemoteCapture(remoteName, remoteCfg, remoteCommand) if err != nil { - return PrestaShopEnvironment{}, err + return Environment{}, err } - return decodePrestaShopEnvironmentPayload(encoded) + return decodeEnvironmentPayload(encoded) } -func DecodePrestaShopEnvironmentPayloadForTest(encoded string) (PrestaShopEnvironment, error) { - return decodePrestaShopEnvironmentPayload(encoded) +// DecodePrestaShopEnvironmentPayloadForTest makes the remote payload boundary +// testable without an SSH server. +func DecodePrestaShopEnvironmentPayloadForTest(encoded string) (Environment, error) { + return decodeEnvironmentPayload(encoded) } -func decodePrestaShopEnvironmentPayload(encoded string) (PrestaShopEnvironment, error) { +func decodeEnvironmentPayload(encoded string) (Environment, error) { trimmed := strings.TrimSpace(encoded) if trimmed == "" { - return PrestaShopEnvironment{}, fmt.Errorf("remote probe returned empty payload") + return Environment{}, fmt.Errorf("remote probe returned empty payload") } decoded, err := base64.StdEncoding.DecodeString(trimmed) if err != nil { - return PrestaShopEnvironment{}, fmt.Errorf("decode remote probe payload: %w", err) + return Environment{}, fmt.Errorf("decode remote probe payload: %w", err) } var payload struct { @@ -65,18 +77,18 @@ func decodePrestaShopEnvironmentPayload(encoded string) (PrestaShopEnvironment, NewCookieKey string `json:"new_cookie_key"` } if err := json.Unmarshal(decoded, &payload); err != nil { - return PrestaShopEnvironment{}, fmt.Errorf("parse remote probe payload: %w", err) + return Environment{}, fmt.Errorf("parse remote probe payload: %w", err) } - host, port := ParseMagentoDBHostPort(payload.Host) + host, port := remote.ParseDatabaseHostPort(payload.Host) username := strings.TrimSpace(payload.Username) database := strings.TrimSpace(payload.DBName) if username == "" || database == "" { - return PrestaShopEnvironment{}, fmt.Errorf("remote parameters.php is missing database_user or database_name") + return Environment{}, fmt.Errorf("remote parameters.php is missing database_user or database_name") } - return PrestaShopEnvironment{ - DB: MagentoDBInfo{ + return Environment{ + DB: remote.RemoteDatabaseMetadata{ Host: host, Port: port, Username: username, @@ -84,7 +96,7 @@ func decodePrestaShopEnvironmentPayload(encoded string) (PrestaShopEnvironment, Database: database, TablePrefix: engine.SafeTablePrefix(payload.TablePrefix), }, - Secrets: PrestaShopSecrets{ + Secrets: Secrets{ Secret: strings.TrimSpace(payload.Secret), CookieKey: strings.TrimSpace(payload.CookieKey), CookieIV: strings.TrimSpace(payload.CookieIV), @@ -93,10 +105,10 @@ func decodePrestaShopEnvironmentPayload(encoded string) (PrestaShopEnvironment, }, nil } -// prestashopParametersProbePHP includes app/config/parameters.php directly (it's +// parametersProbePHP includes app/config/parameters.php directly (it is // guaranteed-valid PHP, since PrestaShop's own kernel includes it at boot) and -// reads the database_* keys out of the returned array. -const prestashopParametersProbePHP = ` +// reads its database and encryption keys from the returned array. +const parametersProbePHP = ` $dbhost=""; $dbport=""; $dbuser=""; $dbpass=""; $dbname=""; $dbprefix=""; $secret=""; $cookieKey=""; $cookieIV=""; $newCookieKey=""; $f = "app/config/parameters.php"; diff --git a/internal/frameworks/prestashop/prestashop.go b/internal/frameworks/prestashop/prestashop.go index 27cc9549..dcb0bd99 100644 --- a/internal/frameworks/prestashop/prestashop.go +++ b/internal/frameworks/prestashop/prestashop.go @@ -1,9 +1,13 @@ package prestashop import ( + "govard/internal/conventions" "govard/internal/engine" "govard/internal/engine/bootstrap" + "govard/internal/engine/remote" "govard/internal/frameworks/types" + "os" + "path/filepath" ) func Definition() types.FrameworkDefinition { @@ -12,12 +16,43 @@ func Definition() types.FrameworkDefinition { DisplayName: "PrestaShop", Config: config, Manifest: manifest, + DefaultDBCredentials: types.DefaultDBCredentials{ + Port: conventions.MySQLPort, + Username: conventions.DefaultPrestaShopDBUser, + Password: conventions.DefaultPrestaShopDBPass, + Database: conventions.DefaultPrestaShopDBName, + }, Detect: engine.DetectionSpec{ FilePaths: []string{"config/defines.inc.php"}, }, + ToolCommands: []types.ToolCommand{ + {Name: "prestashop", Short: "Run PrestaShop CLI commands (Symfony console)", Binary: "php", PrependArgs: []string{"bin/console"}}, + }, Bootstrap: func(opts bootstrap.Options) bootstrap.FrameworkBootstrap { return NewPrestaShopBootstrap(opts) }, - SupportsBootstrap: true, + SupportsBootstrap: true, + DBDriverCategory: "prestashop", + TablePrefixDetector: DetectTablePrefix, + ProbeRemoteDB: func(remoteName string, remoteCfg engine.RemoteConfig) (remote.RemoteDatabaseMetadata, error) { + metadata, err := ProbeEnvironment(remoteName, remoteCfg) + return metadata.DB, err + }, + ProbeRemoteBootstrapMetadata: func(remoteName string, remoteCfg engine.RemoteConfig) (remote.RemoteDatabaseMetadata, error) { + metadata, err := ProbeEnvironment(remoteName, remoteCfg) + if err != nil { + return remote.RemoteDatabaseMetadata{}, err + } + metadata.DB.Private = metadata.Secrets.metadata() + return metadata.DB, nil + }, + RemoteDBUsesConfigTablePrefix: true, + IgnorePostCloneError: func(err error, projectDir string) bool { + if err == nil { + return false + } + _, statErr := os.Stat(filepath.Join(projectDir, "app", "config", "parameters.php")) + return statErr == nil + }, } } diff --git a/internal/frameworks/prestashop/spec.go b/internal/frameworks/prestashop/spec.go new file mode 100644 index 00000000..eb788212 --- /dev/null +++ b/internal/frameworks/prestashop/spec.go @@ -0,0 +1,6 @@ +package prestashop + +import "govard/internal/frameworks/types" + +// Spec declares PrestaShop as a root framework. +func Spec() types.FrameworkSpec { return types.FrameworkSpec{Definition: Definition()} } diff --git a/internal/frameworks/prestashop/table_prefix.go b/internal/frameworks/prestashop/table_prefix.go new file mode 100644 index 00000000..3d7e6f30 --- /dev/null +++ b/internal/frameworks/prestashop/table_prefix.go @@ -0,0 +1,24 @@ +package prestashop + +import ( + "os" + "path/filepath" + "regexp" + + "govard/internal/engine" +) + +var tablePrefixExpr = regexp.MustCompile(`(?i)['"]database_prefix['"]\s*=>\s*['"]([^'"]*)['"]`) + +// DetectTablePrefix reads PrestaShop's parameters.php database-prefix setting. +func DetectTablePrefix(root string) string { + data, err := os.ReadFile(filepath.Join(root, "app", "config", "parameters.php")) + if err != nil { + return "" + } + matches := tablePrefixExpr.FindStringSubmatch(string(data)) + if len(matches) != 2 { + return "" + } + return engine.NormalizeTablePrefix(matches[1]) +} diff --git a/internal/frameworks/registry.go b/internal/frameworks/registry.go index 182bafe3..ae8eec40 100644 --- a/internal/frameworks/registry.go +++ b/internal/frameworks/registry.go @@ -1,6 +1,8 @@ package frameworks import ( + "fmt" + "sort" "strings" "govard/internal/engine" @@ -15,6 +17,7 @@ import ( type Registry struct { byName map[string]types.FrameworkDefinition aliases map[string]string + parents map[string]string } // NewRegistry returns an empty, ready-to-use Registry. Tests construct @@ -24,24 +27,116 @@ func NewRegistry() *Registry { return &Registry{ byName: make(map[string]types.FrameworkDefinition), aliases: make(map[string]string), + parents: make(map[string]string), } } +// NewRegistryFromSpecs resolves a parent/child framework graph into a +// read-only Registry. Specs may be supplied in any order. +func NewRegistryFromSpecs(specs []types.FrameworkSpec) (*Registry, error) { + byName := make(map[string]types.FrameworkSpec, len(specs)) + for _, spec := range specs { + name := normalizeName(spec.Definition.Name) + if name == "" { + return nil, fmt.Errorf("framework spec has an empty name") + } + if _, exists := byName[name]; exists { + return nil, fmt.Errorf("duplicate framework spec %q", name) + } + spec.Definition.Name = name + spec.Parent = normalizeName(spec.Parent) + byName[name] = spec + } + + registry := NewRegistry() + states := make(map[string]uint8, len(byName)) + var resolve func(string) (types.FrameworkDefinition, error) + resolve = func(name string) (types.FrameworkDefinition, error) { + switch states[name] { + case 1: + return types.FrameworkDefinition{}, fmt.Errorf("framework inheritance cycle includes %q", name) + case 2: + definition, _ := registry.Get(name) + return definition, nil + } + + spec := byName[name] + states[name] = 1 + parent := types.FrameworkDefinition{} + if spec.Parent != "" { + if spec.Parent == name { + return types.FrameworkDefinition{}, fmt.Errorf("framework %q cannot be its own parent", name) + } + if _, exists := byName[spec.Parent]; !exists { + return types.FrameworkDefinition{}, fmt.Errorf("framework %q has unknown parent %q", name, spec.Parent) + } + var err error + parent, err = resolve(spec.Parent) + if err != nil { + return types.FrameworkDefinition{}, err + } + } + + definition := spec.Resolve(parent) + definition.Name = name + registry.byName[name] = definition + registry.parents[name] = spec.Parent + states[name] = 2 + return definition, nil + } + + names := make([]string, 0, len(byName)) + for name := range byName { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if _, err := resolve(name); err != nil { + return nil, err + } + } + + for _, name := range names { + definition := registry.byName[name] + for _, rawAlias := range definition.Aliases { + alias := normalizeName(rawAlias) + if alias == "" { + return nil, fmt.Errorf("framework %q has an empty alias", name) + } + if owner, exists := registry.byName[alias]; exists && owner.Name != name { + return nil, fmt.Errorf("framework alias %q conflicts with framework %q", alias, owner.Name) + } + if owner, exists := registry.aliases[alias]; exists && owner != name { + return nil, fmt.Errorf("framework alias %q conflicts with framework %q", alias, owner) + } + registry.aliases[alias] = name + } + } + + return registry, nil +} + // Register adds def to the registry, indexing its aliases for Normalize. func (r *Registry) Register(def types.FrameworkDefinition) { - name := strings.ToLower(strings.TrimSpace(def.Name)) - r.byName[name] = def + name := normalizeName(def.Name) + def.Name = name + r.byName[name] = types.CloneDefinition(def) + r.parents[name] = "" for _, alias := range def.Aliases { - r.aliases[strings.ToLower(strings.TrimSpace(alias))] = name + r.aliases[normalizeName(alias)] = name } } +func normalizeName(raw string) string { + return strings.ToLower(strings.TrimSpace(raw)) +} + // Normalize resolves a raw framework name (possibly an alias) to its // canonical registered Name. Unknown names are returned lowercased/trimmed // but otherwise unchanged, matching the tolerant behavior of the existing // per-package alias checks this registry will eventually replace. func (r *Registry) Normalize(raw string) string { - normalized := strings.ToLower(strings.TrimSpace(raw)) + normalized := normalizeName(raw) if canonical, ok := r.aliases[normalized]; ok { return canonical } @@ -54,7 +149,7 @@ func (r *Registry) Normalize(raw string) string { // as read-only and must not mutate any of its slice fields. func (r *Registry) Get(name string) (types.FrameworkDefinition, bool) { def, ok := r.byName[r.Normalize(name)] - return def, ok + return types.CloneDefinition(def), ok } // All returns every registered definition, in no particular order. Each @@ -64,13 +159,62 @@ func (r *Registry) Get(name string) (types.FrameworkDefinition, bool) { func (r *Registry) All() []types.FrameworkDefinition { all := make([]types.FrameworkDefinition, 0, len(r.byName)) for _, def := range r.byName { - all = append(all, def) + all = append(all, types.CloneDefinition(def)) } + sort.Slice(all, func(i, j int) bool { return all[i].Name < all[j].Name }) return all } +// Lineage returns the resolved ancestry from root to name. Unknown names return +// nil so callers can distinguish them from root frameworks. +func (r *Registry) Lineage(name string) []string { + canonical := r.Normalize(name) + if _, ok := r.byName[canonical]; !ok { + return nil + } + + lineage := []string{canonical} + for parent := r.parents[canonical]; parent != ""; parent = r.parents[parent] { + lineage = append(lineage, parent) + } + for left, right := 0, len(lineage)-1; left < right; left, right = left+1, right-1 { + lineage[left], lineage[right] = lineage[right], lineage[left] + } + return lineage +} + +// IsA reports whether name is ancestor itself or descends from ancestor. +func (r *Registry) IsA(name, ancestor string) bool { + want := r.Normalize(ancestor) + for _, current := range r.Lineage(name) { + if current == want { + return true + } + } + return false +} + var defaultRegistry = NewRegistry() +// RegisterSpecs resolves specs and installs them as the package-level registry. +// The supplied order is retained when projecting detection data into engine, +// because detection priority is intentionally defined by generated registration +// order even though inheritance resolution itself is order-independent. +func RegisterSpecs(specs []types.FrameworkSpec) { + registry, err := NewRegistryFromSpecs(specs) + if err != nil { + panic(fmt.Sprintf("build framework registry: %v", err)) + } + defaultRegistry = registry + for _, spec := range specs { + definition, ok := registry.Get(spec.Definition.Name) + if !ok { + panic(fmt.Sprintf("resolved framework %q is missing", spec.Definition.Name)) + } + registerEngineDefinition(definition) + } +} + // Register adds def to the package-level default registry. Called from // all_generated.go's init() for each of the 14 frameworks; production code should // not call this directly. Also registers def's detection data with @@ -80,9 +224,42 @@ var defaultRegistry = NewRegistry() // global detection registry. func Register(def types.FrameworkDefinition) { defaultRegistry.Register(def) + registerEngineDefinition(def) +} + +func registerEngineDefinition(def types.FrameworkDefinition) { engine.RegisterDetection(strings.ToLower(strings.TrimSpace(def.Name)), def.Detect) engine.RegisterFrameworkConfig(def.Name, def.Config) engine.RegisterFrameworkManifest(def.Name, def.Manifest) + engine.RegisterPHPImageVariant(def.Name, def.PHPImageVariant) + engine.RegisterNodeImageFlavor(def.Name, def.NodeImageFlavor) + engine.RegisterVarnishTemplateFramework(def.Name, def.VarnishTemplateFramework) + engine.RegisterDBDriverCategory(def.Name, def.DBDriverCategory) + engine.RegisterDefaultChownDirectories(def.Name, def.DefaultChownDirectories) + for _, migrationType := range def.MigrationTypes.DDEV { + engine.RegisterMigrationFramework("ddev", migrationType, def.Name) + } + for _, migrationType := range def.MigrationTypes.Warden { + engine.RegisterMigrationFramework("warden", migrationType, def.Name) + } + for _, alias := range def.Aliases { + engine.RegisterFrameworkAlias(alias, def.Name) + } + if def.Upgrade != nil { + engine.RegisterUpgrader(def.Name, def.Upgrade) + } + if def.RunMappingAssetPreparer != nil { + engine.RegisterRunMappingAssetPreparer(def.Name, def.RunMappingAssetPreparer) + } + if def.TablePrefixDetector != nil { + engine.RegisterTablePrefixDetector(def.Name, def.TablePrefixDetector) + } + if def.VersionProfileResolver != nil { + engine.RegisterVersionProfileResolver(def.Name, def.VersionProfileResolver) + } + for name, fn := range def.TemplateFuncs { + engine.RegisterTemplateFunc(name, fn) + } } // Normalize resolves raw against the package-level default registry. @@ -93,3 +270,9 @@ func Get(name string) (types.FrameworkDefinition, bool) { return defaultRegistry // All returns every definition in the package-level default registry. func All() []types.FrameworkDefinition { return defaultRegistry.All() } + +// Lineage returns a framework's resolved ancestry from root to child. +func Lineage(name string) []string { return defaultRegistry.Lineage(name) } + +// IsA reports whether framework is ancestor itself or descends from ancestor. +func IsA(framework, ancestor string) bool { return defaultRegistry.IsA(framework, ancestor) } diff --git a/internal/engine/remote/dotenv_metadata.go b/internal/frameworks/shared/dotenv/metadata.go similarity index 50% rename from internal/engine/remote/dotenv_metadata.go rename to internal/frameworks/shared/dotenv/metadata.go index a22a3b60..2bc4145c 100644 --- a/internal/engine/remote/dotenv_metadata.go +++ b/internal/frameworks/shared/dotenv/metadata.go @@ -1,4 +1,6 @@ -package remote +// Package dotenv owns the shared .env database-metadata format used by +// framework families such as Laravel, Symfony, Shopware, and Bedrock. +package dotenv import ( "encoding/base64" @@ -10,9 +12,12 @@ import ( "govard/internal/conventions" "govard/internal/engine" + "govard/internal/engine/remote" ) -type DotenvDBInfo struct { +// DatabaseInfo is the common database connection shape represented by a .env +// file. It has no framework-specific policy such as a table prefix. +type DatabaseInfo struct { Host string Port int Username string @@ -20,11 +25,12 @@ type DotenvDBInfo struct { Database string } -type DotenvEnvironment struct { - DB DotenvDBInfo +// Environment is the metadata decoded from a remote .env probe. +type Environment struct { + DB DatabaseInfo } -type dotenvDBProbePayload struct { +type databaseProbePayload struct { DatabaseURL string `json:"database_url"` DatabaseHost string `json:"database_host"` DatabasePort string `json:"database_port"` @@ -45,42 +51,43 @@ type dotenvDBProbePayload struct { MysqlPort string `json:"mysql_port"` } -func ProbeDotenvEnvironment(remoteName string, remoteCfg engine.RemoteConfig) (DotenvEnvironment, error) { - remoteCommand := buildMagentoRemoteCommand(remoteCfg.Path, `php -r `+engine.ShellQuote(dotenvDBProbePHP)) - encoded, err := runRemoteCapture(remoteName, remoteCfg, remoteCommand) +// ProbeEnvironment SSHs to a project and extracts the standard .env database +// variables. Framework packages choose whether this shared metadata applies. +func ProbeEnvironment(remoteName string, remoteCfg engine.RemoteConfig) (Environment, error) { + remoteCommand := remote.BuildProjectRemoteCommand(remoteCfg.Path, `php -r `+engine.ShellQuote(databaseProbePHP)) + encoded, err := remote.RunRemoteCapture(remoteName, remoteCfg, remoteCommand) if err != nil { - return DotenvEnvironment{}, err + return Environment{}, err } - return decodeDotenvEnvironmentPayload(encoded) + return decodeEnvironmentPayload(encoded) } -func decodeDotenvEnvironmentPayload(encoded string) (DotenvEnvironment, error) { +func decodeEnvironmentPayload(encoded string) (Environment, error) { trimmed := strings.TrimSpace(encoded) if trimmed == "" { - return DotenvEnvironment{}, fmt.Errorf("remote probe returned empty payload") + return Environment{}, fmt.Errorf("remote probe returned empty payload") } decoded, err := base64.StdEncoding.DecodeString(trimmed) if err != nil { - return DotenvEnvironment{}, fmt.Errorf("decode remote probe payload: %w", err) + return Environment{}, fmt.Errorf("decode remote probe payload: %w", err) } - var payload dotenvDBProbePayload + var payload databaseProbePayload if err := json.Unmarshal(decoded, &payload); err != nil { - return DotenvEnvironment{}, fmt.Errorf("parse remote probe payload: %w", err) + return Environment{}, fmt.Errorf("parse remote probe payload: %w", err) } - info, err := resolveDotenvDBInfo(payload) + info, err := resolveDatabaseInfo(payload) if err != nil { - return DotenvEnvironment{}, err + return Environment{}, err } - - return DotenvEnvironment{DB: info}, nil + return Environment{DB: info}, nil } -func resolveDotenvDBInfo(payload dotenvDBProbePayload) (DotenvDBInfo, error) { +func resolveDatabaseInfo(payload databaseProbePayload) (DatabaseInfo, error) { if databaseURL := strings.TrimSpace(payload.DatabaseURL); databaseURL != "" { - return parseDotenvDatabaseURL(databaseURL) + return parseDatabaseURL(databaseURL) } host := engine.FirstNonEmpty(payload.DatabaseHost, payload.DBHost, payload.MysqlHost) @@ -93,7 +100,7 @@ func resolveDotenvDBInfo(payload dotenvDBProbePayload) (DotenvDBInfo, error) { if portRaw != "" { parsed, err := strconv.Atoi(portRaw) if err != nil || parsed <= 0 { - return DotenvDBInfo{}, fmt.Errorf("invalid database port %q from remote dotenv", portRaw) + return DatabaseInfo{}, fmt.Errorf("invalid database port %q from remote dotenv", portRaw) } port = parsed } @@ -101,10 +108,10 @@ func resolveDotenvDBInfo(payload dotenvDBProbePayload) (DotenvDBInfo, error) { username := strings.TrimSpace(engine.FirstNonEmpty(payload.DatabaseUser, payload.DBUsername, payload.DBUser, payload.MysqlUser)) database := strings.TrimSpace(engine.FirstNonEmpty(payload.DatabaseName, payload.DBDatabase, payload.DBName, payload.MysqlDatabase)) if username == "" || database == "" { - return DotenvDBInfo{}, fmt.Errorf("remote dotenv is missing database username or database name") + return DatabaseInfo{}, fmt.Errorf("remote dotenv is missing database username or database name") } - return DotenvDBInfo{ + return DatabaseInfo{ Host: host, Port: port, Username: username, @@ -113,23 +120,21 @@ func resolveDotenvDBInfo(payload dotenvDBProbePayload) (DotenvDBInfo, error) { }, nil } -func parseDotenvDatabaseURL(raw string) (DotenvDBInfo, error) { - trimmed := strings.TrimSpace(raw) - trimmed = strings.Trim(trimmed, `"'`) +func parseDatabaseURL(raw string) (DatabaseInfo, error) { + trimmed := strings.Trim(strings.TrimSpace(raw), `"'`) if trimmed == "" { - return DotenvDBInfo{}, fmt.Errorf("remote dotenv DATABASE_URL is empty") + return DatabaseInfo{}, fmt.Errorf("remote dotenv DATABASE_URL is empty") } parsed, err := url.Parse(trimmed) if err != nil { - return DotenvDBInfo{}, fmt.Errorf("parse remote DATABASE_URL: %w", err) + return DatabaseInfo{}, fmt.Errorf("parse remote DATABASE_URL: %w", err) } - scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme)) - switch scheme { + switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) { case "mysql", "mysql2", "mariadb": default: - return DotenvDBInfo{}, fmt.Errorf("remote dotenv DATABASE_URL uses unsupported scheme %q", parsed.Scheme) + return DatabaseInfo{}, fmt.Errorf("remote dotenv DATABASE_URL uses unsupported scheme %q", parsed.Scheme) } host := strings.TrimSpace(parsed.Hostname()) @@ -141,7 +146,7 @@ func parseDotenvDatabaseURL(raw string) (DotenvDBInfo, error) { if rawPort := strings.TrimSpace(parsed.Port()); rawPort != "" { parsedPort, parseErr := strconv.Atoi(rawPort) if parseErr != nil || parsedPort <= 0 { - return DotenvDBInfo{}, fmt.Errorf("remote dotenv DATABASE_URL has invalid port %q", rawPort) + return DatabaseInfo{}, fmt.Errorf("remote dotenv DATABASE_URL has invalid port %q", rawPort) } port = parsedPort } @@ -152,25 +157,20 @@ func parseDotenvDatabaseURL(raw string) (DotenvDBInfo, error) { if unescaped, unescapeErr := url.PathUnescape(database); unescapeErr == nil { database = unescaped } - if username == "" || database == "" { - return DotenvDBInfo{}, fmt.Errorf("remote dotenv DATABASE_URL is missing username or database name") + return DatabaseInfo{}, fmt.Errorf("remote dotenv DATABASE_URL is missing username or database name") } - return DotenvDBInfo{ - Host: host, - Port: port, - Username: username, - Password: password, - Database: database, - }, nil + return DatabaseInfo{Host: host, Port: port, Username: username, Password: password, Database: database}, nil } -func ParseDotenvDatabaseURLForTest(raw string) (DotenvDBInfo, error) { - return parseDotenvDatabaseURL(raw) +// ParseDatabaseURLForTest exposes the .env URL boundary for tests. +func ParseDatabaseURLForTest(raw string) (DatabaseInfo, error) { + return parseDatabaseURL(raw) } -func ResolveDotenvDBInfoForTest( +// ResolveDatabaseInfoForTest exposes the precedence rules for tests. +func ResolveDatabaseInfoForTest( databaseURL string, databaseHost string, databasePort string, @@ -184,8 +184,8 @@ func ResolveDotenvDBInfoForTest( dbUser string, dbUsername string, dbPassword string, -) (DotenvDBInfo, error) { - return resolveDotenvDBInfo(dotenvDBProbePayload{ +) (DatabaseInfo, error) { + return resolveDatabaseInfo(databaseProbePayload{ DatabaseURL: databaseURL, DatabaseHost: databaseHost, DatabasePort: databasePort, @@ -202,75 +202,33 @@ func ResolveDotenvDBInfoForTest( }) } -const dotenvDBProbePHP = ` +const databaseProbePHP = ` function govard_parse_env($path, &$vars) { - if (!is_file($path)) { - return; - } + if (!is_file($path)) return; $lines = @file($path, FILE_IGNORE_NEW_LINES); - if ($lines === false) { - return; - } + if ($lines === false) return; foreach ($lines as $line) { $line = trim((string)$line); - if ($line === '' || $line[0] === '#') { - continue; - } - if (isset($line[6]) && substr($line, 0, 7) === 'export ') { - $line = substr($line, 7); - } + if ($line === '' || $line[0] === '#') continue; + if (isset($line[6]) && substr($line, 0, 7) === 'export ') $line = substr($line, 7); $pos = strpos($line, '='); - if ($pos === false) { - continue; - } + if ($pos === false) continue; $key = trim(substr($line, 0, $pos)); - if ($key === '') { - continue; - } + if ($key === '') continue; $value = trim(substr($line, $pos + 1)); if ($value !== '') { - $first = $value[0]; - $last = substr($value, -1); + $first = $value[0]; $last = substr($value, -1); if (($first === '"' || $first === "'") && $last === $first && strlen($value) >= 2) { $value = substr($value, 1, -1); - if ($first === '"') { - $value = stripcslashes($value); - } + if ($first === '"') $value = stripcslashes($value); } } $vars[$key] = $value; } } - -$vars = []; -govard_parse_env('.env', $vars); -$appEnv = trim((string)($vars['APP_ENV'] ?? 'dev')); -if ($appEnv === '') { - $appEnv = 'dev'; -} -govard_parse_env('.env.local', $vars); -govard_parse_env('.env.' . $appEnv, $vars); -govard_parse_env('.env.' . $appEnv . '.local', $vars); - -$out = [ - 'database_url' => (string)($vars['DATABASE_URL'] ?? ''), - 'database_host' => (string)($vars['DATABASE_HOST'] ?? ''), - 'database_port' => (string)($vars['DATABASE_PORT'] ?? ''), - 'database_user' => (string)($vars['DATABASE_USER'] ?? ''), - 'database_password' => (string)($vars['DATABASE_PASSWORD'] ?? ''), - 'database_name' => (string)($vars['DATABASE_NAME'] ?? ''), - 'db_host' => (string)($vars['DB_HOST'] ?? ''), - 'db_port' => (string)($vars['DB_PORT'] ?? ''), - 'db_name' => (string)($vars['DB_NAME'] ?? ''), - 'db_database' => (string)($vars['DB_DATABASE'] ?? ''), - 'db_user' => (string)($vars['DB_USER'] ?? ''), - 'db_username' => (string)($vars['DB_USERNAME'] ?? ''), - 'db_password' => (string)($vars['DB_PASSWORD'] ?? ''), - 'mysql_user' => (string)($vars['MYSQL_USER'] ?? ''), - 'mysql_database' => (string)($vars['MYSQL_DATABASE'] ?? ''), - 'mysql_password' => (string)($vars['MYSQL_PASSWORD'] ?? ''), - 'mysql_host' => (string)($vars['MYSQL_HOST'] ?? ''), - 'mysql_port' => (string)($vars['MYSQL_PORT'] ?? ''), -]; +$vars = []; govard_parse_env('.env', $vars); +$appEnv = trim((string)($vars['APP_ENV'] ?? 'dev')); if ($appEnv === '') $appEnv = 'dev'; +govard_parse_env('.env.local', $vars); govard_parse_env('.env.' . $appEnv, $vars); govard_parse_env('.env.' . $appEnv . '.local', $vars); +$out = ['database_url'=>(string)($vars['DATABASE_URL'] ?? ''), 'database_host'=>(string)($vars['DATABASE_HOST'] ?? ''), 'database_port'=>(string)($vars['DATABASE_PORT'] ?? ''), 'database_user'=>(string)($vars['DATABASE_USER'] ?? ''), 'database_password'=>(string)($vars['DATABASE_PASSWORD'] ?? ''), 'database_name'=>(string)($vars['DATABASE_NAME'] ?? ''), 'db_host'=>(string)($vars['DB_HOST'] ?? ''), 'db_port'=>(string)($vars['DB_PORT'] ?? ''), 'db_name'=>(string)($vars['DB_NAME'] ?? ''), 'db_database'=>(string)($vars['DB_DATABASE'] ?? ''), 'db_user'=>(string)($vars['DB_USER'] ?? ''), 'db_username'=>(string)($vars['DB_USERNAME'] ?? ''), 'db_password'=>(string)($vars['DB_PASSWORD'] ?? ''), 'mysql_user'=>(string)($vars['MYSQL_USER'] ?? ''), 'mysql_database'=>(string)($vars['MYSQL_DATABASE'] ?? ''), 'mysql_password'=>(string)($vars['MYSQL_PASSWORD'] ?? ''), 'mysql_host'=>(string)($vars['MYSQL_HOST'] ?? ''), 'mysql_port'=>(string)($vars['MYSQL_PORT'] ?? '')]; echo base64_encode(json_encode($out)); ` diff --git a/internal/blueprints/files/shopware/services.yml b/internal/frameworks/shopware/blueprint/services.yml similarity index 100% rename from internal/blueprints/files/shopware/services.yml rename to internal/frameworks/shopware/blueprint/services.yml diff --git a/internal/blueprints/files/support/nginx/templates/shopware.conf b/internal/frameworks/shopware/blueprint/shopware.conf similarity index 100% rename from internal/blueprints/files/support/nginx/templates/shopware.conf rename to internal/frameworks/shopware/blueprint/shopware.conf diff --git a/internal/frameworks/shopware/embed.go b/internal/frameworks/shopware/embed.go new file mode 100644 index 00000000..b017bbf3 --- /dev/null +++ b/internal/frameworks/shopware/embed.go @@ -0,0 +1,28 @@ +package shopware + +import ( + "embed" + "io/fs" + + "govard/internal/blueprints" +) + +//go:embed all:blueprint +var blueprintFiles embed.FS + +var BlueprintFS fs.FS + +func init() { + var err error + BlueprintFS, err = fs.Sub(blueprintFiles, "blueprint") + if err != nil { + panic(err) + } + + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "shopware", + FS: BlueprintFS, + HasDir: true, + NginxTemplate: "shopware.conf", + }) +} diff --git a/internal/frameworks/shopware/shopware.go b/internal/frameworks/shopware/shopware.go index d04aa242..885d3838 100644 --- a/internal/frameworks/shopware/shopware.go +++ b/internal/frameworks/shopware/shopware.go @@ -1,25 +1,52 @@ package shopware import ( + "govard/internal/conventions" "govard/internal/engine" "govard/internal/engine/bootstrap" + "govard/internal/engine/remote" + "govard/internal/frameworks/shared/dotenv" "govard/internal/frameworks/types" ) func Definition() types.FrameworkDefinition { return types.FrameworkDefinition{ - Name: "shopware", - DisplayName: "Shopware", - Config: config, - Manifest: manifest, + Name: "shopware", + DisplayName: "Shopware", + MigrationTypes: types.MigrationTypes{DDEV: []string{"shopware6"}, Warden: []string{"shopware"}}, + Config: config, + Manifest: manifest, + DefaultDBCredentials: types.DefaultDBCredentials{ + Port: conventions.MySQLPort, + Username: conventions.DefaultDBUser, + Password: conventions.DefaultDBPass, + Database: conventions.DefaultDBName, + }, Detect: engine.DetectionSpec{ ComposerPackages: []string{"shopware/core", "shopware/platform"}, }, + ToolCommands: []types.ToolCommand{ + {Name: "shopware", Short: "Run Shopware CLI commands", Binary: "bin/console"}, + }, Bootstrap: func(opts bootstrap.Options) bootstrap.FrameworkBootstrap { return NewShopwareBootstrap(opts) }, FreshInstall: freshInstall, FreshInstallNeedsDomain: true, SupportsFreshInstall: true, + DBDriverCategory: "shopware", + ProbeRemoteDB: func(remoteName string, remoteCfg engine.RemoteConfig) (remote.RemoteDatabaseMetadata, error) { + metadata, err := dotenv.ProbeEnvironment(remoteName, remoteCfg) + if err != nil { + return remote.RemoteDatabaseMetadata{}, err + } + return remote.RemoteDatabaseMetadata{ + Host: metadata.DB.Host, + Port: metadata.DB.Port, + Username: metadata.DB.Username, + Password: metadata.DB.Password, + Database: metadata.DB.Database, + }, nil + }, } } diff --git a/internal/frameworks/shopware/spec.go b/internal/frameworks/shopware/spec.go new file mode 100644 index 00000000..16f669d6 --- /dev/null +++ b/internal/frameworks/shopware/spec.go @@ -0,0 +1,6 @@ +package shopware + +import "govard/internal/frameworks/types" + +// Spec declares Shopware as a root framework. +func Spec() types.FrameworkSpec { return types.FrameworkSpec{Definition: Definition()} } diff --git a/internal/blueprints/files/symfony/services.yml b/internal/frameworks/symfony/blueprint/services.yml similarity index 100% rename from internal/blueprints/files/symfony/services.yml rename to internal/frameworks/symfony/blueprint/services.yml diff --git a/internal/blueprints/files/support/nginx/templates/symfony.conf b/internal/frameworks/symfony/blueprint/symfony.conf similarity index 100% rename from internal/blueprints/files/support/nginx/templates/symfony.conf rename to internal/frameworks/symfony/blueprint/symfony.conf diff --git a/internal/frameworks/symfony/embed.go b/internal/frameworks/symfony/embed.go new file mode 100644 index 00000000..4cb71cfc --- /dev/null +++ b/internal/frameworks/symfony/embed.go @@ -0,0 +1,28 @@ +package symfony + +import ( + "embed" + "io/fs" + + "govard/internal/blueprints" +) + +//go:embed all:blueprint +var blueprintFiles embed.FS + +var BlueprintFS fs.FS + +func init() { + var err error + BlueprintFS, err = fs.Sub(blueprintFiles, "blueprint") + if err != nil { + panic(err) + } + + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "symfony", + FS: BlueprintFS, + HasDir: true, + NginxTemplate: "symfony.conf", + }) +} diff --git a/internal/frameworks/symfony/spec.go b/internal/frameworks/symfony/spec.go new file mode 100644 index 00000000..23652e04 --- /dev/null +++ b/internal/frameworks/symfony/spec.go @@ -0,0 +1,6 @@ +package symfony + +import "govard/internal/frameworks/types" + +// Spec declares Symfony as a root framework. +func Spec() types.FrameworkSpec { return types.FrameworkSpec{Definition: Definition()} } diff --git a/internal/frameworks/symfony/symfony.go b/internal/frameworks/symfony/symfony.go index 56f30fb0..a4d79d9f 100644 --- a/internal/frameworks/symfony/symfony.go +++ b/internal/frameworks/symfony/symfony.go @@ -1,21 +1,34 @@ package symfony import ( + "govard/internal/conventions" "govard/internal/engine" "govard/internal/engine/bootstrap" + "govard/internal/engine/remote" "govard/internal/engine/tunnel" + "govard/internal/frameworks/shared/dotenv" "govard/internal/frameworks/types" ) func Definition() types.FrameworkDefinition { return types.FrameworkDefinition{ - Name: "symfony", - DisplayName: "Symfony", - Config: config, - Manifest: manifest, + Name: "symfony", + DisplayName: "Symfony", + MigrationTypes: types.MigrationTypes{DDEV: []string{"symfony"}, Warden: []string{"symfony"}}, + Config: config, + Manifest: manifest, + DefaultDBCredentials: types.DefaultDBCredentials{ + Port: conventions.MySQLPort, + Username: conventions.DefaultSymfonyDBUser, + Password: conventions.DefaultSymfonyDBPass, + Database: conventions.DefaultSymfonyDBName, + }, Detect: engine.DetectionSpec{ ComposerPackages: []string{"symfony/framework-bundle", "symfony/symfony"}, }, + ToolCommands: []types.ToolCommand{ + {Name: "symfony", Short: "Run Symfony CLI commands", Binary: "php", PrependArgs: []string{"bin/console"}}, + }, Bootstrap: func(opts bootstrap.Options) bootstrap.FrameworkBootstrap { return NewSymfonyBootstrap(opts) }, @@ -26,5 +39,20 @@ func Definition() types.FrameworkDefinition { FreshInstallNeedsDB: true, SupportsBootstrap: true, SupportsFreshInstall: true, + DBDriverCategory: "symfony", + Upgrade: Upgrade, + ProbeRemoteDB: func(remoteName string, remoteCfg engine.RemoteConfig) (remote.RemoteDatabaseMetadata, error) { + metadata, err := dotenv.ProbeEnvironment(remoteName, remoteCfg) + if err != nil { + return remote.RemoteDatabaseMetadata{}, err + } + return remote.RemoteDatabaseMetadata{ + Host: metadata.DB.Host, + Port: metadata.DB.Port, + Username: metadata.DB.Username, + Password: metadata.DB.Password, + Database: metadata.DB.Database, + }, nil + }, } } diff --git a/internal/engine/upgrade_symfony.go b/internal/frameworks/symfony/upgrade.go similarity index 92% rename from internal/engine/upgrade_symfony.go rename to internal/frameworks/symfony/upgrade.go index 9338d077..c1a39cc6 100644 --- a/internal/engine/upgrade_symfony.go +++ b/internal/frameworks/symfony/upgrade.go @@ -1,4 +1,4 @@ -package engine +package symfony import ( "context" @@ -7,9 +7,12 @@ import ( "github.com/pterm/pterm" "govard/internal/conventions" + "govard/internal/engine" ) -func upgradeSymfony(ctx context.Context, config Config, opts UpgradeOptions) error { +// Upgrade is symfony's engine.UpgradeFunc, moved verbatim from +// engine.upgradeSymfony. +func Upgrade(ctx context.Context, config engine.Config, opts engine.UpgradeOptions) error { pterm.Info.Println("Symfony Upgrade Pipeline") containerName := fmt.Sprintf("%s%s", opts.ProjectName, conventions.PHPSuffix) diff --git a/internal/frameworks/types/definition.go b/internal/frameworks/types/definition.go index b1496878..df4a309c 100644 --- a/internal/frameworks/types/definition.go +++ b/internal/frameworks/types/definition.go @@ -1,9 +1,14 @@ package types import ( + "text/template" + "govard/internal/engine" "govard/internal/engine/bootstrap" + "govard/internal/engine/remote" "govard/internal/engine/tunnel" + + "github.com/spf13/cobra" ) // BootstrapFactory builds a framework's bootstrapper for one invocation. @@ -30,11 +35,18 @@ type BootstrapFactory func(bootstrap.Options) bootstrap.FrameworkBootstrap type FrameworkDefinition struct { // Name is the canonical framework key, e.g. "magento2", "laravel". Name string + // Parent is the canonical framework key this definition inherits from. + // It is empty for root frameworks and is populated only on resolved child + // definitions. + Parent string // Aliases are additional strings that should resolve to Name (e.g. // "magento" -> "magento2"). Aliases []string // DisplayName is a human-readable label, e.g. "Magento 2". DisplayName string + // MigrationTypes lists external tool framework identifiers that should map + // to this definition during DDEV or Warden configuration migration. + MigrationTypes MigrationTypes // Config carries runtime/compose defaults (PHP version, includes list, // nginx template, etc.), currently sourced from engine.GetFrameworkConfig. @@ -43,6 +55,46 @@ type FrameworkDefinition struct { // currently sourced from engine.GetFrameworkManifestConfig. Manifest engine.FrameworkManifestConfig + // DefaultDBCredentials holds the local-development database port/username/ + // password/database-name defaults for this framework. Host, Engine, and + // TablePrefix are deliberately excluded - Host/TablePrefix are resolved at + // runtime (container inspection, remote probing, user config), and Engine + // is already derived from Config.DefaultDB via dbEngineForFramework, not a + // literal per framework. Replaces the switch in + // internal/cmd/db_credentials.go's defaultDBCredentialsForFrameworkFields. + DefaultDBCredentials DefaultDBCredentials + // DefaultChownDirectories lists additional paths requiring ownership repair + // for this framework. Core always supplies its generic baseline path. + DefaultChownDirectories []string + + // PHPStanPaths lists the default PHPStan analysis paths for this framework + // (used both by `govard test phpstan` and `govard vscode setup`'s + // phpstan.options fallback when no path args/project config are given), + // nil for frameworks that use the generic {"app", "src"} default. + PHPStanPaths []string + + // ComposerCodingStandard is the Composer package (e.g. + // "magento/magento-coding-standard") that registers this framework's phpcs + // coding standard, and the --standard label to pass phpcs when that package + // is required. Zero value (empty Package) means this framework has no + // dedicated coding-standard package - replaces one entry of + // internal/cmd/vscode_setup.go's composerCodingStandardPackages, which was + // keyed by package name rather than framework. + ComposerCodingStandard ComposerCodingStandard + // ComposerAuth describes optional Composer repository credentials that this + // framework requires for bootstrap dependency installation. + ComposerAuth ComposerAuthRequirement + + // ToolCommands are framework-owned `govard tool` command declarations. + // Their availability is derived from the resolved registry lineage rather + // than a second framework-name allowlist in internal/cmd. + ToolCommands []ToolCommand + // DefaultTestCommand replaces the generic PHPUnit default when non-empty. + DefaultTestCommand TestCommand + // TestSuiteCommands contains explicitly supported named suites such as MFTF + // or integration. Core only executes the resolved command. + TestSuiteCommands map[string]TestCommand + // Detect describes how to auto-detect this framework from a project // directory (composer.json/package.json/auth.json/file-path matches). // Populated by each framework's Definition() and pushed into @@ -66,6 +118,20 @@ type FrameworkDefinition struct { // SupportsFreshInstall allows `govard bootstrap --fresh` for this // framework. SupportsFreshInstall bool + // MinimumBootstrapVersion is the lowest numeric framework version accepted + // by fresh-install bootstrap; empty means any valid numeric version. + MinimumBootstrapVersion string + // DefaultFreshMetaPackage is the framework's Composer package used when the + // CLI still carries its generic default package value. + DefaultFreshMetaPackage string + // PrepareComposer performs framework-specific compatibility work before a + // clone workflow installs dependencies. Generic bootstrap orchestration does + // not need to know why the work is required. + PrepareComposer func(config engine.Config) error + // RequiresComposerManifestForDumpAutoload prevents a best-effort composer + // dump-autoload call when composer.json is absent. Most frameworks leave it + // false because their clone workflow can still have a usable vendor tree. + RequiresComposerManifestForDumpAutoload bool // FreshInstall runs this framework's fresh-install orchestration // (CreateProject/Install/Configure sequencing, env-up timing, etc.), @@ -115,4 +181,203 @@ type FrameworkDefinition struct { // is unsupported), so gating on that combined condition would make // this hook permanently unreachable for exactly the frameworks that set it. PostCloneHook func(opts bootstrap.Options, projectDir string, helpers bootstrap.CmdHelpers) error + // IgnorePostCloneError allows a framework to recognize an already-complete + // local configuration after its clone hook reports an otherwise non-fatal + // error. Core retains generic composer/vendor fallback handling. + IgnorePostCloneError func(err error, projectDir string) bool + + // PHPImageVariant is the Docker image variant suffix (e.g. "magento1", + // "magento2") this framework's PHP container needs instead of the plain + // "php" image - "" for frameworks that use the plain image. engine can't + // import this package (frameworks imports engine, not the reverse), so + // frameworks.Register pushes this into engine.RegisterPHPImageVariant + // instead of engine reading the field directly; see + // internal/engine/runtime_images.go's PHPImageVariantForFramework. + PHPImageVariant string + // NodeImageFlavor controls the Node image tag shape for this framework. + // "standard" selects node:; empty uses the generic alpine image. + NodeImageFlavor string + // VarnishTemplateFramework optionally identifies another framework whose + // Varnish blueprint assets this definition intentionally reuses. + VarnishTemplateFramework string + + // DBDriverCategory is the phpMyAdmin per-project DB-user/label category + // this framework's database credentials use (e.g. "magento" for both + // magento1 and magento2), injected into the generated PHP config for the + // global phpMyAdmin container. "" means this framework has no category of + // its own - the generated PHP falls back to "app". engine can't import this + // package (frameworks imports engine, not the reverse), so + // frameworks.Register pushes this into engine.RegisterDBDriverCategory + // instead of engine reading the field directly; see + // internal/engine/proxy.go's DBDriverCategoryForFramework. + DBDriverCategory string + + // Upgrade runs this framework's upgrade pipeline (dependency bump, + // migrations, cache flush, etc.), replacing a per-framework case in + // internal/engine/upgrade.go's UpgradeFramework switch. Populated by + // frameworks.Register via engine.RegisterUpgrader. nil for frameworks + // with no upgrade pipeline implemented yet - UpgradeFramework reports + // "not implemented" for them, matching pre-existing behavior. + Upgrade engine.UpgradeFunc + // RunMappingAssetPreparer prepares this framework's per-store + // nginx/apache "run mapping" assets before a blueprint renders (e.g. + // Magento's mage-run-map.conf files), replacing internal/engine's + // former prepareMagentoRunMappingAssets/isMagentoFramework gate. + // Populated by frameworks.Register via + // engine.RegisterRunMappingAssetPreparer. nil for frameworks with + // nothing to prepare here (10 of 14). + RunMappingAssetPreparer engine.RunMappingAssetPreparer + + // TablePrefixDetector reads this framework's own config file (env.php, + // local.xml, parameters.php, etc.) and returns its configured database + // table prefix, replacing a per-framework case in + // internal/engine/table_prefix.go's FrameworkSupportsTablePrefix/ + // DetectMagentoTablePrefix switches. Populated by frameworks.Register via + // engine.RegisterTablePrefixDetector. nil for frameworks with no + // table-prefix concept (most of the 14) - FrameworkSupportsTablePrefix + // reports false for them, matching pre-existing behavior. + TablePrefixDetector engine.TablePrefixDetector + ResolveBootstrapTablePrefix func(configuredPrefix string) (string, error) + BuildDeployLocalesQuery func(tablePrefix string) string + BootstrapPlanSteps func(createAdmin bool) []BootstrapPlanStep + EnableVarnishOnInit bool + + // VersionProfileResolver resolves this framework's version-specific + // runtime-profile overrides (e.g. Magento 2's per-patch-release stack), + // replacing the hardcoded `if framework == "magento2"` branch in + // internal/engine/profile.go's ResolveRuntimeProfile. Populated by + // frameworks.Register via engine.RegisterVersionProfileResolver. nil for + // every framework except magento2 today - all others rely on the generic + // JSON-driven resolveFrameworkProfileFromRegistry fallback instead. + VersionProfileResolver engine.VersionProfileResolver + + // TemplateFuncs contributes additional blueprint-template functions this + // framework needs (e.g. emdash's/nextjs's runtime-command builders), + // merged into the FuncMap available to every rendered blueprint template. + // Keyed the same way the template references them (e.g. + // {{ emdashRuntimeCommand ... }}). nil for frameworks with no custom + // template functions - 12 of the 14 today. + TemplateFuncs template.FuncMap + + // ProbeRemoteDB probes a configured remote (SSH host + path) for this + // framework's live database credentials, replacing a per-framework case + // in internal/cmd/db_credentials.go's resolveRemoteDBCredentials switch. + // Returns remote.RemoteDatabaseMetadata, the generic transport shape + // (Host/Port/Username/Password/Database/TablePrefix). Frameworks whose own + // probe returns a narrower type leave TablePrefix empty. nil for frameworks with + // no remote-DB probing implemented (custom, django, nextjs, emdash) - + // resolveRemoteDBCredentials falls back to remoteCfg.DBName/User/Pass/Port + // the same way the switch's default/"custom" cases do today. + ProbeRemoteDB func(remoteName string, remoteCfg engine.RemoteConfig) (remote.RemoteDatabaseMetadata, error) + // ProbeRemoteBootstrapMetadata retrieves remote metadata required by a + // framework's post-clone bootstrap. Generic core forwards its opaque Private + // values without knowing their framework-specific meaning. + ProbeRemoteBootstrapMetadata func(remoteName string, remoteCfg engine.RemoteConfig) (remote.RemoteDatabaseMetadata, error) + // RemoteDBUsesConfigTablePrefix allows config.TablePrefix to act as a + // fallback only for frameworks whose historical remote DB workflow has a + // table-prefix concept. A probed prefix always wins. Frameworks such as + // WordPress and dotenv-based applications leave this false so a local + // config value cannot silently alter a remote import/query. + RemoteDBUsesConfigTablePrefix bool + + // DefaultAdminPath is this framework's conventional admin route. Empty + // uses the product-wide default. It keeps non-standard paths such as + // Emdash's _emdash/admin with their framework definition instead of in + // command or desktop callers. + DefaultAdminPath string + // ResolveRemoteAdminPath optionally probes a remote deployment for its + // configured admin route. Core consumes the returned path without knowing + // which framework's configuration file supplied it. + ResolveRemoteAdminPath func(remoteName string, remoteCfg engine.RemoteConfig) (string, error) + // DetectLocalAdminMetadata reads framework-owned local admin routing data. + DetectLocalAdminMetadata func(projectRoot string) (frontName string, tablePrefix string) + // BuildLocalAdminSettingsQuery returns the framework-owned query for local + // admin URL settings; core only executes it against the local database. + BuildLocalAdminSettingsQuery func(tablePrefix string) string + // ResolveLocalAdminURL applies framework-specific local admin URL policy. + ResolveLocalAdminURL func(baseURL string, envFrontName string, dbValues map[string]string) string + // PostEnvironmentUp performs framework-specific compatibility work after + // containers become ready. Generic lifecycle orchestration reports failures + // but does not need to identify the framework. + PostEnvironmentUp func(config engine.Config) error + // ConfigureAfterProfileShift applies framework tuning after a detected + // runtime-profile change. The caller controls whether and when to prompt; + // the framework owns the resulting configuration work. + ConfigureAfterProfileShift func(config engine.Config, shift *engine.ProfileShiftInfo) error + // PostSync performs framework-specific filesystem repair after a sync that + // transferred files or media. + PostSync func(config engine.Config) error + // UnblockSearchIndex clears a framework-specific search-engine write block + // when `doctor --fix` identifies that condition. + UnblockSearchIndex func(config engine.Config) error + // BuildSearchHostFixSQL returns the framework-specific SQL required to + // point a local installation at its configured search service. + BuildSearchHostFixSQL func(config engine.Config) string + // BootstrapEnvironmentPath identifies a framework-owned runtime file that + // must be rendered before clone configuration. Empty means none is needed. + BootstrapEnvironmentPath string + // BootstrapEnvironmentMetadataKey identifies the opaque remote metadata + // value used as the environment renderer's secret. Core forwards the value + // without assigning framework meaning to it. + BootstrapEnvironmentMetadataKey string + // RenderBootstrapEnvironment renders a framework's bootstrap runtime file + // from generic connection data and a framework-owned secret. + RenderBootstrapEnvironment func(secret string, database BootstrapEnvironmentDatabase, tablePrefix string) string + + // AutoConfigure runs `govard config auto`'s framework-specific + // post-render setup (env.php generation, table-prefix/crypt-key wiring, + // etc.), replacing a per-framework case in + // internal/cmd/config_auto.go's applyFrameworkAutoConfiguration switch. + // nil for frameworks with nothing to do here - every unlisted framework + // gets a "not supported yet" warning; wordpress explicitly registers a + // no-op instead of leaving this nil, to distinguish "confirmed nothing to + // do" from "not supported". + AutoConfigure func(cmd *cobra.Command, config engine.Config) error +} + +// DefaultDBCredentials is the port/username/password/database-name shape +// for FrameworkDefinition.DefaultDBCredentials. Mirrors the relevant subset +// of internal/cmd's dbCredentials struct (Host/Engine/TablePrefix excluded +// - see FrameworkDefinition.DefaultDBCredentials's doc comment for why). +type DefaultDBCredentials struct { + Port int + Username string + Password string + Database string +} + +type BootstrapPlanStep struct { + Description string + Command string +} + +// MigrationTypes is the set of external tool identifiers that correspond to a +// framework. The canonical name remains FrameworkDefinition.Name. +type MigrationTypes struct { + DDEV []string + Warden []string +} + +// BootstrapEnvironmentDatabase is the common local DB data supplied to a +// framework's bootstrap-environment renderer. +type BootstrapEnvironmentDatabase struct { + Database string + Username string + Password string +} + +// ComposerCodingStandard pairs a Composer package name with the phpcs +// --standard label it registers. See FrameworkDefinition.ComposerCodingStandard. +type ComposerCodingStandard struct { + Package string + Standard string +} + +// ComposerAuthRequirement defines the Composer repository and user-facing +// guidance for framework-owned dependency credentials. An empty Repository +// means no interactive authentication is required. +type ComposerAuthRequirement struct { + Repository string + DisplayName string + CredentialURL string } diff --git a/internal/frameworks/types/spec.go b/internal/frameworks/types/spec.go new file mode 100644 index 00000000..5bd2f2c8 --- /dev/null +++ b/internal/frameworks/types/spec.go @@ -0,0 +1,255 @@ +package types + +import ( + "text/template" + + "govard/internal/engine" + "govard/internal/engine/bootstrap" + "govard/internal/engine/remote" + "govard/internal/engine/tunnel" + + "github.com/spf13/cobra" +) + +// Override represents an explicit change to an inherited definition value. +// Its zero value means inherit. Set applies the supplied value, while Clear +// explicitly applies T's zero value. The distinction is essential for function +// capabilities and scalar defaults, where a plain zero value is ambiguous. +type Override[T any] struct { + set bool + value T +} + +// Set returns an override that replaces an inherited value. +func Set[T any](value T) Override[T] { + return Override[T]{set: true, value: value} +} + +// Clear returns an override that deliberately removes an inherited value. +func Clear[T any]() Override[T] { + return Override[T]{set: true} +} + +func (o Override[T]) apply(target *T) { + if o.set { + *target = o.value + } +} + +// FrameworkPatch contains the first set of inheritable definition fields. +// Additional fields are added here as their consumers move to the resolved +// capability model. Keeping every change explicit prevents child frameworks +// from silently inheriting or clearing policy through Go zero values. +type FrameworkPatch struct { + DisplayName Override[string] + MigrationTypes Override[MigrationTypes] + Config Override[engine.FrameworkConfig] + Manifest Override[engine.FrameworkManifestConfig] + DefaultDBCredentials Override[DefaultDBCredentials] + DefaultChownDirectories Override[[]string] + PHPStanPaths Override[[]string] + ComposerCodingStandard Override[ComposerCodingStandard] + ComposerAuth Override[ComposerAuthRequirement] + ToolCommands Override[[]ToolCommand] + DefaultTestCommand Override[TestCommand] + TestSuiteCommands Override[map[string]TestCommand] + Detect Override[engine.DetectionSpec] + Bootstrap Override[BootstrapFactory] + BaseURLManager Override[func() tunnel.BaseURLManager] + SupportsBootstrap Override[bool] + SupportsFreshInstall Override[bool] + MinimumBootstrapVersion Override[string] + DefaultFreshMetaPackage Override[string] + PrepareComposer Override[func(engine.Config) error] + RequiresComposerManifestForDumpAutoload Override[bool] + FreshInstall Override[func(bootstrap.Options, string, bootstrap.CmdHelpers) error] + FreshInstallNeedsDB Override[bool] + FreshInstallNeedsDomain Override[bool] + FreshInstallManagesOwnEnvUp Override[bool] + PreConfigureHook Override[func(bootstrap.Options, string, bootstrap.CmdHelpers) error] + PostCloneHook Override[func(bootstrap.Options, string, bootstrap.CmdHelpers) error] + IgnorePostCloneError Override[func(error, string) bool] + PHPImageVariant Override[string] + NodeImageFlavor Override[string] + VarnishTemplateFramework Override[string] + DBDriverCategory Override[string] + Upgrade Override[engine.UpgradeFunc] + RunMappingAssetPreparer Override[engine.RunMappingAssetPreparer] + TablePrefixDetector Override[engine.TablePrefixDetector] + ResolveBootstrapTablePrefix Override[func(string) (string, error)] + BuildDeployLocalesQuery Override[func(string) string] + BootstrapPlanSteps Override[func(bool) []BootstrapPlanStep] + EnableVarnishOnInit Override[bool] + VersionProfileResolver Override[engine.VersionProfileResolver] + TemplateFuncs Override[template.FuncMap] + ProbeRemoteDB Override[func(string, engine.RemoteConfig) (remote.RemoteDatabaseMetadata, error)] + ProbeRemoteBootstrapMetadata Override[func(string, engine.RemoteConfig) (remote.RemoteDatabaseMetadata, error)] + RemoteDBUsesConfigTablePrefix Override[bool] + DefaultAdminPath Override[string] + ResolveRemoteAdminPath Override[func(string, engine.RemoteConfig) (string, error)] + DetectLocalAdminMetadata Override[func(string) (string, string)] + BuildLocalAdminSettingsQuery Override[func(string) string] + ResolveLocalAdminURL Override[func(string, string, map[string]string) string] + PostEnvironmentUp Override[func(engine.Config) error] + ConfigureAfterProfileShift Override[func(engine.Config, *engine.ProfileShiftInfo) error] + PostSync Override[func(engine.Config) error] + UnblockSearchIndex Override[func(engine.Config) error] + BuildSearchHostFixSQL Override[func(engine.Config) string] + BootstrapEnvironmentPath Override[string] + BootstrapEnvironmentMetadataKey Override[string] + RenderBootstrapEnvironment Override[func(string, BootstrapEnvironmentDatabase, string) string] + AutoConfigure Override[func(*cobra.Command, engine.Config) error] +} + +// FrameworkSpec is a partial framework declaration. Root specs supply a full +// Definition. Child specs identify a Parent, supply their identity in +// Definition (Name and Aliases), and state every inherited-field delta in Patch. +type FrameworkSpec struct { + Parent string + Definition FrameworkDefinition + Patch FrameworkPatch +} + +// Resolve merges this spec onto parent. Callers must validate Parent and graph +// ordering before calling Resolve. +func (s FrameworkSpec) Resolve(parent FrameworkDefinition) FrameworkDefinition { + if s.Parent == "" { + return cloneDefinition(s.Definition) + } + + resolved := cloneDefinition(parent) + resolved.Name = s.Definition.Name + resolved.Aliases = cloneStrings(s.Definition.Aliases) + resolved.Parent = s.Parent + s.Patch.DisplayName.apply(&resolved.DisplayName) + s.Patch.MigrationTypes.apply(&resolved.MigrationTypes) + s.Patch.Config.apply(&resolved.Config) + s.Patch.Manifest.apply(&resolved.Manifest) + s.Patch.DefaultDBCredentials.apply(&resolved.DefaultDBCredentials) + s.Patch.DefaultChownDirectories.apply(&resolved.DefaultChownDirectories) + s.Patch.PHPImageVariant.apply(&resolved.PHPImageVariant) + s.Patch.NodeImageFlavor.apply(&resolved.NodeImageFlavor) + s.Patch.VarnishTemplateFramework.apply(&resolved.VarnishTemplateFramework) + s.Patch.PHPStanPaths.apply(&resolved.PHPStanPaths) + s.Patch.ComposerCodingStandard.apply(&resolved.ComposerCodingStandard) + s.Patch.ComposerAuth.apply(&resolved.ComposerAuth) + s.Patch.ToolCommands.apply(&resolved.ToolCommands) + s.Patch.DefaultTestCommand.apply(&resolved.DefaultTestCommand) + s.Patch.TestSuiteCommands.apply(&resolved.TestSuiteCommands) + s.Patch.Detect.apply(&resolved.Detect) + s.Patch.Bootstrap.apply(&resolved.Bootstrap) + s.Patch.BaseURLManager.apply(&resolved.BaseURLManager) + s.Patch.SupportsBootstrap.apply(&resolved.SupportsBootstrap) + s.Patch.SupportsFreshInstall.apply(&resolved.SupportsFreshInstall) + s.Patch.MinimumBootstrapVersion.apply(&resolved.MinimumBootstrapVersion) + s.Patch.DefaultFreshMetaPackage.apply(&resolved.DefaultFreshMetaPackage) + s.Patch.PrepareComposer.apply(&resolved.PrepareComposer) + s.Patch.RequiresComposerManifestForDumpAutoload.apply(&resolved.RequiresComposerManifestForDumpAutoload) + s.Patch.FreshInstall.apply(&resolved.FreshInstall) + s.Patch.FreshInstallNeedsDB.apply(&resolved.FreshInstallNeedsDB) + s.Patch.FreshInstallNeedsDomain.apply(&resolved.FreshInstallNeedsDomain) + s.Patch.FreshInstallManagesOwnEnvUp.apply(&resolved.FreshInstallManagesOwnEnvUp) + s.Patch.PreConfigureHook.apply(&resolved.PreConfigureHook) + s.Patch.PostCloneHook.apply(&resolved.PostCloneHook) + s.Patch.IgnorePostCloneError.apply(&resolved.IgnorePostCloneError) + s.Patch.DBDriverCategory.apply(&resolved.DBDriverCategory) + s.Patch.Upgrade.apply(&resolved.Upgrade) + s.Patch.RunMappingAssetPreparer.apply(&resolved.RunMappingAssetPreparer) + s.Patch.TablePrefixDetector.apply(&resolved.TablePrefixDetector) + s.Patch.ResolveBootstrapTablePrefix.apply(&resolved.ResolveBootstrapTablePrefix) + s.Patch.BuildDeployLocalesQuery.apply(&resolved.BuildDeployLocalesQuery) + s.Patch.BootstrapPlanSteps.apply(&resolved.BootstrapPlanSteps) + s.Patch.EnableVarnishOnInit.apply(&resolved.EnableVarnishOnInit) + s.Patch.VersionProfileResolver.apply(&resolved.VersionProfileResolver) + s.Patch.TemplateFuncs.apply(&resolved.TemplateFuncs) + s.Patch.ProbeRemoteDB.apply(&resolved.ProbeRemoteDB) + s.Patch.ProbeRemoteBootstrapMetadata.apply(&resolved.ProbeRemoteBootstrapMetadata) + s.Patch.RemoteDBUsesConfigTablePrefix.apply(&resolved.RemoteDBUsesConfigTablePrefix) + s.Patch.DefaultAdminPath.apply(&resolved.DefaultAdminPath) + s.Patch.ResolveRemoteAdminPath.apply(&resolved.ResolveRemoteAdminPath) + s.Patch.DetectLocalAdminMetadata.apply(&resolved.DetectLocalAdminMetadata) + s.Patch.BuildLocalAdminSettingsQuery.apply(&resolved.BuildLocalAdminSettingsQuery) + s.Patch.ResolveLocalAdminURL.apply(&resolved.ResolveLocalAdminURL) + s.Patch.PostEnvironmentUp.apply(&resolved.PostEnvironmentUp) + s.Patch.ConfigureAfterProfileShift.apply(&resolved.ConfigureAfterProfileShift) + s.Patch.PostSync.apply(&resolved.PostSync) + s.Patch.UnblockSearchIndex.apply(&resolved.UnblockSearchIndex) + s.Patch.BuildSearchHostFixSQL.apply(&resolved.BuildSearchHostFixSQL) + s.Patch.BootstrapEnvironmentPath.apply(&resolved.BootstrapEnvironmentPath) + s.Patch.BootstrapEnvironmentMetadataKey.apply(&resolved.BootstrapEnvironmentMetadataKey) + s.Patch.RenderBootstrapEnvironment.apply(&resolved.RenderBootstrapEnvironment) + s.Patch.AutoConfigure.apply(&resolved.AutoConfigure) + return cloneDefinition(resolved) +} + +func cloneStrings(values []string) []string { + if values == nil { + return nil + } + cloned := make([]string, len(values)) + copy(cloned, values) + return cloned +} + +func cloneDefinition(def FrameworkDefinition) FrameworkDefinition { + cloned := def + cloned.Aliases = cloneStrings(def.Aliases) + cloned.MigrationTypes.DDEV = cloneStrings(def.MigrationTypes.DDEV) + cloned.MigrationTypes.Warden = cloneStrings(def.MigrationTypes.Warden) + cloned.PHPStanPaths = cloneStrings(def.PHPStanPaths) + cloned.DefaultChownDirectories = cloneStrings(def.DefaultChownDirectories) + cloned.ToolCommands = cloneToolCommands(def.ToolCommands) + cloned.DefaultTestCommand.Args = cloneStrings(def.DefaultTestCommand.Args) + cloned.TestSuiteCommands = cloneTestSuiteCommands(def.TestSuiteCommands) + cloned.Config.Includes = cloneStrings(def.Config.Includes) + cloned.Manifest.Ignored = cloneStrings(def.Manifest.Ignored) + cloned.Manifest.Sensitive = cloneStrings(def.Manifest.Sensitive) + cloned.Manifest.Paths.WebRootCandidates = append([]engine.FrameworkWebRootCandidate(nil), def.Manifest.Paths.WebRootCandidates...) + cloned.Manifest.Sync.NoiseExcludes = cloneStrings(def.Manifest.Sync.NoiseExcludes) + cloned.Manifest.Sync.MediaExcludes.NonAll = cloneStrings(def.Manifest.Sync.MediaExcludes.NonAll) + cloned.Manifest.Sync.MediaExcludes.Optimized = cloneStrings(def.Manifest.Sync.MediaExcludes.Optimized) + cloned.Manifest.Sync.MediaExcludes.Minimal = cloneStrings(def.Manifest.Sync.MediaExcludes.Minimal) + cloned.Detect.ComposerPackages = cloneStrings(def.Detect.ComposerPackages) + cloned.Detect.PackageJSONDeps = cloneStrings(def.Detect.PackageJSONDeps) + cloned.Detect.AuthJSONHosts = cloneStrings(def.Detect.AuthJSONHosts) + cloned.Detect.FilePaths = cloneStrings(def.Detect.FilePaths) + if def.TemplateFuncs != nil { + cloned.TemplateFuncs = make(template.FuncMap, len(def.TemplateFuncs)) + for name, fn := range def.TemplateFuncs { + cloned.TemplateFuncs[name] = fn + } + } + return cloned +} + +func cloneToolCommands(commands []ToolCommand) []ToolCommand { + if commands == nil { + return nil + } + cloned := make([]ToolCommand, len(commands)) + for index, command := range commands { + cloned[index] = command + cloned[index].Aliases = cloneStrings(command.Aliases) + cloned[index].PrependArgs = cloneStrings(command.PrependArgs) + } + return cloned +} + +func cloneTestSuiteCommands(commands map[string]TestCommand) map[string]TestCommand { + if commands == nil { + return nil + } + cloned := make(map[string]TestCommand, len(commands)) + for name, command := range commands { + command.Args = cloneStrings(command.Args) + cloned[name] = command + } + return cloned +} + +// CloneDefinition returns a deep-enough copy for registry callers. Functions +// remain shared because they are immutable values; every slice and map exposed +// by the current definition contract receives its own backing storage. +func CloneDefinition(def FrameworkDefinition) FrameworkDefinition { + return cloneDefinition(def) +} diff --git a/internal/frameworks/types/testing.go b/internal/frameworks/types/testing.go new file mode 100644 index 00000000..da883520 --- /dev/null +++ b/internal/frameworks/types/testing.go @@ -0,0 +1,8 @@ +package types + +// TestCommand describes one framework-owned project test invocation. +type TestCommand struct { + Label string + Binary string + Args []string +} diff --git a/internal/frameworks/types/tooling.go b/internal/frameworks/types/tooling.go new file mode 100644 index 00000000..e276e5d9 --- /dev/null +++ b/internal/frameworks/types/tooling.go @@ -0,0 +1,13 @@ +package types + +// ToolCommand describes a framework-owned CLI exposed by `govard tool`. +// The owning framework is resolved by the registry; callers do not maintain a +// separate framework-name allowlist. +type ToolCommand struct { + Name string + Aliases []string + Short string + Binary string + PrependArgs []string + DefaultUser string +} diff --git a/internal/blueprints/files/support/nginx/templates/wordpress.conf b/internal/frameworks/wordpress/blueprint/wordpress.conf similarity index 100% rename from internal/blueprints/files/support/nginx/templates/wordpress.conf rename to internal/frameworks/wordpress/blueprint/wordpress.conf diff --git a/internal/engine/wordpress_compatibility.go b/internal/frameworks/wordpress/compatibility.go similarity index 98% rename from internal/engine/wordpress_compatibility.go rename to internal/frameworks/wordpress/compatibility.go index bc4e9abd..fb813937 100644 --- a/internal/engine/wordpress_compatibility.go +++ b/internal/frameworks/wordpress/compatibility.go @@ -1,4 +1,4 @@ -package engine +package wordpress import ( "fmt" @@ -6,6 +6,7 @@ import ( "strings" "govard/internal/conventions" + "govard/internal/engine" "github.com/pterm/pterm" ) @@ -30,7 +31,7 @@ const ( // FixWordPressCompatibility ensures the PHP container has WP-CLI (wp) installed. // It downloads the WP-CLI phar directly from the official builds repository, // selecting the version based on the detected WordPress version. -func FixWordPressCompatibility(config Config) error { +func FixWordPressCompatibility(config engine.Config) error { if config.Framework != conventions.FrameworkWordPress { return nil } diff --git a/internal/frameworks/wordpress/embed.go b/internal/frameworks/wordpress/embed.go new file mode 100644 index 00000000..583c426f --- /dev/null +++ b/internal/frameworks/wordpress/embed.go @@ -0,0 +1,28 @@ +package wordpress + +import ( + "embed" + "io/fs" + + "govard/internal/blueprints" +) + +//go:embed all:blueprint +var blueprintFiles embed.FS + +var BlueprintFS fs.FS + +func init() { + var err error + BlueprintFS, err = fs.Sub(blueprintFiles, "blueprint") + if err != nil { + panic(err) + } + + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "wordpress", + FS: BlueprintFS, + HasDir: false, + NginxTemplate: "wordpress.conf", + }) +} diff --git a/internal/engine/remote/wordpress_metadata.go b/internal/frameworks/wordpress/metadata.go similarity index 51% rename from internal/engine/remote/wordpress_metadata.go rename to internal/frameworks/wordpress/metadata.go index 1ec6b075..cbb153e5 100644 --- a/internal/engine/remote/wordpress_metadata.go +++ b/internal/frameworks/wordpress/metadata.go @@ -1,4 +1,4 @@ -package remote +package wordpress import ( "encoding/base64" @@ -7,9 +7,19 @@ import ( "strings" "govard/internal/engine" + "govard/internal/engine/remote" ) -type WordPressDBInfo struct { +// Environment holds credentials extracted remotely from this framework's +// wp-config.php. +type Environment struct { + DB DatabaseInfo +} + +// DatabaseInfo is the WordPress wp-config.php connection shape. It intentionally +// has no table-prefix field: WordPress remote imports do not inherit a local +// configuration prefix. +type DatabaseInfo struct { Host string Port int Username string @@ -17,28 +27,32 @@ type WordPressDBInfo struct { Database string } -type WordPressEnvironment struct { - DB WordPressDBInfo -} - -func ProbeWordPressEnvironment(remoteName string, remoteCfg engine.RemoteConfig) (WordPressEnvironment, error) { - remoteCommand := buildMagentoRemoteCommand(remoteCfg.Path, `php -r `+engine.ShellQuote(wordpressDBProbePHP)) - encoded, err := runRemoteCapture(remoteName, remoteCfg, remoteCommand) +// ProbeEnvironment SSHs to the remote project and extracts WordPress DB +// credentials from wp-config.php. +func ProbeEnvironment(remoteName string, remoteCfg engine.RemoteConfig) (Environment, error) { + remoteCommand := remote.BuildProjectRemoteCommand(remoteCfg.Path, `php -r `+engine.ShellQuote(dbProbePHP)) + encoded, err := remote.RunRemoteCapture(remoteName, remoteCfg, remoteCommand) if err != nil { - return WordPressEnvironment{}, err + return Environment{}, err } - return decodeWordPressEnvironmentPayload(encoded) + return decodeEnvironmentPayload(encoded) +} + +// DecodeEnvironmentPayloadForTest makes the remote payload boundary testable +// without an SSH server. +func DecodeEnvironmentPayloadForTest(encoded string) (Environment, error) { + return decodeEnvironmentPayload(encoded) } -func decodeWordPressEnvironmentPayload(encoded string) (WordPressEnvironment, error) { +func decodeEnvironmentPayload(encoded string) (Environment, error) { trimmed := strings.TrimSpace(encoded) if trimmed == "" { - return WordPressEnvironment{}, fmt.Errorf("remote probe returned empty payload") + return Environment{}, fmt.Errorf("remote probe returned empty payload") } decoded, err := base64.StdEncoding.DecodeString(trimmed) if err != nil { - return WordPressEnvironment{}, fmt.Errorf("decode remote probe payload: %w", err) + return Environment{}, fmt.Errorf("decode remote probe payload: %w", err) } var payload struct { @@ -48,28 +62,26 @@ func decodeWordPressEnvironmentPayload(encoded string) (WordPressEnvironment, er DBName string `json:"dbname"` } if err := json.Unmarshal(decoded, &payload); err != nil { - return WordPressEnvironment{}, fmt.Errorf("parse remote probe payload: %w", err) + return Environment{}, fmt.Errorf("parse remote probe payload: %w", err) } - host, port := ParseMagentoDBHostPort(payload.Host) + host, port := remote.ParseDatabaseHostPort(payload.Host) username := strings.TrimSpace(payload.Username) database := strings.TrimSpace(payload.DBName) if username == "" || database == "" { - return WordPressEnvironment{}, fmt.Errorf("remote wp-config.php is missing DB_USER or DB_NAME") + return Environment{}, fmt.Errorf("remote wp-config.php is missing DB_USER or DB_NAME") } - return WordPressEnvironment{ - DB: WordPressDBInfo{ - Host: host, - Port: port, - Username: username, - Password: payload.Password, - Database: database, - }, - }, nil + return Environment{DB: DatabaseInfo{ + Host: host, + Port: port, + Username: username, + Password: payload.Password, + Database: database, + }}, nil } -const wordpressDBProbePHP = ` +const dbProbePHP = ` $dbname = ""; $dbuser = ""; $dbpass = ""; $dbhost = ""; $content = @file_get_contents("wp-config.php"); if ($content) { @@ -77,7 +89,6 @@ if ($content) { if (preg_match("/define\s*\(\s*['\"]DB_USER['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $content, $m)) $dbuser = $m[1]; if (preg_match("/define\s*\(\s*['\"]DB_PASSWORD['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $content, $m)) $dbpass = $m[1]; if (preg_match("/define\s*\(\s*['\"]DB_HOST['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $content, $m)) $dbhost = $m[1]; - if (!$dbname || !$dbuser) { define('SHORTINIT', true); @include "wp-config.php"; diff --git a/internal/frameworks/wordpress/spec.go b/internal/frameworks/wordpress/spec.go new file mode 100644 index 00000000..df436d8c --- /dev/null +++ b/internal/frameworks/wordpress/spec.go @@ -0,0 +1,6 @@ +package wordpress + +import "govard/internal/frameworks/types" + +// Spec declares WordPress as a root framework. +func Spec() types.FrameworkSpec { return types.FrameworkSpec{Definition: Definition()} } diff --git a/internal/engine/upgrade_wordpress.go b/internal/frameworks/wordpress/upgrade.go similarity index 90% rename from internal/engine/upgrade_wordpress.go rename to internal/frameworks/wordpress/upgrade.go index a1ad1750..1a1e431c 100644 --- a/internal/engine/upgrade_wordpress.go +++ b/internal/frameworks/wordpress/upgrade.go @@ -1,4 +1,4 @@ -package engine +package wordpress import ( "context" @@ -7,9 +7,12 @@ import ( "github.com/pterm/pterm" "govard/internal/conventions" + "govard/internal/engine" ) -func upgradeWordPress(ctx context.Context, config Config, opts UpgradeOptions) error { +// Upgrade is wordpress's engine.UpgradeFunc, moved verbatim from +// engine.upgradeWordPress. +func Upgrade(ctx context.Context, config engine.Config, opts engine.UpgradeOptions) error { pterm.Info.Println("WordPress Upgrade Pipeline") containerName := fmt.Sprintf("%s%s", opts.ProjectName, conventions.PHPSuffix) diff --git a/internal/frameworks/wordpress/wordpress.go b/internal/frameworks/wordpress/wordpress.go index a3fa2f89..e13e6702 100644 --- a/internal/frameworks/wordpress/wordpress.go +++ b/internal/frameworks/wordpress/wordpress.go @@ -1,32 +1,91 @@ package wordpress import ( + "govard/internal/conventions" "govard/internal/engine" "govard/internal/engine/bootstrap" + "govard/internal/engine/remote" "govard/internal/engine/tunnel" + "govard/internal/frameworks/shared/dotenv" "govard/internal/frameworks/types" + "os" + "path/filepath" + + "github.com/spf13/cobra" ) func Definition() types.FrameworkDefinition { return types.FrameworkDefinition{ - Name: "wordpress", - Aliases: []string{"wp"}, - DisplayName: "WordPress", - Config: config, - Manifest: manifest, + Name: "wordpress", + Aliases: []string{"wp"}, + DisplayName: "WordPress", + MigrationTypes: types.MigrationTypes{DDEV: []string{"wordpress"}, Warden: []string{"wordpress"}}, + Config: config, + Manifest: manifest, + DefaultDBCredentials: types.DefaultDBCredentials{ + Port: conventions.MySQLPort, + Username: conventions.DefaultWordPressDBUser, + Password: conventions.DefaultWordPressDBPass, + Database: conventions.DefaultWordPressDBName, + }, Detect: engine.DetectionSpec{ ComposerPackages: []string{"johnpbloch/wordpress", "roots/wordpress", "wordpress/wordpress"}, }, + ComposerCodingStandard: types.ComposerCodingStandard{Package: "wp-coding-standards/wpcs", Standard: "WordPress"}, + ToolCommands: []types.ToolCommand{ + {Name: "wp", Short: "Run WordPress CLI commands", Binary: "wp"}, + }, Bootstrap: func(opts bootstrap.Options) bootstrap.FrameworkBootstrap { return NewWordPressBootstrap(opts) }, BaseURLManager: func() tunnel.BaseURLManager { return &WordPressManager{} }, - FreshInstall: freshInstall, - FreshInstallNeedsDB: true, - FreshInstallNeedsDomain: true, - SupportsBootstrap: true, - SupportsFreshInstall: true, + FreshInstall: freshInstall, + FreshInstallNeedsDB: true, + FreshInstallNeedsDomain: true, + SupportsBootstrap: true, + SupportsFreshInstall: true, + PrepareComposer: FixWordPressCompatibility, + RequiresComposerManifestForDumpAutoload: true, + PostEnvironmentUp: FixWordPressCompatibility, + IgnorePostCloneError: func(err error, projectDir string) bool { + if err == nil { + return false + } + _, statErr := os.Stat(filepath.Join(projectDir, "wp-config.php")) + return statErr == nil + }, + DBDriverCategory: "wordpress", + Upgrade: Upgrade, + ProbeRemoteDB: func(remoteName string, remoteCfg engine.RemoteConfig) (remote.RemoteDatabaseMetadata, error) { + metadata, err := ProbeEnvironment(remoteName, remoteCfg) + if err != nil { + // Bedrock-style WordPress sites keep DB creds in .env, not wp-config.php - + // fall back to the generic dotenv probe, matching the pre-existing + // two-step behavior in resolveRemoteDBCredentials. + dotenv, dotenvErr := dotenv.ProbeEnvironment(remoteName, remoteCfg) + if dotenvErr == nil { + return remote.RemoteDatabaseMetadata{ + Host: dotenv.DB.Host, + Port: dotenv.DB.Port, + Username: dotenv.DB.Username, + Password: dotenv.DB.Password, + Database: dotenv.DB.Database, + }, nil + } + return remote.RemoteDatabaseMetadata{}, err + } + return remote.RemoteDatabaseMetadata{ + Host: metadata.DB.Host, + Port: metadata.DB.Port, + Username: metadata.DB.Username, + Password: metadata.DB.Password, + Database: metadata.DB.Database, + }, nil + }, + AutoConfigure: func(cmd *cobra.Command, config engine.Config) error { + return nil + }, } } diff --git a/tests/blueprint_content_test.go b/tests/blueprint_content_test.go index b505f166..810adb90 100644 --- a/tests/blueprint_content_test.go +++ b/tests/blueprint_content_test.go @@ -1,13 +1,12 @@ package tests import ( - "io" "os" "path/filepath" - "runtime" "strings" "testing" + "govard/internal/blueprints" "govard/internal/engine" "gopkg.in/yaml.v3" @@ -25,10 +24,6 @@ func TestRenderMagento2Blueprint(t *testing.T) { // Without this, `govard env up` succeeds but the site returns 502 Bad Gateway. tempDir := t.TempDir() setTestGovardHome(t, tempDir) - t.Setenv("GOVARD_BLUEPRINTS_DIR", func() string { - _, filename, _, _ := runtime.Caller(0) - return filepath.Join(filepath.Dir(filename), "..", "internal", "blueprints", "files") - }()) config := engine.Config{ ProjectName: "sample-project", @@ -125,11 +120,8 @@ func TestRenderBlueprintMountsGovardRootCAIntoPHPRuntimes(t *testing.T) { govardHome := setTestGovardHome(t, tempDir) // We need to set up blueprints in the tempDir because we are calling RenderBlueprint directly on it - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -298,12 +290,8 @@ func TestRenderBlueprintReRendersWhenKnownProjectDomainsChange(t *testing.T) { registryPath := filepath.Join(t.TempDir(), "projects.json") t.Setenv(engine.ProjectRegistryPathEnvVar, registryPath) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -367,12 +355,8 @@ func TestRenderBlueprintReRendersWhenGovardRootCAAppears(t *testing.T) { tempDir := t.TempDir() govardHome := setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -485,12 +469,8 @@ func TestRenderEmdashBlueprintWithDetectedPNPM(t *testing.T) { tempDir := t.TempDir() setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } if err := os.WriteFile(filepath.Join(tempDir, "package.json"), []byte(`{"packageManager":"pnpm@10.11.0"}`), 0644); err != nil { @@ -534,12 +514,8 @@ func TestRenderNextjsBlueprintSkipsManagedWebServerAssets(t *testing.T) { tempDir := t.TempDir() homeDir := setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -569,12 +545,8 @@ func TestRenderEmdashBlueprintSkipsManagedWebServerAssets(t *testing.T) { tempDir := t.TempDir() homeDir := setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -675,12 +647,8 @@ func TestRenderBlueprintWithFeatures(t *testing.T) { tempDir := t.TempDir() setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -740,12 +708,8 @@ func TestRenderMagento2BlueprintHybridWebServer(t *testing.T) { tempDir := t.TempDir() setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -822,12 +786,8 @@ func TestRenderMagento2BlueprintWithVarnishAcrossWebServers(t *testing.T) { tempDir := t.TempDir() homeDir := setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -902,12 +862,8 @@ func TestRenderMagento2BlueprintWithMageRunMappings(t *testing.T) { tempDir := t.TempDir() homeDir := setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -969,12 +925,8 @@ func TestRenderMagento2BlueprintWithRenderedNginxConfig(t *testing.T) { tempDir := t.TempDir() homeDir := setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -1027,12 +979,8 @@ func TestRenderMagento2BlueprintHybridWithRenderedNginxConfig(t *testing.T) { tempDir := t.TempDir() homeDir := setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -1082,12 +1030,8 @@ func TestRenderMagento1BlueprintApacheWithMageRunMappings(t *testing.T) { tempDir := t.TempDir() homeDir := setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -1158,12 +1102,8 @@ func TestRenderMagento1BlueprintApacheWithRenderedHTTPDConfig(t *testing.T) { tempDir := t.TempDir() homeDir := setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -1220,12 +1160,8 @@ func TestRenderMagento2BlueprintHybridWithRenderedApacheHTTPDConfig(t *testing.T tempDir := t.TempDir() homeDir := setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -1278,12 +1214,8 @@ func testBlueprintRender(t *testing.T, framework string, expectedStrings []strin tempDir := t.TempDir() setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -1336,44 +1268,6 @@ func testBlueprintRender(t *testing.T, framework string, expectedStrings []strin } } -func copyDir(src, dst string) error { - return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - relPath, err := filepath.Rel(src, path) - if err != nil { - return err - } - - dstPath := filepath.Join(dst, relPath) - - if info.IsDir() { - return os.MkdirAll(dstPath, info.Mode()) - } - - return copyFile(path, dstPath) - }) -} - -func copyFile(src, dst string) error { - sourceFile, err := os.Open(src) - if err != nil { - return err - } - defer sourceFile.Close() - - destFile, err := os.Create(dst) - if err != nil { - return err - } - defer destFile.Close() - - _, err = io.Copy(destFile, sourceFile) - return err -} - func setTestGovardHome(t *testing.T, root string) string { t.Helper() diff --git a/tests/blueprint_features_test.go b/tests/blueprint_features_test.go index c8ad2bb0..db9cb0d3 100644 --- a/tests/blueprint_features_test.go +++ b/tests/blueprint_features_test.go @@ -3,10 +3,10 @@ package tests import ( "os" "path/filepath" - "runtime" "strings" "testing" + "govard/internal/blueprints" "govard/internal/engine" ) @@ -148,12 +148,8 @@ func renderComposeWithConfig(t *testing.T, config engine.Config) string { tempDir := t.TempDir() setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } diff --git a/tests/blueprint_override_test.go b/tests/blueprint_override_test.go index c05f41f1..b28ce3cf 100644 --- a/tests/blueprint_override_test.go +++ b/tests/blueprint_override_test.go @@ -3,10 +3,10 @@ package tests import ( "os" "path/filepath" - "runtime" "strings" "testing" + "govard/internal/blueprints" "govard/internal/engine" ) @@ -14,12 +14,8 @@ func TestRenderBlueprintMergesProjectComposeOverride(t *testing.T) { tempDir := t.TempDir() setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("copy blueprints: %v", err) } diff --git a/tests/blueprint_source_override_test.go b/tests/blueprint_source_override_test.go new file mode 100644 index 00000000..67192e88 --- /dev/null +++ b/tests/blueprint_source_override_test.go @@ -0,0 +1,63 @@ +package tests + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "govard/internal/engine" +) + +// This catches the consolidation regression where a checkout-local framework +// blueprint was ignored because only the compiled-in union filesystem was +// considered after assets moved out of internal/blueprints/files. +func TestRenderBlueprintUsesCheckoutFrameworkAssetOverride(t *testing.T) { + checkout := t.TempDir() + projectDir := filepath.Join(checkout, "project") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("create project: %v", err) + } + + // This directory marks checkout as a source-tree layout. Its framework + // asset overlays the embedded Laravel template, while all other assets + // continue to come from the embedded base filesystem. + sharedDir := filepath.Join(checkout, "internal", "blueprints", "files") + if err := os.MkdirAll(sharedDir, 0o755); err != nil { + t.Fatalf("create shared blueprints directory: %v", err) + } + frameworkBlueprintDir := filepath.Join(checkout, "internal", "frameworks", "laravel", "blueprint") + if err := os.MkdirAll(frameworkBlueprintDir, 0o755); err != nil { + t.Fatalf("create Laravel blueprint directory: %v", err) + } + const marker = "# checkout-laravel-override" + if err := os.WriteFile(filepath.Join(frameworkBlueprintDir, "laravel.conf"), []byte(marker+"\n"), 0o644); err != nil { + t.Fatalf("write Laravel override: %v", err) + } + + setTestGovardHome(t, checkout) + config := engine.Config{ + ProjectName: "checkout-override", + Framework: "laravel", + Domain: "checkout-override.test", + Stack: engine.Stack{ + PHPVersion: "8.4", + Services: engine.Services{ + WebServer: "nginx", + DB: "mariadb", + }, + }, + } + if err := engine.RenderBlueprint(projectDir, config); err != nil { + t.Fatalf("render blueprint: %v", err) + } + + nginxPath := filepath.Join(engine.GovardHomeDir(), "nginx", config.ProjectName, "default.conf") + nginx, err := os.ReadFile(nginxPath) + if err != nil { + t.Fatalf("read rendered nginx config: %v", err) + } + if !strings.Contains(string(nginx), marker) { + t.Fatalf("expected checkout framework blueprint marker in rendered nginx config, got:\n%s", nginx) + } +} diff --git a/tests/blueprint_workflow_test.go b/tests/blueprint_workflow_test.go index 5bce0f12..7baf889b 100644 --- a/tests/blueprint_workflow_test.go +++ b/tests/blueprint_workflow_test.go @@ -3,10 +3,10 @@ package tests import ( "os" "path/filepath" - "runtime" "strings" "testing" + "govard/internal/blueprints" "govard/internal/engine" "gopkg.in/yaml.v3" @@ -18,12 +18,8 @@ func TestFullSetupLogic(t *testing.T) { projectName := filepath.Base(tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -107,12 +103,8 @@ func TestRenderBlueprintReRendersWhenBlueprintContentsChange(t *testing.T) { tempDir := t.TempDir() setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -174,12 +166,8 @@ func TestRenderBlueprintReRendersWhenProjectComposeOverrideChanges(t *testing.T) tempDir := t.TempDir() setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -237,12 +225,8 @@ func TestRenderBlueprintReRendersWhenNginxCustomConfigDirChanges(t *testing.T) { tempDir := t.TempDir() setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -296,12 +280,8 @@ func TestRenderBlueprintReRendersWhenApacheCustomConfigDirChanges(t *testing.T) tempDir := t.TempDir() setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -355,12 +335,8 @@ func TestRenderBlueprintReRendersWhenPackageManagerSignalChanges(t *testing.T) { tempDir := t.TempDir() setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } if err := os.WriteFile(filepath.Join(tempDir, "package-lock.json"), []byte("{}"), 0o644); err != nil { @@ -410,12 +386,8 @@ func TestRenderBlueprintReRendersWhenSSHAuthSockChanges(t *testing.T) { tempDir := t.TempDir() setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -469,12 +441,8 @@ func TestRenderBlueprintIncludesNginxCustomConfigDir(t *testing.T) { tempDir := t.TempDir() setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -548,12 +516,8 @@ func TestRenderBlueprintIncludesApacheCustomConfigDir(t *testing.T) { tempDir := t.TempDir() setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } @@ -619,12 +583,8 @@ func TestRenderBlueprintIncludesApacheCustomConfigDirInHybridMode(t *testing.T) tempDir := t.TempDir() setTestGovardHome(t, tempDir) - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("Failed to copy blueprints: %v", err) } diff --git a/tests/blueprints_union_fs_test.go b/tests/blueprints_union_fs_test.go new file mode 100644 index 00000000..96a30823 --- /dev/null +++ b/tests/blueprints_union_fs_test.go @@ -0,0 +1,125 @@ +package tests + +import ( + "errors" + "io/fs" + "testing" + "testing/fstest" + + "govard/internal/blueprints" +) + +var errBrokenBlueprintMount = errors.New("broken blueprint mount") + +type brokenBlueprintMountFS struct{} + +func (brokenBlueprintMountFS) Open(string) (fs.File, error) { + return nil, errBrokenBlueprintMount +} + +func TestUnionFSWalkDirEnumeratesMergedTreeExactlyOnce(t *testing.T) { + t.Cleanup(blueprints.ResetMountsForTest()) + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "testfw", + FS: fstest.MapFS{"services.yml": &fstest.MapFile{Data: []byte("a: 1")}, "testfw.conf": &fstest.MapFile{Data: []byte("conf")}}, + HasDir: true, + NginxTemplate: "testfw.conf", + }) + + var visited []string + err := fs.WalkDir(blueprints.FS, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + visited = append(visited, path) + } + return nil + }) + if err != nil { + t.Fatalf("WalkDir: %v", err) + } + + foundServicesYML := false + foundNginxConf := false + nginxConfCount := 0 + for _, p := range visited { + if p == "testfw/services.yml" { + foundServicesYML = true + } + if p == "support/nginx/templates/testfw.conf" { + foundNginxConf = true + nginxConfCount++ + } + if p == "testfw/testfw.conf" { + t.Errorf("nginx template leaked into dir-mount listing at %q - should only appear under support/nginx/templates/", p) + } + } + if !foundServicesYML { + t.Errorf("expected testfw/services.yml in walk, got %v", visited) + } + if !foundNginxConf { + t.Errorf("expected support/nginx/templates/testfw.conf in walk, got %v", visited) + } + if nginxConfCount != 1 { + t.Errorf("expected testfw.conf visited exactly once, got %d times", nginxConfCount) + } +} + +func TestUnionFSStatDirMountRootReportsVirtualName(t *testing.T) { + t.Cleanup(blueprints.ResetMountsForTest()) + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "testfw", + FS: fstest.MapFS{"services.yml": &fstest.MapFile{Data: []byte("a: 1")}}, + HasDir: true, + }) + + info, err := blueprints.Stat("testfw") + if err != nil { + t.Fatalf("Stat(testfw): %v", err) + } + if info.Name() != "testfw" { + t.Errorf("Stat(testfw).Name() = %q, want %q", info.Name(), "testfw") + } + if !info.IsDir() { + t.Errorf("Stat(testfw).IsDir() = false, want true") + } +} + +func TestUnionFSFallbackPathsStillWork(t *testing.T) { + t.Cleanup(blueprints.ResetMountsForTest()) + // proxy.yml is a real, never-relocated file under internal/blueprints/files/. + data, err := fs.ReadFile(blueprints.FS, "proxy.yml") + if err != nil { + t.Fatalf("ReadFile(proxy.yml): %v", err) + } + if len(data) == 0 { + t.Errorf("proxy.yml read via union FS is empty") + } +} + +func TestUnionFSReportsBrokenFrameworkTemplateMount(t *testing.T) { + t.Cleanup(blueprints.ResetMountsForTest()) + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "broken", + FS: brokenBlueprintMountFS{}, + NginxTemplate: "broken.conf", + }) + + if _, err := fs.ReadDir(blueprints.FS, "support/nginx/templates"); !errors.Is(err, errBrokenBlueprintMount) { + t.Fatalf("ReadDir() error = %v, want broken mount error", err) + } +} + +func TestUnionFSConformance(t *testing.T) { + t.Cleanup(blueprints.ResetMountsForTest()) + blueprints.RegisterFrameworkMount(blueprints.FrameworkMount{ + Framework: "testfw", + FS: fstest.MapFS{"services.yml": &fstest.MapFile{Data: []byte("a: 1")}, "testfw.conf": &fstest.MapFile{Data: []byte("conf")}}, + HasDir: true, + NginxTemplate: "testfw.conf", + }) + if err := fstest.TestFS(blueprints.FS, "proxy.yml", "testfw/services.yml", "support/nginx/templates/testfw.conf", "support/nginx/templates/default.conf"); err != nil { + t.Fatalf("fstest.TestFS: %v", err) + } +} diff --git a/tests/bootstrap_composer_policy_test.go b/tests/bootstrap_composer_policy_test.go new file mode 100644 index 00000000..58df0b10 --- /dev/null +++ b/tests/bootstrap_composer_policy_test.go @@ -0,0 +1,22 @@ +package tests + +import ( + "testing" + + "govard/internal/cmd" +) + +// WordPress projects without composer.json must not run dump-autoload, while +// frameworks with a PHP dependency workflow retain the existing best-effort +// behavior. This guards against reintroducing a name comparison in cmd. +func TestComposerDumpAutoloadPolicyComesFromFrameworkDefinition(t *testing.T) { + if cmd.ShouldRunComposerDumpAutoloadForTest("wordpress", false) { + t.Fatal("WordPress without composer.json should skip composer dump-autoload") + } + if !cmd.ShouldRunComposerDumpAutoloadForTest("wordpress", true) { + t.Fatal("WordPress with composer.json should run composer dump-autoload") + } + if !cmd.ShouldRunComposerDumpAutoloadForTest("laravel", false) { + t.Fatal("Laravel without composer.json should retain best-effort dump-autoload behavior") + } +} diff --git a/tests/bootstrap_magento1_test.go b/tests/bootstrap_magento1_test.go index 5a9e73d6..01d94c98 100644 --- a/tests/bootstrap_magento1_test.go +++ b/tests/bootstrap_magento1_test.go @@ -4,11 +4,11 @@ import ( "strings" "testing" - "govard/internal/engine/bootstrap" + "govard/internal/frameworks/magento1" ) func TestGenerateMagento1CryptKeyReturnsRandom32CharHex(t *testing.T) { - key1, err := bootstrap.GenerateMagento1CryptKey() + key1, err := magento1.GenerateCryptKey() if err != nil { t.Fatalf("GenerateMagento1CryptKey() error = %v", err) } @@ -21,7 +21,7 @@ func TestGenerateMagento1CryptKeyReturnsRandom32CharHex(t *testing.T) { } } - key2, err := bootstrap.GenerateMagento1CryptKey() + key2, err := magento1.GenerateCryptKey() if err != nil { t.Fatalf("GenerateMagento1CryptKey() error = %v", err) } diff --git a/tests/bootstrap_magento_env_test.go b/tests/bootstrap_magento_env_test.go index 32761ee7..dae074af 100644 --- a/tests/bootstrap_magento_env_test.go +++ b/tests/bootstrap_magento_env_test.go @@ -5,12 +5,16 @@ import ( "strings" "testing" - "govard/internal/cmd" "govard/internal/conventions" + "govard/internal/frameworks/magento2" ) func TestBuildBootstrapMagentoEnvPHPForTestUsesDefaultDBHost(t *testing.T) { - got := cmd.BuildBootstrapMagentoEnvPHPForTest("test-crypt-key", "sample_db", "sample_user", "sample_pass") + got := magento2.BuildBootstrapEnvironment( + "test-crypt-key", + magento2.BootstrapEnvironmentDatabase{Database: "sample_db", Username: "sample_user", Password: "sample_pass"}, + "", + ) expectedHost := fmt.Sprintf("'host' => %q", conventions.DefaultMagentoDBHost) if count := strings.Count(got, expectedHost); count != 2 { @@ -34,7 +38,11 @@ func TestBuildBootstrapMagentoEnvPHPForTestUsesDefaultDBHost(t *testing.T) { } func TestBuildBootstrapMagentoEnvPHPForTestUsesTablePrefix(t *testing.T) { - got := cmd.BuildBootstrapMagentoEnvPHPWithPrefixForTest("test-crypt-key", "sample_db", "sample_user", "sample_pass", "demo_") + got := magento2.BuildBootstrapEnvironment( + "test-crypt-key", + magento2.BootstrapEnvironmentDatabase{Database: "sample_db", Username: "sample_user", Password: "sample_pass"}, + "demo_", + ) if !strings.Contains(got, "'table_prefix' => \"demo_\"") { t.Fatalf("expected rendered env.php to contain table prefix, got:\n%s", got) diff --git a/tests/bootstrap_post_install_test.go b/tests/bootstrap_post_install_test.go index 152eb8e0..1b800717 100644 --- a/tests/bootstrap_post_install_test.go +++ b/tests/bootstrap_post_install_test.go @@ -32,28 +32,3 @@ func TestRunBootstrapHyvaInstallForTestRunsExpectedComposerCalls(t *testing.T) { t.Fatalf("composer calls = %#v, want %#v", calls, want) } } - -func TestRunBootstrapSampleDataForTestRunsAllSteps(t *testing.T) { - calls := make([][]string, 0, 4) - defer cmd.SetGovardSubcommandRunnerForTest(func(subCmd *cobra.Command, args ...string) error { - captured := make([]string, len(args)) - copy(captured, args) - calls = append(calls, captured) - return nil - })() - - err := cmd.RunBootstrapSampleDataForTest(&cobra.Command{}) - if err != nil { - t.Fatalf("RunBootstrapSampleDataForTest() error = %v", err) - } - - want := [][]string{ - {"tool", "magento", "sample:deploy"}, - {"tool", "magento", "setup:upgrade"}, - {"tool", "magento", "indexer:reindex"}, - {"tool", "magento", "cache:flush"}, - } - if !reflect.DeepEqual(calls, want) { - t.Fatalf("sample data calls = %#v, want %#v", calls, want) - } -} diff --git a/tests/bootstrap_prestashop_test.go b/tests/bootstrap_prestashop_test.go index d3f9a41a..2f4d2cb8 100644 --- a/tests/bootstrap_prestashop_test.go +++ b/tests/bootstrap_prestashop_test.go @@ -66,15 +66,17 @@ func TestBootstrapPkgPrestaShopPostCloneGeneratesParametersFile(t *testing.T) { func TestBootstrapPkgPrestaShopPostCloneReusesRemoteSecretsWhenProvided(t *testing.T) { projectDir := t.TempDir() prestashop := prestashop.NewPrestaShopBootstrap(bootstrap.Options{ - DBHost: "db", - DBUser: "shopuser", - DBPass: "shoppass", - DBName: "shopdb", - TablePrefix: "shop_", - PrestaShopSecret: "remote-secret", - PrestaShopCookieKey: "remote-cookie-key", - PrestaShopCookieIV: "remote-cookie-iv", - PrestaShopNewCookieKey: "remote-new-cookie-key", + DBHost: "db", + DBUser: "shopuser", + DBPass: "shoppass", + DBName: "shopdb", + TablePrefix: "shop_", + RemoteMetadata: map[string]string{ + "prestashop.secret": "remote-secret", + "prestashop.cookie_key": "remote-cookie-key", + "prestashop.cookie_iv": "remote-cookie-iv", + "prestashop.new_cookie_key": "remote-new-cookie-key", + }, }) if err := prestashop.PostClone(projectDir); err != nil { diff --git a/tests/config_auto_command_test.go b/tests/config_auto_command_test.go index 4708bdd6..5a180ba4 100644 --- a/tests/config_auto_command_test.go +++ b/tests/config_auto_command_test.go @@ -5,19 +5,29 @@ import ( "govard/internal/cmd" "govard/internal/engine" + "govard/internal/frameworks/types" + + "github.com/spf13/cobra" ) func TestApplyFrameworkAutoConfigurationUsesMagento1Handler(t *testing.T) { called := false - restore := cmd.SetMagento1AutoConfigurationRunnerForTest(func(projectName string, config engine.Config) error { - called = true - if projectName != "sample-project" { - t.Fatalf("expected project name sample-project, got %s", projectName) - } - if config.Framework != "magento1" { - t.Fatalf("expected magento1 config, got %s", config.Framework) + restore := cmd.SetFrameworkLookupForAutoConfigureForTest(func(name string) (types.FrameworkDefinition, bool) { + if name != "magento1" { + t.Fatalf("expected lookup for magento1, got %s", name) } - return nil + return types.FrameworkDefinition{ + AutoConfigure: func(_ *cobra.Command, config engine.Config) error { + called = true + if config.ProjectName != "sample-project" { + t.Fatalf("expected project name sample-project, got %s", config.ProjectName) + } + if config.Framework != "magento1" { + t.Fatalf("expected magento1 config, got %s", config.Framework) + } + return nil + }, + }, true }) defer restore() @@ -36,15 +46,22 @@ func TestApplyFrameworkAutoConfigurationUsesMagento1Handler(t *testing.T) { func TestApplyFrameworkAutoConfigurationUsesMagento2HandlerForMageOS(t *testing.T) { called := false - restore := cmd.SetMagento2AutoConfigurationRunnerForTest(func(projectName string, config engine.Config, force bool) error { - called = true - if projectName != "sample-project" { - t.Fatalf("expected project name sample-project, got %s", projectName) + restore := cmd.SetFrameworkLookupForAutoConfigureForTest(func(name string) (types.FrameworkDefinition, bool) { + if name != "mageos" { + t.Fatalf("expected lookup for mageos, got %s", name) } - if config.Framework != "mageos" { - t.Fatalf("expected mageos config, got %s", config.Framework) - } - return nil + return types.FrameworkDefinition{ + AutoConfigure: func(_ *cobra.Command, config engine.Config) error { + called = true + if config.ProjectName != "sample-project" { + t.Fatalf("expected project name sample-project, got %s", config.ProjectName) + } + if config.Framework != "mageos" { + t.Fatalf("expected mageos config, got %s", config.Framework) + } + return nil + }, + }, true }) defer restore() @@ -63,3 +80,26 @@ func TestApplyFrameworkAutoConfigurationUsesMagento2HandlerForMageOS(t *testing. t.Fatal("expected Magento 2 auto configuration runner to be invoked for mageos") } } + +func TestPrepareFrameworkComposerUsesDefinitionHook(t *testing.T) { + called := false + restore := cmd.SetFrameworkLookupForBootstrapForTest(func(name string) (types.FrameworkDefinition, bool) { + if name != "wordpress" { + t.Fatalf("expected lookup for wordpress, got %s", name) + } + return types.FrameworkDefinition{ + PrepareComposer: func(config engine.Config) error { + called = config.ProjectName == "sample-project" + return nil + }, + }, true + }) + defer restore() + + if err := cmd.PrepareFrameworkComposerForTest(engine.Config{ProjectName: "sample-project", Framework: "wordpress"}); err != nil { + t.Fatalf("PrepareFrameworkComposerForTest() error = %v", err) + } + if !called { + t.Fatal("expected framework composer preparation hook to run") + } +} diff --git a/tests/configure_command_test.go b/tests/configure_command_test.go index a7ec26b3..67a30202 100644 --- a/tests/configure_command_test.go +++ b/tests/configure_command_test.go @@ -5,11 +5,12 @@ import ( "testing" "govard/internal/engine" + "govard/internal/frameworks/magento2" ) func TestConfigureAddsBaseUrl(t *testing.T) { config := engine.Config{Domain: "store.test"} - cmds := engine.MagentoConfigCommandsForTest("proj", config) + cmds := magento2.MagentoConfigCommandsForTest("proj", config) found := false for _, cmd := range cmds { for _, arg := range cmd.Args { @@ -33,7 +34,7 @@ func TestConfigureDatabaseSetupDoesNotSetSearchFlags(t *testing.T) { }, } - cmds := engine.MagentoConfigCommandsForTest("proj", config) + cmds := magento2.MagentoConfigCommandsForTest("proj", config) foundDBSetup := false for _, cmd := range cmds { if cmd.Desc != "Setting Database connection" { @@ -60,7 +61,7 @@ func TestConfigureDatabaseSetupIncludesTablePrefix(t *testing.T) { TablePrefix: "demo_", } - cmds := engine.MagentoConfigCommandsForTest("proj", config) + cmds := magento2.MagentoConfigCommandsForTest("proj", config) for _, cmd := range cmds { if cmd.Desc != "Setting Database connection" { continue @@ -85,7 +86,7 @@ func TestConfigureSearchHostCommandsUseMagentoConfigSet(t *testing.T) { }, } - cmds := engine.MagentoConfigCommandsForTest("proj", config) + cmds := magento2.MagentoConfigCommandsForTest("proj", config) joined := make([]string, 0, len(cmds)) for _, cmd := range cmds { joined = append(joined, strings.Join(cmd.Args, " ")) @@ -105,7 +106,7 @@ func TestConfigureEnablesWebServerRewrites(t *testing.T) { Domain: "store.test", } - cmds := engine.MagentoConfigCommandsForTest("proj", config) + cmds := magento2.MagentoConfigCommandsForTest("proj", config) joined := make([]string, 0, len(cmds)) for _, cmd := range cmds { joined = append(joined, strings.Join(cmd.Args, " ")) @@ -129,7 +130,7 @@ func TestConfigureRabbitMQAmqpCommands(t *testing.T) { }, } - cmds := engine.MagentoConfigCommandsForTest("proj", config) + cmds := magento2.MagentoConfigCommandsForTest("proj", config) joined := make([]string, 0, len(cmds)) for _, cmd := range cmds { joined = append(joined, strings.Join(cmd.Args, " ")) @@ -153,7 +154,7 @@ func TestConfigureWithoutRabbitMQSkipsAmqpCommands(t *testing.T) { }, } - cmds := engine.MagentoConfigCommandsForTest("proj", config) + cmds := magento2.MagentoConfigCommandsForTest("proj", config) for _, cmd := range cmds { for _, arg := range cmd.Args { if strings.HasPrefix(arg, "--amqp-") { @@ -166,7 +167,7 @@ func TestConfigureWithoutRabbitMQSkipsAmqpCommands(t *testing.T) { func TestConfigureMageOSDatabaseSetupUsesMageOSCredentials(t *testing.T) { config := engine.Config{Framework: "mageos"} - cmds := engine.MagentoConfigCommandsForTest("proj", config) + cmds := magento2.MagentoConfigCommandsForTest("proj", config) for _, cmd := range cmds { if cmd.Desc != "Setting Database connection" { continue @@ -183,7 +184,7 @@ func TestConfigureMageOSDatabaseSetupUsesMageOSCredentials(t *testing.T) { func TestConfigureMagento2DatabaseSetupStillUsesMagentoCredentials(t *testing.T) { config := engine.Config{Framework: "magento2"} - cmds := engine.MagentoConfigCommandsForTest("proj", config) + cmds := magento2.MagentoConfigCommandsForTest("proj", config) for _, cmd := range cmds { if cmd.Desc != "Setting Database connection" { continue diff --git a/tests/desktop_list_frameworks_test.go b/tests/desktop_list_frameworks_test.go new file mode 100644 index 00000000..5913cba1 --- /dev/null +++ b/tests/desktop_list_frameworks_test.go @@ -0,0 +1,48 @@ +package tests + +import ( + "testing" + + "govard/internal/desktop" +) + +func TestListFrameworksReturnsAllRegisteredFrameworks(t *testing.T) { + app := &desktop.App{} + options, err := app.ListFrameworks() + if err != nil { + t.Fatalf("ListFrameworks() error: %v", err) + } + if len(options) != 15 { + t.Fatalf("ListFrameworks() returned %d frameworks, want 15", len(options)) + } + + byName := make(map[string]desktop.FrameworkOption, len(options)) + for _, opt := range options { + byName[opt.Name] = opt + } + + magento2, ok := byName["magento2"] + if !ok { + t.Fatal("expected \"magento2\" in ListFrameworks() result") + } + if magento2.DisplayName != "Magento 2" { + t.Errorf("magento2.DisplayName = %q, want %q", magento2.DisplayName, "Magento 2") + } + found := false + for _, alias := range magento2.Aliases { + if alias == "magento" { + found = true + } + } + if !found { + t.Errorf("magento2.Aliases = %v, want to contain %q", magento2.Aliases, "magento") + } + + custom, ok := byName["custom"] + if !ok { + t.Fatal("expected \"custom\" in ListFrameworks() result") + } + if custom.DisplayName != "Custom" { + t.Errorf("custom.DisplayName = %q, want %q", custom.DisplayName, "Custom") + } +} diff --git a/tests/dotenv_metadata_test.go b/tests/dotenv_metadata_test.go index 85b46fb4..16fdc820 100644 --- a/tests/dotenv_metadata_test.go +++ b/tests/dotenv_metadata_test.go @@ -3,11 +3,11 @@ package tests import ( "testing" - "govard/internal/engine/remote" + "govard/internal/frameworks/shared/dotenv" ) func TestParseDotenvDatabaseURL(t *testing.T) { - info, err := remote.ParseDotenvDatabaseURLForTest("mysql://etl_user:s3cr3t@127.0.0.1:3307/etl_dev?serverVersion=8.0") + info, err := dotenv.ParseDatabaseURLForTest("mysql://etl_user:s3cr3t@127.0.0.1:3307/etl_dev?serverVersion=8.0") if err != nil { t.Fatalf("expected parse success, got error: %v", err) } @@ -29,7 +29,7 @@ func TestParseDotenvDatabaseURL(t *testing.T) { } func TestResolveDotenvDBInfoPrefersDatabaseURL(t *testing.T) { - info, err := remote.ResolveDotenvDBInfoForTest( + info, err := dotenv.ResolveDatabaseInfoForTest( "mysql://correct_user:correct_pass@db.example:3309/correct_db", "", "", @@ -53,7 +53,7 @@ func TestResolveDotenvDBInfoPrefersDatabaseURL(t *testing.T) { } func TestResolveDotenvDBInfoFromDiscreteVars(t *testing.T) { - info, err := remote.ResolveDotenvDBInfoForTest( + info, err := dotenv.ResolveDatabaseInfoForTest( "", "db.internal", "3310", diff --git a/tests/framework_alias_registry_test.go b/tests/framework_alias_registry_test.go new file mode 100644 index 00000000..af549918 --- /dev/null +++ b/tests/framework_alias_registry_test.go @@ -0,0 +1,32 @@ +package tests + +import ( + "testing" + + "govard/internal/engine" +) + +func TestNormalizeFrameworkAliasResolvesRegisteredAliases(t *testing.T) { + // magento2 and wordpress register "magento"/"wp" as aliases via their + // own Definition() - registration already happened at package init() + // time for the whole test binary, so we assert against that real state + // rather than registering throwaway aliases here. + tests := []struct { + raw string + expected string + }{ + {"magento", "magento2"}, + {"MAGENTO", "magento2"}, + {" magento ", "magento2"}, + {"wp", "wordpress"}, + {"m2", "magento2"}, + {"m1", "magento1"}, + {"laravel", "laravel"}, + {"totally-unknown-framework", "totally-unknown-framework"}, + } + for _, tt := range tests { + if got := engine.NormalizeFrameworkAlias(tt.raw); got != tt.expected { + t.Errorf("NormalizeFrameworkAlias(%q) = %q, want %q", tt.raw, got, tt.expected) + } + } +} diff --git a/tests/framework_gen_discover_test.go b/tests/framework_gen_discover_test.go index a093c444..394ea988 100644 --- a/tests/framework_gen_discover_test.go +++ b/tests/framework_gen_discover_test.go @@ -46,6 +46,10 @@ type FrameworkDefinition struct{ Name string } writeTestFile(t, filepath.Join(root, "gen", "main.go"), `package main func main() {} +`) + writeTestFile(t, filepath.Join(root, "shared", "dotenv.go"), `package shared + +func Parse() {} `) got, err := generator.DiscoverFrameworkDirs(root) diff --git a/tests/framework_gen_render_test.go b/tests/framework_gen_render_test.go index 858a1512..be30ade8 100644 --- a/tests/framework_gen_render_test.go +++ b/tests/framework_gen_render_test.go @@ -39,35 +39,41 @@ func TestRenderSourceProducesValidGo(t *testing.T) { t.Errorf("expected a 'Code generated' header, got:\n%s", source) } - var registerArgs []string + var specArgs []string ast.Inspect(file, func(n ast.Node) bool { call, ok := n.(*ast.CallExpr) if !ok { return true } fnIdent, ok := call.Fun.(*ast.Ident) - if !ok || fnIdent.Name != "Register" || len(call.Args) != 1 { + if !ok || fnIdent.Name != "RegisterSpecs" || len(call.Args) != 1 { return true } - defCall, ok := call.Args[0].(*ast.CallExpr) + list, ok := call.Args[0].(*ast.CompositeLit) if !ok { return true } - sel, ok := defCall.Fun.(*ast.SelectorExpr) - if !ok { - return true - } - pkgIdent, ok := sel.X.(*ast.Ident) - if !ok { - return true + for _, entry := range list.Elts { + specCall, ok := entry.(*ast.CallExpr) + if !ok { + continue + } + sel, ok := specCall.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Spec" { + continue + } + pkgIdent, ok := sel.X.(*ast.Ident) + if !ok { + continue + } + specArgs = append(specArgs, pkgIdent.Name) } - registerArgs = append(registerArgs, pkgIdent.Name) return true }) want := []string{"django", "cakephp"} - if !reflect.DeepEqual(registerArgs, want) { - t.Errorf("Register() call order = %v, want %v", registerArgs, want) + if !reflect.DeepEqual(specArgs, want) { + t.Errorf("RegisterSpecs() call order = %v, want %v", specArgs, want) } } diff --git a/tests/framework_operational_hooks_test.go b/tests/framework_operational_hooks_test.go new file mode 100644 index 00000000..722dd132 --- /dev/null +++ b/tests/framework_operational_hooks_test.go @@ -0,0 +1,83 @@ +package tests + +import ( + "context" + "testing" + + "govard/internal/engine" + "govard/internal/frameworks" +) + +func TestMagentoOperationalHooksAreInheritedByMageOS(t *testing.T) { + magento, ok := frameworks.Get("magento2") + if !ok || magento.PostSync == nil || magento.UnblockSearchIndex == nil || magento.BuildSearchHostFixSQL == nil || magento.RenderBootstrapEnvironment == nil || magento.ComposerAuth.Repository == "" { + t.Fatal("expected Magento 2 to own operational and bootstrap-environment hooks") + } + if len(magento.DefaultChownDirectories) == 0 { + t.Fatal("expected Magento 2 to own its default chown directories") + } + if magento.MinimumBootstrapVersion == "" || magento.DefaultFreshMetaPackage == "" { + t.Fatal("expected Magento 2 to own fresh-install version and package defaults") + } + if magento.DetectLocalAdminMetadata == nil || magento.BuildLocalAdminSettingsQuery == nil || magento.ResolveLocalAdminURL == nil { + t.Fatal("expected Magento 2 to own local admin metadata, query, and URL policy") + } + + mageOS, ok := frameworks.Get("mageos") + if !ok || mageOS.PostSync == nil || mageOS.UnblockSearchIndex == nil || mageOS.BuildSearchHostFixSQL == nil || mageOS.RenderBootstrapEnvironment == nil { + t.Fatal("expected Mage-OS to inherit Magento operational hooks") + } + if len(mageOS.DefaultChownDirectories) == 0 { + t.Fatal("expected Mage-OS to inherit Magento default chown directories") + } + if mageOS.DefaultFreshMetaPackage != "mage-os/project-community-edition" { + t.Fatalf("expected Mage-OS package override, got %q", mageOS.DefaultFreshMetaPackage) + } +} + +func TestUpgradeRegistryUsesRegisteredFrameworkAliases(t *testing.T) { + engine.RegisterFrameworkAlias("upgrade-fixture", "upgrade-canonical") + engine.RegisterUpgrader("upgrade-canonical", func(context.Context, engine.Config, engine.UpgradeOptions) error { + return nil + }) + + if _, ok := engine.GetUpgrader("upgrade-fixture"); !ok { + t.Fatal("expected upgrader lookup to use the registered alias") + } +} + +func TestFrameworkMigrationTypesAreOwnedByDefinitions(t *testing.T) { + magento, ok := frameworks.Get("magento2") + if !ok || len(magento.MigrationTypes.DDEV) == 0 || len(magento.MigrationTypes.Warden) == 0 { + t.Fatal("expected Magento 2 to declare its DDEV and Warden migration types") + } + + wordpress, ok := frameworks.Get("wordpress") + if !ok || len(wordpress.MigrationTypes.DDEV) == 0 || len(wordpress.MigrationTypes.Warden) == 0 { + t.Fatal("expected WordPress to declare its DDEV and Warden migration types") + } +} + +func TestFrameworkRuntimeImageAndTemplatePoliciesAreOwnedByDefinitions(t *testing.T) { + emdash, ok := frameworks.Get("emdash") + if !ok || emdash.NodeImageFlavor != "standard" { + t.Fatal("expected Emdash to own its standard Node image policy") + } + + mageOS, ok := frameworks.Get("mageos") + if !ok || mageOS.VarnishTemplateFramework != "magento2" { + t.Fatal("expected Mage-OS to own its Magento 2 Varnish-template inheritance") + } +} + +func TestFrameworkPostCloneErrorPoliciesAreOwnedByDefinitions(t *testing.T) { + wordpress, ok := frameworks.Get("wordpress") + if !ok || wordpress.IgnorePostCloneError == nil { + t.Fatal("expected WordPress to own its post-clone error policy") + } + + prestashop, ok := frameworks.Get("prestashop") + if !ok || prestashop.IgnorePostCloneError == nil { + t.Fatal("expected PrestaShop to own its post-clone error policy") + } +} diff --git a/tests/framework_registry_completeness_test.go b/tests/framework_registry_completeness_test.go index 08ca5df7..59d8cd95 100644 --- a/tests/framework_registry_completeness_test.go +++ b/tests/framework_registry_completeness_test.go @@ -11,15 +11,13 @@ import ( "govard/internal/frameworks" ) -// TestRegistryHasAllTwelveFrameworks confirms the package-level registry -// (populated by all_generated.go's init()) has exactly the same 12 frameworks as -// allFrameworkNames (defined in framework_snapshot_test.go, Plan 1) - -// the two lists must never drift, or a framework would silently be -// missing from either the registry or its golden-snapshot coverage. -func TestRegistryHasAllTwelveFrameworks(t *testing.T) { +// TestRegistryIncludesSnapshotFrameworksAndCustom confirms the package-level +// registry contains every golden-snapshot framework plus Custom, which is a +// selectable escape hatch deliberately excluded from those snapshots. +func TestRegistryIncludesSnapshotFrameworksAndCustom(t *testing.T) { all := frameworks.All() - if len(all) != len(allFrameworkNames) { - t.Fatalf("registry has %d frameworks, allFrameworkNames has %d", len(all), len(allFrameworkNames)) + if len(all) != len(allFrameworkNames)+1 { + t.Fatalf("registry has %d frameworks, want %d snapshot frameworks plus custom", len(all), len(allFrameworkNames)) } registered := map[string]bool{} @@ -31,6 +29,9 @@ func TestRegistryHasAllTwelveFrameworks(t *testing.T) { t.Errorf("framework %q is in allFrameworkNames but not registered in internal/frameworks", name) } } + if !registered["custom"] { + t.Error("custom must be registered because CLI and desktop expose it as a selectable framework") + } } // TestRegistryConfigMatchesEngine cross-checks the registry's Config field diff --git a/tests/framework_registry_inheritance_test.go b/tests/framework_registry_inheritance_test.go new file mode 100644 index 00000000..98909328 --- /dev/null +++ b/tests/framework_registry_inheritance_test.go @@ -0,0 +1,196 @@ +package tests + +import ( + "reflect" + "strings" + "testing" + + "govard/internal/engine" + "govard/internal/frameworks" + "govard/internal/frameworks/types" +) + +func TestRegistryBuildResolvesChildBeforeParent(t *testing.T) { + root := types.FrameworkSpec{ + Definition: types.FrameworkDefinition{ + Name: "parent", + Aliases: []string{"base"}, + DisplayName: "Parent", + PHPImageVariant: "parent", + PHPStanPaths: []string{"app", "src"}, + }, + } + child := types.FrameworkSpec{ + Parent: "parent", + Definition: types.FrameworkDefinition{ + Name: "child", + }, + Patch: types.FrameworkPatch{ + DisplayName: types.Set("Child"), + PHPImageVariant: types.Clear[string](), + PHPStanPaths: types.Set([]string{"packages/child"}), + }, + } + + registry, err := frameworks.NewRegistryFromSpecs([]types.FrameworkSpec{child, root}) + if err != nil { + t.Fatalf("NewRegistryFromSpecs() error = %v", err) + } + + definition, ok := registry.Get("child") + if !ok { + t.Fatal("child definition was not resolved") + } + if definition.DisplayName != "Child" { + t.Errorf("DisplayName = %q, want Child", definition.DisplayName) + } + if definition.PHPImageVariant != "" { + t.Errorf("PHPImageVariant = %q, want explicitly cleared value", definition.PHPImageVariant) + } + if !reflect.DeepEqual(definition.PHPStanPaths, []string{"packages/child"}) { + t.Errorf("PHPStanPaths = %v, want child override", definition.PHPStanPaths) + } + if got, want := registry.Lineage("child"), []string{"parent", "child"}; !reflect.DeepEqual(got, want) { + t.Errorf("Lineage(child) = %v, want %v", got, want) + } + if !registry.IsA("child", "parent") { + t.Error("child should be recognized as a parent descendant") + } +} + +func TestRegistryBuildRejectsInheritanceCycle(t *testing.T) { + _, err := frameworks.NewRegistryFromSpecs([]types.FrameworkSpec{ + {Parent: "two", Definition: types.FrameworkDefinition{Name: "one"}}, + {Parent: "one", Definition: types.FrameworkDefinition{Name: "two"}}, + }) + if err == nil { + t.Fatal("NewRegistryFromSpecs() succeeded for an inheritance cycle") + } + if !strings.Contains(err.Error(), "cycle") { + t.Errorf("cycle error = %q, want text containing cycle", err) + } +} + +func TestRegistryBuildAppliesRuntimeAndFeatureOverrides(t *testing.T) { + root := types.FrameworkSpec{ + Definition: types.FrameworkDefinition{ + Name: "parent", + Config: engine.FrameworkConfig{Runtime: "php", DefaultPHP: "8.3"}, + SupportsBootstrap: true, + }, + } + child := types.FrameworkSpec{ + Parent: "parent", + Definition: types.FrameworkDefinition{Name: "child"}, + Patch: types.FrameworkPatch{ + Config: types.Set(engine.FrameworkConfig{Runtime: "node", DefaultNodeVer: "20"}), + SupportsBootstrap: types.Clear[bool](), + }, + } + + registry, err := frameworks.NewRegistryFromSpecs([]types.FrameworkSpec{root, child}) + if err != nil { + t.Fatalf("NewRegistryFromSpecs() error = %v", err) + } + definition, ok := registry.Get("child") + if !ok { + t.Fatal("child definition was not resolved") + } + if definition.Config.Runtime != "node" || definition.Config.DefaultNodeVer != "20" { + t.Errorf("Config = %#v, want child runtime override", definition.Config) + } + if definition.SupportsBootstrap { + t.Error("SupportsBootstrap should be explicitly cleared") + } +} + +func TestDefaultRegistryResolvesMagentoFamilies(t *testing.T) { + mageOS, ok := frameworks.Get("mageos") + if !ok { + t.Fatal("mageos definition was not registered") + } + if mageOS.Parent != "magento2" { + t.Errorf("mageos Parent = %q, want magento2", mageOS.Parent) + } + if got, want := frameworks.Lineage("mageos"), []string{"magento2", "mageos"}; !reflect.DeepEqual(got, want) { + t.Errorf("Lineage(mageos) = %v, want %v", got, want) + } + if !frameworks.IsA("mageos", "magento2") { + t.Error("mageos should inherit Magento 2 behavior") + } + if mageOS.VersionProfileResolver != nil { + t.Error("mageos must explicitly clear Magento 2's version-profile resolver") + } + + openMage, ok := frameworks.Get("openmage") + if !ok { + t.Fatal("openmage definition was not registered") + } + if openMage.Parent != "magento1" { + t.Errorf("openmage Parent = %q, want magento1", openMage.Parent) + } + if got, want := frameworks.Lineage("openmage"), []string{"magento1", "openmage"}; !reflect.DeepEqual(got, want) { + t.Errorf("Lineage(openmage) = %v, want %v", got, want) + } + if !frameworks.IsA("openmage", "magento1") { + t.Error("openmage should inherit Magento 1 behavior") + } +} + +func TestRegistryGetReturnsIndependentDefinitionSlices(t *testing.T) { + registry, err := frameworks.NewRegistryFromSpecs([]types.FrameworkSpec{{ + Definition: types.FrameworkDefinition{ + Name: "parent", + Aliases: []string{"base"}, + PHPStanPaths: []string{"app", "src"}, + }, + }}) + if err != nil { + t.Fatalf("NewRegistryFromSpecs() error = %v", err) + } + + first, ok := registry.Get("parent") + if !ok { + t.Fatal("parent definition was not resolved") + } + first.Aliases[0] = "changed-alias" + first.PHPStanPaths[0] = "changed-path" + + second, ok := registry.Get("parent") + if !ok { + t.Fatal("parent definition was no longer present") + } + if got, want := second.Aliases, []string{"base"}; !reflect.DeepEqual(got, want) { + t.Errorf("Aliases after caller mutation = %v, want %v", got, want) + } + if got, want := second.PHPStanPaths, []string{"app", "src"}; !reflect.DeepEqual(got, want) { + t.Errorf("PHPStanPaths after caller mutation = %v, want %v", got, want) + } +} + +func TestRegistryBuildPreservesExplicitEmptyManifestSlices(t *testing.T) { + registry, err := frameworks.NewRegistryFromSpecs([]types.FrameworkSpec{{ + Definition: types.FrameworkDefinition{ + Name: "empty-manifest", + Manifest: engine.FrameworkManifestConfig{ + Sync: engine.FrameworkSyncConfig{ + MediaExcludes: engine.FrameworkMediaExcludeSet{NonAll: []string{}}, + }, + }, + }, + }}) + if err != nil { + t.Fatalf("NewRegistryFromSpecs() error = %v", err) + } + + definition, ok := registry.Get("empty-manifest") + if !ok { + t.Fatal("empty-manifest definition was not resolved") + } + if definition.Manifest.Sync.MediaExcludes.NonAll == nil { + t.Fatal("explicit empty NonAll excludes became nil") + } + if len(definition.Manifest.Sync.MediaExcludes.NonAll) != 0 { + t.Errorf("NonAll excludes = %v, want explicit empty slice", definition.Manifest.Sync.MediaExcludes.NonAll) + } +} diff --git a/tests/framework_snapshot_test.go b/tests/framework_snapshot_test.go index 78a18a51..e28d1b96 100644 --- a/tests/framework_snapshot_test.go +++ b/tests/framework_snapshot_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "govard/internal/blueprints" "govard/internal/cmd" "govard/internal/engine" "govard/internal/engine/bootstrap" @@ -112,9 +113,6 @@ func testDataDir(t *testing.T) string { } func TestFrameworkSnapshotBlueprintRendering(t *testing.T) { - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") goldenRoot := testDataDir(t) for _, framework := range allFrameworkNames { @@ -129,7 +127,7 @@ func TestFrameworkSnapshotBlueprintRendering(t *testing.T) { t.Setenv("SSH_AUTH_SOCK", "") destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("failed to copy blueprints: %v", err) } diff --git a/tests/framework_test_commands_test.go b/tests/framework_test_commands_test.go new file mode 100644 index 00000000..7f2c8c7b --- /dev/null +++ b/tests/framework_test_commands_test.go @@ -0,0 +1,31 @@ +package tests + +import ( + "reflect" + "testing" + + "govard/internal/cmd" +) + +// This catches framework test-suite routing drifting back into cmd switches. +func TestFrameworkTestCommandsComeFromDefinitions(t *testing.T) { + laravelDefault, ok := cmd.FrameworkTestCommandForTest("laravel", "default") + if !ok { + t.Fatal("expected Laravel default test command") + } + if laravelDefault.Binary != "php" || !reflect.DeepEqual(laravelDefault.Args, []string{"artisan", "test"}) { + t.Fatalf("Laravel default test command = %#v, want php artisan test", laravelDefault) + } + + mageOSMFTF, ok := cmd.FrameworkTestCommandForTest("mageos", "mftf") + if !ok { + t.Fatal("expected Mage-OS to inherit Magento MFTF test command") + } + if mageOSMFTF.Binary != "php" || !reflect.DeepEqual(mageOSMFTF.Args, []string{"vendor/bin/mftf", "run:group"}) { + t.Fatalf("Mage-OS MFTF command = %#v, want php vendor/bin/mftf run:group", mageOSMFTF) + } + + if _, ok := cmd.FrameworkTestCommandForTest("wordpress", "integration"); ok { + t.Fatal("WordPress must not expose Magento integration tests") + } +} diff --git a/tests/framework_tool_commands_test.go b/tests/framework_tool_commands_test.go new file mode 100644 index 00000000..f1faabd1 --- /dev/null +++ b/tests/framework_tool_commands_test.go @@ -0,0 +1,39 @@ +package tests + +import ( + "reflect" + "testing" + + "govard/internal/cmd" +) + +// This catches framework CLI availability drifting from its owning framework +// definitions (the former static cmd list made Mage-OS/OpenMage inheritance +// easy to miss). +func TestFrameworkToolCommandsComeFromFrameworkDefinitions(t *testing.T) { + commands := cmd.FrameworkToolCommandsForTest() + byName := make(map[string]cmd.FrameworkCommand, len(commands)) + for _, command := range commands { + byName[command.Name] = command + } + + magerun, ok := byName["magerun"] + if !ok { + t.Fatal("expected magerun command") + } + if !reflect.DeepEqual(magerun.Frameworks, []string{"magento1", "magento2", "mageos", "openmage"}) { + t.Fatalf("magerun frameworks = %v, want Magento family", magerun.Frameworks) + } + + artisan, ok := byName["artisan"] + if !ok { + t.Fatal("expected artisan command") + } + if !reflect.DeepEqual(artisan.Frameworks, []string{"laravel"}) { + t.Fatalf("artisan frameworks = %v, want [laravel]", artisan.Frameworks) + } + + if _, found := byName["composer"]; found { + t.Fatal("composer is a generic command and must not be published by a framework definition") + } +} diff --git a/tests/init_frameworks_test.go b/tests/init_frameworks_test.go new file mode 100644 index 00000000..18b0e5e4 --- /dev/null +++ b/tests/init_frameworks_test.go @@ -0,0 +1,29 @@ +package tests + +import ( + "reflect" + "sort" + "testing" + + "govard/internal/cmd" + "govard/internal/frameworks" +) + +// This catches the old hard-coded init picker drifting from the registry (it +// previously omitted Django and would have omitted newly registered Custom). +func TestInitFrameworkOptionsMatchRegistry(t *testing.T) { + got := cmd.InitFrameworkOptionsForTest() + + want := make([]cmd.FrameworkSelectionOption, 0) + for _, definition := range frameworks.All() { + want = append(want, cmd.FrameworkSelectionOption{ + Name: definition.Name, + DisplayName: definition.DisplayName, + }) + } + sort.Slice(want, func(i, j int) bool { return want[i].DisplayName < want[j].DisplayName }) + + if !reflect.DeepEqual(got, want) { + t.Fatalf("init framework options = %#v, want registry-derived %#v", got, want) + } +} diff --git a/tests/integration/blueprint_test.go b/tests/integration/blueprint_test.go index 6cf83973..aa0970b3 100644 --- a/tests/integration/blueprint_test.go +++ b/tests/integration/blueprint_test.go @@ -13,7 +13,6 @@ import ( ) func TestRenderAllFrameworkBlueprints(t *testing.T) { - env := NewTestEnvironment(t) frameworks := []string{ "magento2", @@ -32,7 +31,7 @@ func TestRenderAllFrameworkBlueprints(t *testing.T) { t.Run(fw, func(t *testing.T) { projectDir := t.TempDir() - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) config := engine.Config{ ProjectName: "test-" + fw, @@ -75,11 +74,10 @@ func TestRenderAllFrameworkBlueprints(t *testing.T) { } func TestRenderBlueprintWithFeatures(t *testing.T) { - env := NewTestEnvironment(t) projectDir := t.TempDir() - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) config := engine.Config{ ProjectName: "test-features", @@ -133,11 +131,10 @@ func TestRenderBlueprintWithFeatures(t *testing.T) { } func TestRenderBlueprintWithCustomWebRoot(t *testing.T) { - env := NewTestEnvironment(t) projectDir := t.TempDir() - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) config := engine.Config{ ProjectName: "test-webroot", @@ -170,11 +167,10 @@ func TestRenderBlueprintWithCustomWebRoot(t *testing.T) { } func TestRenderBlueprintWithVarnish(t *testing.T) { - env := NewTestEnvironment(t) projectDir := t.TempDir() - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) config := engine.Config{ ProjectName: "test-varnish", @@ -214,11 +210,10 @@ func TestRenderBlueprintWithVarnish(t *testing.T) { } func TestRenderBlueprintWithXdebug(t *testing.T) { - env := NewTestEnvironment(t) projectDir := t.TempDir() - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) config := engine.Config{ ProjectName: "test-xdebug", @@ -262,11 +257,10 @@ func TestRenderBlueprintWithXdebug(t *testing.T) { } func TestRenderBlueprintWithRabbitMQ(t *testing.T) { - env := NewTestEnvironment(t) projectDir := t.TempDir() - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) config := engine.Config{ ProjectName: "test-queue", @@ -302,11 +296,10 @@ func TestRenderBlueprintWithRabbitMQ(t *testing.T) { } func TestRenderBlueprintWithComposeOverride(t *testing.T) { - env := NewTestEnvironment(t) projectDir := t.TempDir() - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) overrideContent := ` services: @@ -362,11 +355,10 @@ services: } func TestBlueprintNetworkConfiguration(t *testing.T) { - env := NewTestEnvironment(t) projectDir := t.TempDir() - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) config := engine.Config{ ProjectName: "test-network", @@ -406,11 +398,10 @@ func TestBlueprintNetworkConfiguration(t *testing.T) { } func TestRenderBlueprintWithComposerVersion(t *testing.T) { - env := NewTestEnvironment(t) projectDir := t.TempDir() - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) config := engine.Config{ ProjectName: "test-composer-ver", diff --git a/tests/integration/edge_cases_test.go b/tests/integration/edge_cases_test.go index 2922c74b..ff0f37f9 100644 --- a/tests/integration/edge_cases_test.go +++ b/tests/integration/edge_cases_test.go @@ -13,11 +13,9 @@ import ( ) func TestEdgeCaseProjectNameWithUnderscores(t *testing.T) { - env := NewTestEnvironment(t) - projectDir := t.TempDir() - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) config := engine.Config{ ProjectName: "my_test_project_123", @@ -54,11 +52,9 @@ func TestEdgeCaseProjectNameWithUnderscores(t *testing.T) { } func TestEdgeCaseProjectNameWithHyphens(t *testing.T) { - env := NewTestEnvironment(t) - projectDir := t.TempDir() - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) config := engine.Config{ ProjectName: "my-test-project", @@ -171,11 +167,9 @@ func TestEdgeCasePHPVersionEdgeValues(t *testing.T) { } func TestEdgeCaseEmptyBlueprintIncludes(t *testing.T) { - env := NewTestEnvironment(t) - projectDir := t.TempDir() - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) config := engine.Config{ ProjectName: "test-empty", @@ -225,11 +219,9 @@ func TestEdgeCaseFrameworkDetectionWithDevDependencies(t *testing.T) { } func TestEdgeCaseDuplicateFeatureFlags(t *testing.T) { - env := NewTestEnvironment(t) - projectDir := t.TempDir() - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) config := engine.Config{ ProjectName: "test-dup", @@ -303,11 +295,9 @@ func TestEdgeCaseNilConfigFeatures(t *testing.T) { } func TestEdgeCaseVeryLongProjectName(t *testing.T) { - env := NewTestEnvironment(t) - projectDir := t.TempDir() - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) longName := "very-long-project-name-that-exceeds-normal-length-limits-for-testing-purposes" config := engine.Config{ diff --git a/tests/integration/env_lifecycle_test.go b/tests/integration/env_lifecycle_test.go index e728ffb7..0e517048 100644 --- a/tests/integration/env_lifecycle_test.go +++ b/tests/integration/env_lifecycle_test.go @@ -49,7 +49,7 @@ func TestUpQuickstartWithShims(t *testing.T) { env := NewTestEnvironment(t) projectDir := env.CreateProjectFromFixture(t, "magento2/options-local", "up-m2") - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) shim := env.SetupRuntimeShims(t, map[string]int{"docker": 0, "ssh": 0, "rsync": 0}) result := env.RunGovardWithEnv(t, projectDir, shim.Env(), "env", "up", "--quickstart") diff --git a/tests/integration/errors_test.go b/tests/integration/errors_test.go index 174ada5a..c93b5488 100644 --- a/tests/integration/errors_test.go +++ b/tests/integration/errors_test.go @@ -346,11 +346,9 @@ func TestCreateSnapshotNonExistentProject(t *testing.T) { } func TestBlueprintRenderWithSpecialCharactersInProjectName(t *testing.T) { - env := NewTestEnvironment(t) - projectDir := t.TempDir() - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) config := engine.Config{ ProjectName: "test_project-123", diff --git a/tests/integration/framework_shift_validation_test.go b/tests/integration/framework_shift_validation_test.go index cb3a473e..e094c198 100644 --- a/tests/integration/framework_shift_validation_test.go +++ b/tests/integration/framework_shift_validation_test.go @@ -6,6 +6,7 @@ package integration import ( "encoding/json" "govard/internal/engine" + "govard/internal/frameworks/magento2" "os" "path/filepath" "testing" @@ -66,7 +67,7 @@ func TestFrameworkProfileShiftDetection(t *testing.T) { } config.Stack.PHPVersion = "8.2" - info := engine.DetectProfileShiftForTest(config) + info := magento2.DetectProfileShiftForTest(config) if !info.Shifted { t.Errorf("[%s] Expected shift to be detected for PHP version change", fw) } @@ -78,7 +79,7 @@ func TestFrameworkProfileShiftDetection(t *testing.T) { config.Stack.PHPVersion = "8.1" config.Profile = "staging" - info = engine.DetectProfileShiftForTest(config) + info = magento2.DetectProfileShiftForTest(config) if !info.Shifted { t.Errorf("[%s] Expected shift to be detected for Profile change", fw) } diff --git a/tests/integration/framework_test.go b/tests/integration/framework_test.go index 07280e03..efc975c1 100644 --- a/tests/integration/framework_test.go +++ b/tests/integration/framework_test.go @@ -428,7 +428,7 @@ func TestFrameworkConfigOverride(t *testing.T) { } CreateGovardConfig(t, projectDir, config) - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) err := engine.RenderBlueprint(projectDir, config) if err != nil { diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index e100c33e..cccf0288 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -18,6 +18,7 @@ import ( "testing" "time" + "govard/internal/blueprints" "govard/internal/engine" "gopkg.in/yaml.v3" @@ -25,10 +26,9 @@ import ( // TestEnvironment holds the configuration for integration tests type TestEnvironment struct { - ProjectRoot string - TestProjects map[string]string - BinaryPath string - BlueprintsPath string + ProjectRoot string + TestProjects map[string]string + BinaryPath string } // RuntimeShims describes the command shim environment used by integration tests. @@ -64,10 +64,9 @@ func NewTestEnvironment(t *testing.T) *TestEnvironment { copyBinaryForTest(t, binaryPath, isolatedBinary) return &TestEnvironment{ - ProjectRoot: projectRoot, - TestProjects: make(map[string]string), - BinaryPath: isolatedBinary, - BlueprintsPath: filepath.Join(projectRoot, "internal", "blueprints", "files"), + ProjectRoot: projectRoot, + TestProjects: make(map[string]string), + BinaryPath: isolatedBinary, } } @@ -391,28 +390,18 @@ func WaitForCondition(t *testing.T, timeout time.Duration, interval time.Duratio return false } -// CopyBlueprints copies blueprints to test project -func CopyBlueprints(t *testing.T, src, dst string) { +// CopyBlueprints materializes the merged blueprints.FS (the real, in-binary +// blueprint set - shared includes/proxy.yml plus every framework package's +// embedded blueprint mount) into dst. It intentionally does NOT source from +// the on-disk internal/blueprints/files directory: that tree only holds the +// remainder of blueprints not relocated into internal/frameworks/, so +// copying it directly would silently drop every relocated framework's +// nginx templates and service definitions. +func CopyBlueprints(t *testing.T, dst string) { t.Helper() - if err := os.MkdirAll(dst, 0755); err != nil { - t.Fatalf("Failed to create blueprints dir: %v", err) - } - - entries, err := os.ReadDir(src) - if err != nil { - t.Fatalf("Failed to read blueprints dir: %v", err) - } - - for _, entry := range entries { - srcPath := filepath.Join(src, entry.Name()) - dstPath := filepath.Join(dst, entry.Name()) - - if entry.IsDir() { - copyDir(t, srcPath, dstPath) - } else { - copyFile(t, srcPath, dstPath) - } + if err := os.CopyFS(dst, blueprints.FS); err != nil { + t.Fatalf("Failed to copy blueprints: %v", err) } } diff --git a/tests/integration/magento_config_test.go b/tests/integration/magento_config_test.go index 7cc0744a..47535040 100644 --- a/tests/integration/magento_config_test.go +++ b/tests/integration/magento_config_test.go @@ -8,6 +8,7 @@ import ( "testing" "govard/internal/engine" + "govard/internal/frameworks/magento2" ) func TestBuildMagentoCommandsBasic(t *testing.T) { @@ -27,7 +28,7 @@ func TestBuildMagentoCommandsBasic(t *testing.T) { }, } - commands := engine.MagentoConfigCommandsForTest("magento-test", config) + commands := magento2.MagentoConfigCommandsForTest("magento-test", config) if len(commands) == 0 { t.Fatal("Expected at least one Magento command") @@ -63,7 +64,7 @@ func TestBuildMagentoCommandsWithRedis(t *testing.T) { }, } - commands := engine.MagentoConfigCommandsForTest("magento-redis", config) + commands := magento2.MagentoConfigCommandsForTest("magento-redis", config) hasRedisCache := false hasRedisSession := false @@ -102,7 +103,7 @@ func TestBuildMagentoCommandsWithValkey(t *testing.T) { }, } - commands := engine.MagentoConfigCommandsForTest("magento-valkey", config) + commands := magento2.MagentoConfigCommandsForTest("magento-valkey", config) hasCacheConfig := false for _, cmd := range commands { @@ -137,7 +138,7 @@ func TestBuildMagentoCommandsWithVarnish(t *testing.T) { }, } - commands := engine.MagentoConfigCommandsForTest("magento-varnish", config) + commands := magento2.MagentoConfigCommandsForTest("magento-varnish", config) hasVarnishConfig := false for _, cmd := range commands { @@ -169,7 +170,7 @@ func TestBuildMagentoCommandsWithElasticsearch(t *testing.T) { }, } - commands := engine.MagentoConfigCommandsForTest("magento-es", config) + commands := magento2.MagentoConfigCommandsForTest("magento-es", config) hasSearchConfig := false for _, cmd := range commands { @@ -202,7 +203,7 @@ func TestBuildMagentoCommandsWithOpenSearch(t *testing.T) { }, } - commands := engine.MagentoConfigCommandsForTest("magento-os", config) + commands := magento2.MagentoConfigCommandsForTest("magento-os", config) hasSearchConfig := false for _, cmd := range commands { @@ -238,7 +239,7 @@ func TestBuildMagentoCommandsAllFeatures(t *testing.T) { }, } - commands := engine.MagentoConfigCommandsForTest("magento-full", config) + commands := magento2.MagentoConfigCommandsForTest("magento-full", config) requiredCommands := map[string]bool{ "Database": false, @@ -282,7 +283,7 @@ func TestBuildMagentoCommandsBaseURL(t *testing.T) { }, } - commands := engine.MagentoConfigCommandsForTest("magento-url", config) + commands := magento2.MagentoConfigCommandsForTest("magento-url", config) hasBaseURL := false for _, cmd := range commands { @@ -314,7 +315,7 @@ func TestBuildMagentoCommandsNoDomain(t *testing.T) { }, } - commands := engine.MagentoConfigCommandsForTest("magento-no-domain", config) + commands := magento2.MagentoConfigCommandsForTest("magento-no-domain", config) if len(commands) == 0 { t.Fatal("Expected commands even without domain") @@ -337,7 +338,7 @@ func TestBuildMagentoCommandsContainerName(t *testing.T) { }, } - commands := engine.MagentoConfigCommandsForTest("test-project", config) + commands := magento2.MagentoConfigCommandsForTest("test-project", config) for _, cmd := range commands { found := false @@ -371,7 +372,7 @@ func TestBuildMagentoCommandsUser(t *testing.T) { }, } - commands := engine.MagentoConfigCommandsForTest("magento-user", config) + commands := magento2.MagentoConfigCommandsForTest("magento-user", config) hasUserFlag := false for _, cmd := range commands { diff --git a/tests/integration/magento_shift_test.go b/tests/integration/magento_shift_test.go index f5ebdb05..5f12dbf9 100644 --- a/tests/integration/magento_shift_test.go +++ b/tests/integration/magento_shift_test.go @@ -6,6 +6,7 @@ package integration import ( "encoding/json" "govard/internal/engine" + "govard/internal/frameworks/magento2" "os" "path/filepath" "testing" @@ -58,14 +59,14 @@ func TestMagentoProfileShiftDetection(t *testing.T) { config.Profile = "default" config.FrameworkVersion = "2.4.6-p3" - shifted, reason := engine.CheckProfileShiftCleanupForTest(config) + shifted, reason := magento2.CheckProfileShiftCleanupForTest(config) if shifted { t.Errorf("Expected no shift, got %v: %s", shifted, reason) } // Case 2: PHP Version change config.Stack.PHPVersion = "8.4" - shifted, reason = engine.CheckProfileShiftCleanupForTest(config) + shifted, reason = magento2.CheckProfileShiftCleanupForTest(config) if !shifted || reason != "PHP version changed: 8.2 -> 8.4" { t.Errorf("Expected PHP shift, got shifted=%v, reason=%s", shifted, reason) } @@ -73,7 +74,7 @@ func TestMagentoProfileShiftDetection(t *testing.T) { // Case 3: Profile change config.Stack.PHPVersion = "8.2" config.Profile = "upgrade" - shifted, reason = engine.CheckProfileShiftCleanupForTest(config) + shifted, reason = magento2.CheckProfileShiftCleanupForTest(config) if !shifted || reason != "Profile changed: \"default\" -> \"upgrade\"" { t.Errorf("Expected Profile shift, got shifted=%v, reason=%s", shifted, reason) } @@ -81,7 +82,7 @@ func TestMagentoProfileShiftDetection(t *testing.T) { // Case 4: Framework Version change config.Profile = "default" config.FrameworkVersion = "2.4.8-p4" - shifted, reason = engine.CheckProfileShiftCleanupForTest(config) + shifted, reason = magento2.CheckProfileShiftCleanupForTest(config) if !shifted || reason != "Version changed: 2.4.6-p3 -> 2.4.8-p4" { t.Errorf("Expected Version shift, got shifted=%v, reason=%s", shifted, reason) } @@ -90,7 +91,7 @@ func TestMagentoProfileShiftDetection(t *testing.T) { // This is the fix: empty PHP version means "use profile default", not "change" config.Stack.PHPVersion = "" config.FrameworkVersion = "2.4.6-p3" - shifted, reason = engine.CheckProfileShiftCleanupForTest(config) + shifted, reason = magento2.CheckProfileShiftCleanupForTest(config) if shifted { t.Errorf("Expected no shift when current PHP is empty, got shifted=%v: %s", shifted, reason) } @@ -98,7 +99,7 @@ func TestMagentoProfileShiftDetection(t *testing.T) { // Case 6: Empty current profile should NOT trigger shift config.Stack.PHPVersion = "8.2" config.Profile = "" - shifted, reason = engine.CheckProfileShiftCleanupForTest(config) + shifted, reason = magento2.CheckProfileShiftCleanupForTest(config) if shifted { t.Errorf("Expected no shift when current profile is empty, got shifted=%v: %s", shifted, reason) } @@ -106,7 +107,7 @@ func TestMagentoProfileShiftDetection(t *testing.T) { // Case 7: Empty current version should NOT trigger shift config.Profile = "default" config.FrameworkVersion = "" - shifted, reason = engine.CheckProfileShiftCleanupForTest(config) + shifted, reason = magento2.CheckProfileShiftCleanupForTest(config) if shifted { t.Errorf("Expected no shift when current version is empty, got shifted=%v: %s", shifted, reason) } @@ -158,7 +159,7 @@ func TestDetectMagentoProfileShiftInfo(t *testing.T) { config.Profile = "default" config.FrameworkVersion = "2.4.6-p3" - info := engine.DetectProfileShiftForTest(config) + info := magento2.DetectProfileShiftForTest(config) if info.Shifted { t.Errorf("Expected no shift, got Shifted=true, Reason=%s", info.Reason) } @@ -170,7 +171,7 @@ func TestDetectMagentoProfileShiftInfo(t *testing.T) { config.Profile = "default" config.FrameworkVersion = "2.4.8-p4" - info := engine.DetectProfileShiftForTest(config) + info := magento2.DetectProfileShiftForTest(config) if !info.Shifted { t.Fatal("Expected shift to be detected") } @@ -207,7 +208,7 @@ func TestDetectMagentoProfileShiftInfo(t *testing.T) { config.Stack.PHPVersion = "8.4" config.FrameworkVersion = "2.4.8-p4" - info := engine.DetectProfileShiftForTest(config) + info := magento2.DetectProfileShiftForTest(config) if info.Shifted { t.Fatal("Expected NO shift when there's no previous info (user runs `govard config auto` manually)") } diff --git a/tests/integration/workflow_test.go b/tests/integration/workflow_test.go index 97aa9c14..c7fdae47 100644 --- a/tests/integration/workflow_test.go +++ b/tests/integration/workflow_test.go @@ -19,7 +19,7 @@ func TestEndToEndMagento2Workflow(t *testing.T) { projectDir := env.CreateMagento2Project(t, "magento2-integration") defer env.CleanupProject(t, "magento2-integration") - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) config := engine.Config{ ProjectName: "magento2-test", @@ -66,7 +66,7 @@ func TestEndToEndLaravelWorkflow(t *testing.T) { projectDir := env.CreateLaravelProject(t, "laravel-integration") defer env.CleanupProject(t, "laravel-integration") - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) config := engine.Config{ ProjectName: "laravel-test", @@ -105,7 +105,7 @@ func TestEndToEndNextJSWorkflow(t *testing.T) { projectDir := env.CreateNextJSProject(t, "nextjs-integration") defer env.CleanupProject(t, "nextjs-integration") - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) config := engine.Config{ ProjectName: "nextjs-test", @@ -138,7 +138,7 @@ func TestBlueprintRenderingWithAllFeatures(t *testing.T) { projectDir := env.CreateMagento2Project(t, "magento2-full") defer env.CleanupProject(t, "magento2-full") - CopyBlueprints(t, env.BlueprintsPath, filepath.Join(projectDir, "blueprints")) + CopyBlueprints(t, filepath.Join(projectDir, "blueprints")) config := engine.Config{ ProjectName: "magento2-full", diff --git a/tests/magento1_multistore_test.go b/tests/magento1_multistore_test.go index 61c4fac1..9e03c87a 100644 --- a/tests/magento1_multistore_test.go +++ b/tests/magento1_multistore_test.go @@ -5,6 +5,7 @@ import ( "testing" "govard/internal/engine" + "govard/internal/frameworks/magento2" ) func TestBuildMagento1CommandsStoreDomains(t *testing.T) { @@ -19,7 +20,7 @@ func TestBuildMagento1CommandsStoreDomains(t *testing.T) { }, } - commands := engine.MagentoConfigCommandsForTest("testproject", config) + commands := magento2.MagentoConfigCommandsForTest("testproject", config) foundWebsiteScopedSQL := false foundStoreScopedSQL := false @@ -62,7 +63,7 @@ func TestBuildMagento1CommandsTypedStoreDomains(t *testing.T) { }, } - commands := engine.MagentoConfigCommandsForTest("testproject", config) + commands := magento2.MagentoConfigCommandsForTest("testproject", config) foundWebsiteOnly := false foundStoreOnly := false @@ -113,7 +114,7 @@ func TestBuildMagento1CommandsUseTablePrefix(t *testing.T) { }, } - commands := engine.MagentoConfigCommandsForTest("testproject", config) + commands := magento2.MagentoConfigCommandsForTest("testproject", config) all := make([]string, 0, len(commands)) for _, cmd := range commands { all = append(all, strings.Join(cmd.Args, " ")) diff --git a/tests/magento_composer_install_test.go b/tests/magento_composer_install_test.go index 525c2c3a..dd0438e6 100644 --- a/tests/magento_composer_install_test.go +++ b/tests/magento_composer_install_test.go @@ -7,6 +7,7 @@ import ( "testing" "govard/internal/engine" + "govard/internal/frameworks/magento2" ) func TestMaybeRunMagentoComposerInstallSkipsWhenVendorSatisfiesLock(t *testing.T) { @@ -30,13 +31,13 @@ func TestMaybeRunMagentoComposerInstallSkipsWhenVendorSatisfiesLock(t *testing.T } composerInstallCalls := 0 - defer engine.SetMagentoComposerInstallRunnerForTest(func(projectName string, config engine.Config, stdout, stderr io.Writer) error { + defer magento2.SetMagentoComposerInstallRunnerForTest(func(projectName string, config engine.Config, stdout, stderr io.Writer) error { composerInstallCalls++ return nil })() config := engine.Config{ProjectName: "sample-project", Framework: "magento2"} - engine.MaybeRunMagentoComposerInstallForTest("sample-project", config) + magento2.MaybeRunMagentoComposerInstallForTest("sample-project", config) if composerInstallCalls != 0 { t.Fatalf("expected composer install to be skipped, but it was called %d time(s)", composerInstallCalls) @@ -49,13 +50,13 @@ func TestMaybeRunMagentoComposerInstallRunsWhenVendorDoesNotSatisfyLock(t *testi // No composer.lock/installed.json fixtures: VendorSatisfiesComposerLock reports false. composerInstallCalls := 0 - defer engine.SetMagentoComposerInstallRunnerForTest(func(projectName string, config engine.Config, stdout, stderr io.Writer) error { + defer magento2.SetMagentoComposerInstallRunnerForTest(func(projectName string, config engine.Config, stdout, stderr io.Writer) error { composerInstallCalls++ return nil })() config := engine.Config{ProjectName: "sample-project", Framework: "magento2"} - engine.MaybeRunMagentoComposerInstallForTest("sample-project", config) + magento2.MaybeRunMagentoComposerInstallForTest("sample-project", config) if composerInstallCalls != 1 { t.Fatalf("expected composer install to run exactly once, got %d", composerInstallCalls) @@ -84,13 +85,13 @@ func TestMaybeRunMagentoComposerInstallRunsWhenLockVersionMismatches(t *testing. } composerInstallCalls := 0 - defer engine.SetMagentoComposerInstallRunnerForTest(func(projectName string, config engine.Config, stdout, stderr io.Writer) error { + defer magento2.SetMagentoComposerInstallRunnerForTest(func(projectName string, config engine.Config, stdout, stderr io.Writer) error { composerInstallCalls++ return nil })() config := engine.Config{ProjectName: "sample-project", Framework: "magento2"} - engine.MaybeRunMagentoComposerInstallForTest("sample-project", config) + magento2.MaybeRunMagentoComposerInstallForTest("sample-project", config) if composerInstallCalls != 1 { t.Fatalf("expected composer install to run exactly once, got %d", composerInstallCalls) diff --git a/tests/magento_detection_helpers_test.go b/tests/magento_detection_helpers_test.go index 68fcbddf..67936968 100644 --- a/tests/magento_detection_helpers_test.go +++ b/tests/magento_detection_helpers_test.go @@ -1,7 +1,7 @@ package tests import ( - "govard/internal/engine" + "govard/internal/frameworks/magento2" "os" "testing" ) @@ -25,7 +25,7 @@ func TestIsMagentoElasticsuiteProject(t *testing.T) { t.Fatalf("Failed to write composer.json: %v", err) } - if !engine.IsMagentoElasticsuiteProjectForTest() { + if !magento2.IsMagentoElasticsuiteProjectForTest() { t.Errorf("Expected elasticsuite detection via composer.json to be true") } @@ -41,7 +41,7 @@ func TestIsMagentoElasticsuiteProject(t *testing.T) { t.Fatalf("Failed to write config.php: %v", err) } - if !engine.IsMagentoElasticsuiteProjectForTest() { + if !magento2.IsMagentoElasticsuiteProjectForTest() { t.Errorf("Expected elasticsuite detection via config.php to be true") } } @@ -59,7 +59,7 @@ func TestIsMagentoConfigPathUnavailable(t *testing.T) { } for _, tt := range tests { - if got := engine.IsMagentoConfigPathUnavailableForTest(tt.output); got != tt.expected { + if got := magento2.IsMagentoConfigPathUnavailableForTest(tt.output); got != tt.expected { t.Errorf("IsMagentoConfigPathUnavailable(%q) = %v; want %v", tt.output, got, tt.expected) } } diff --git a/tests/magento_family_fresh_install_test.go b/tests/magento_family_fresh_install_test.go index 7224076a..9265b148 100644 --- a/tests/magento_family_fresh_install_test.go +++ b/tests/magento_family_fresh_install_test.go @@ -133,10 +133,10 @@ func TestBuildMagentoSetupInstallArgsUsesOpenSearchForMageOSRegardlessOfVersion( } } -func TestMagentoFamilyPreConfigureCallsEnsureMagentoEnvPHP(t *testing.T) { +func TestMagentoFamilyPreConfigureCallsEnsureFrameworkEnvironment(t *testing.T) { called := false helpers := bootstrap.CmdHelpers{ - EnsureMagentoEnvPHP: func() error { + EnsureFrameworkEnvironment: func() error { called = true return nil }, @@ -146,14 +146,14 @@ func TestMagentoFamilyPreConfigureCallsEnsureMagentoEnvPHP(t *testing.T) { t.Fatalf("PreConfigure() error = %v", err) } if !called { - t.Fatal("expected EnsureMagentoEnvPHP to be called") + t.Fatal("expected EnsureFrameworkEnvironment to be called") } } func TestMagentoFamilyPreConfigurePropagatesError(t *testing.T) { wantErr := errors.New("env.php generation failed") helpers := bootstrap.CmdHelpers{ - EnsureMagentoEnvPHP: func() error { + EnsureFrameworkEnvironment: func() error { return wantErr }, } @@ -167,11 +167,11 @@ func TestMagentoFamilyPreConfigurePropagatesError(t *testing.T) { func TestMagentoFamilyPostCloneCreatesAdminThenReindexesWhenAdminCreateTrue(t *testing.T) { var calls []string helpers := bootstrap.CmdHelpers{ - RunMagentoAdminCreate: func() error { + RunToolSilent: func(tool string, args []string) error { calls = append(calls, "admin-create") return nil }, - RunMagentoReindex: func() error { + RunTool: func(tool string, args []string) error { calls = append(calls, "reindex") return nil }, @@ -189,11 +189,11 @@ func TestMagentoFamilyPostCloneCreatesAdminThenReindexesWhenAdminCreateTrue(t *t func TestMagentoFamilyPostCloneSkipsAdminCreateWhenFalse(t *testing.T) { var calls []string helpers := bootstrap.CmdHelpers{ - RunMagentoAdminCreate: func() error { + RunToolSilent: func(tool string, args []string) error { calls = append(calls, "admin-create") return nil }, - RunMagentoReindex: func() error { + RunTool: func(tool string, args []string) error { calls = append(calls, "reindex") return nil }, @@ -211,10 +211,10 @@ func TestMagentoFamilyPostCloneSkipsAdminCreateWhenFalse(t *testing.T) { func TestMagentoFamilyPostClonePropagatesReindexError(t *testing.T) { wantErr := errors.New("reindex failed") helpers := bootstrap.CmdHelpers{ - RunMagentoAdminCreate: func() error { + RunToolSilent: func(tool string, args []string) error { return nil }, - RunMagentoReindex: func() error { + RunTool: func(tool string, args []string) error { return wantErr }, } @@ -224,3 +224,26 @@ func TestMagentoFamilyPostClonePropagatesReindexError(t *testing.T) { t.Fatalf("PostClone() error = %v, want %v", err, wantErr) } } + +func TestMagentoFamilySampleDataUsesMagentoToolWorkflow(t *testing.T) { + var calls []string + helpers := bootstrap.CmdHelpers{ + RunTool: func(tool string, args []string) error { + calls = append(calls, tool+" "+strings.Join(args, " ")) + return nil + }, + } + + if err := magento2.RunSampleData(helpers); err != nil { + t.Fatalf("RunSampleData() error = %v", err) + } + want := []string{ + "magento sample:deploy", + "magento setup:upgrade", + "magento indexer:reindex", + "magento cache:flush", + } + if !reflect.DeepEqual(calls, want) { + t.Fatalf("tool calls = %#v, want %#v", calls, want) + } +} diff --git a/tests/magento_multistore_test.go b/tests/magento_multistore_test.go index 407e3a95..01c5f0c6 100644 --- a/tests/magento_multistore_test.go +++ b/tests/magento_multistore_test.go @@ -2,6 +2,7 @@ package tests import ( "govard/internal/engine" + "govard/internal/frameworks/magento2" "strings" "testing" ) @@ -17,7 +18,7 @@ func TestBuildMagentoCommandsStoreDomains(t *testing.T) { }, } - commands := engine.MagentoConfigCommandsForTest("testproject", config) + commands := magento2.MagentoConfigCommandsForTest("testproject", config) foundUnsecure := false foundSecure := false @@ -53,7 +54,7 @@ func TestBuildMagentoCommandsNoStoreDomains(t *testing.T) { // StoreDomains is nil } - commands := engine.MagentoConfigCommandsForTest("testproject", config) + commands := magento2.MagentoConfigCommandsForTest("testproject", config) for _, cmd := range commands { if strings.Contains(strings.Join(cmd.Args, " "), "--scope=stores") { @@ -74,7 +75,7 @@ func TestBuildMagentoCommandsWebsiteScopedStoreDomains(t *testing.T) { }, } - commands := engine.MagentoConfigCommandsForTest("testproject", config) + commands := magento2.MagentoConfigCommandsForTest("testproject", config) foundWebsiteScope := false foundStoreScope := false diff --git a/tests/magento_search_fix_test.go b/tests/magento_search_fix_test.go index b5475c02..9208fb28 100644 --- a/tests/magento_search_fix_test.go +++ b/tests/magento_search_fix_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "govard/internal/engine" + "govard/internal/frameworks/magento2" ) func TestBuildMagentoSearchHostFixSQL(t *testing.T) { @@ -50,7 +50,7 @@ func TestBuildMagentoSearchHostFixSQL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - sql := engine.BuildMagentoSearchHostFixSQL(tt.host, tt.searchEngine) + sql := magento2.BuildMagentoSearchHostFixSQL(tt.host, tt.searchEngine) for _, exp := range tt.expected { if !strings.Contains(sql, exp) { diff --git a/tests/magento_upgrade_test.go b/tests/magento_upgrade_test.go index d91e8529..277ccf7b 100644 --- a/tests/magento_upgrade_test.go +++ b/tests/magento_upgrade_test.go @@ -4,7 +4,7 @@ import ( "reflect" "testing" - "govard/internal/engine" + "govard/internal/frameworks/magento2" ) func TestMergeComposerMapKeys(t *testing.T) { @@ -103,7 +103,7 @@ func TestMergeComposerMapKeys(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - engine.MergeComposerMapKeysForTest(tt.current, tt.target, tt.key) + magento2.MergeComposerMapKeysForTest(tt.current, tt.target, tt.key) if !reflect.DeepEqual(tt.current, tt.expected) { t.Errorf("MergeComposerMapKeysForTest() mismatch.\nGot: %v\nWant: %v", tt.current, tt.expected) } diff --git a/tests/mageos_blueprint_test.go b/tests/mageos_blueprint_test.go index 200bf760..137fc149 100644 --- a/tests/mageos_blueprint_test.go +++ b/tests/mageos_blueprint_test.go @@ -3,9 +3,9 @@ package tests import ( "os" "path/filepath" - "runtime" "testing" + "govard/internal/blueprints" "govard/internal/engine" ) @@ -38,13 +38,9 @@ func TestMageOSRequiredRuntimeImageIsPHPMagento2(t *testing.T) { } func TestMageOSBlueprintRendersLikeMagento2(t *testing.T) { - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Join(filepath.Dir(filename), "..") - blueprintsDir := filepath.Join(projectRoot, "internal", "blueprints", "files") - tempDir := t.TempDir() destBlueprintsDir := filepath.Join(tempDir, "blueprints") - if err := copyDir(blueprintsDir, destBlueprintsDir); err != nil { + if err := os.CopyFS(destBlueprintsDir, blueprints.FS); err != nil { t.Fatalf("failed to copy blueprints: %v", err) } diff --git a/tests/mageos_upgrade_test.go b/tests/mageos_upgrade_test.go index e3a63f8a..5c16abeb 100644 --- a/tests/mageos_upgrade_test.go +++ b/tests/mageos_upgrade_test.go @@ -4,6 +4,7 @@ import ( "testing" "govard/internal/engine" + "govard/internal/frameworks/magento2" ) func TestMageOSComposerCleanupUsesMageOSPrefixNotMagentoPrefix(t *testing.T) { @@ -20,7 +21,7 @@ func TestMageOSComposerCleanupUsesMageOSPrefixNotMagentoPrefix(t *testing.T) { }, } - engine.MergeComposerMapKeysWithPrefixForTest(current, target, "require", "mage-os/") + magento2.MergeComposerMapKeysWithPrefixForTest(current, target, "require", "mage-os/") requireMap := current["require"].(map[string]interface{}) if _, ok := requireMap["mage-os/module-catalog"]; ok { @@ -47,7 +48,7 @@ func TestMagentoComposerCleanupStillUsesMagentoPrefix(t *testing.T) { }, } - engine.MergeComposerMapKeysWithPrefixForTest(current, target, "require", "magento/") + magento2.MergeComposerMapKeysWithPrefixForTest(current, target, "require", "magento/") requireMap := current["require"].(map[string]interface{}) if _, ok := requireMap["magento/module-catalog"]; ok { diff --git a/tests/open_command_test.go b/tests/open_command_test.go index c47ebced..d23dfaa2 100644 --- a/tests/open_command_test.go +++ b/tests/open_command_test.go @@ -6,6 +6,8 @@ import ( "govard/internal/cmd" "govard/internal/engine" + "govard/internal/frameworks" + "govard/internal/frameworks/magento2" ) func TestOpenAdminURL(t *testing.T) { @@ -107,6 +109,33 @@ func TestBuildRemoteAdminURLForTest(t *testing.T) { } } +func TestFrameworkRemoteAdminPathDispatch(t *testing.T) { + emdashPath, err := frameworks.ResolveRemoteAdminPath("emdash", "staging", engine.RemoteConfig{}) + if err != nil { + t.Fatalf("resolve Emdash admin path: %v", err) + } + if emdashPath != "_emdash/admin" { + t.Fatalf("expected Emdash path, got %q", emdashPath) + } + + defaultPath, err := frameworks.ResolveRemoteAdminPath("laravel", "staging", engine.RemoteConfig{}) + if err != nil { + t.Fatalf("resolve Laravel admin path: %v", err) + } + if defaultPath != "admin" { + t.Fatalf("expected generic admin path, got %q", defaultPath) + } + + magento, ok := frameworks.Get("magento2") + if !ok || magento.ResolveRemoteAdminPath == nil { + t.Fatal("expected Magento 2 to own remote admin-path probing") + } + mageOS, ok := frameworks.Get("mageos") + if !ok || mageOS.ResolveRemoteAdminPath == nil { + t.Fatal("expected Mage-OS to inherit remote admin-path probing") + } +} + func TestBuildSFTPURLForTest(t *testing.T) { url := cmd.BuildSFTPURLForTest(engine.RemoteConfig{ Host: "dev.example.com", @@ -126,14 +155,14 @@ func TestResolveMagentoAdminURLForTest(t *testing.T) { baseURL := "https://shop.test" t.Run("uses env frontName by default", func(t *testing.T) { - url := cmd.ResolveMagentoAdminURLForTest(baseURL, "backend_x", map[string]string{}) + url := magento2.ResolveLocalAdminURL(baseURL, "backend_x", map[string]string{}) if url != "https://shop.test/backend_x" { t.Fatalf("unexpected admin url from env frontName: %s", url) } }) t.Run("uses custom_path from db when enabled", func(t *testing.T) { - url := cmd.ResolveMagentoAdminURLForTest(baseURL, "backend_x", map[string]string{ + url := magento2.ResolveLocalAdminURL(baseURL, "backend_x", map[string]string{ "admin/url/use_custom_path": "1", "admin/url/custom_path": "super-secret-admin", }) @@ -143,7 +172,7 @@ func TestResolveMagentoAdminURLForTest(t *testing.T) { }) t.Run("uses custom url from db when enabled", func(t *testing.T) { - url := cmd.ResolveMagentoAdminURLForTest(baseURL, "backend_x", map[string]string{ + url := magento2.ResolveLocalAdminURL(baseURL, "backend_x", map[string]string{ "admin/url/use_custom": "1", "admin/url/custom": "https://admin.example.com/secure-panel", }) @@ -153,7 +182,7 @@ func TestResolveMagentoAdminURLForTest(t *testing.T) { }) t.Run("falls back to admin when no data", func(t *testing.T) { - url := cmd.ResolveMagentoAdminURLForTest(baseURL, "", map[string]string{}) + url := magento2.ResolveLocalAdminURL(baseURL, "", map[string]string{}) if url != "https://shop.test/admin" { t.Fatalf("unexpected fallback admin url: %s", url) } diff --git a/tests/relax_packages_test.go b/tests/relax_packages_test.go index 89ff6d94..0df58365 100644 --- a/tests/relax_packages_test.go +++ b/tests/relax_packages_test.go @@ -1,7 +1,7 @@ package tests import ( - "govard/internal/engine" + "govard/internal/frameworks/magento2" "testing" ) @@ -20,7 +20,7 @@ func TestRelaxPackagesFromContent(t *testing.T) { }` // Call the function with empty containerName so it doesn't try to run docker commands - relaxed := engine.RelaxPackagesFromContentForTest(content, "") + relaxed := magento2.RelaxPackagesFromContentForTest(content, "") expected := []string{ "symfony/process:*", @@ -52,7 +52,7 @@ func TestRelaxPackagesFromContentWithNoMatches(t *testing.T) { "some/other-package": "^1.0" } }` - relaxed := engine.RelaxPackagesFromContentForTest(content, "") + relaxed := magento2.RelaxPackagesFromContentForTest(content, "") if len(relaxed) != 0 { t.Errorf("Expected 0 relaxed packages, got %d: %v", len(relaxed), relaxed) } diff --git a/tests/remote_db_credentials_projection_test.go b/tests/remote_db_credentials_projection_test.go new file mode 100644 index 00000000..9cbae437 --- /dev/null +++ b/tests/remote_db_credentials_projection_test.go @@ -0,0 +1,44 @@ +package tests + +import ( + "testing" + + "govard/internal/cmd" + "govard/internal/engine" + "govard/internal/engine/remote" +) + +func TestProjectRemoteDBCredentialsDoesNotInjectConfigPrefixWithoutCapability(t *testing.T) { + credentials := cmd.ProjectRemoteDBCredentialsForTest( + engine.Config{TablePrefix: "wp_"}, + remote.RemoteDatabaseMetadata{Host: "db.example", Username: "wordpress", Database: "wordpress"}, + false, + ) + if credentials.TablePrefix != "" { + t.Errorf("TablePrefix = %q, want empty when framework does not opt in", credentials.TablePrefix) + } +} + +func TestProjectRemoteDBCredentialsUsesRemotePrefixBeforeOptInConfigPrefix(t *testing.T) { + t.Run("remote prefix wins", func(t *testing.T) { + credentials := cmd.ProjectRemoteDBCredentialsForTest( + engine.Config{TablePrefix: "local_"}, + remote.RemoteDatabaseMetadata{Host: "db.example", Username: "magento", Database: "magento", TablePrefix: "remote_"}, + true, + ) + if credentials.TablePrefix != "remote_" { + t.Errorf("TablePrefix = %q, want remote_", credentials.TablePrefix) + } + }) + + t.Run("opt-in config prefix is fallback", func(t *testing.T) { + credentials := cmd.ProjectRemoteDBCredentialsForTest( + engine.Config{TablePrefix: "local_"}, + remote.RemoteDatabaseMetadata{Host: "db.example", Username: "prestashop", Database: "prestashop"}, + true, + ) + if credentials.TablePrefix != "local_" { + t.Errorf("TablePrefix = %q, want local_", credentials.TablePrefix) + } + }) +} diff --git a/tests/remote_magento_metadata_test.go b/tests/remote_magento_metadata_test.go index 3af6cdda..464808bb 100644 --- a/tests/remote_magento_metadata_test.go +++ b/tests/remote_magento_metadata_test.go @@ -6,9 +6,11 @@ import ( "testing" "govard/internal/engine/remote" + "govard/internal/frameworks/magento1" + "govard/internal/frameworks/magento2" ) -func TestParseMagentoDBHostPort(t *testing.T) { +func TestParseRemoteDatabaseHostPort(t *testing.T) { testCases := []struct { name string raw string @@ -25,7 +27,7 @@ func TestParseMagentoDBHostPort(t *testing.T) { for _, testCase := range testCases { testCase := testCase t.Run(testCase.name, func(t *testing.T) { - host, port := remote.ParseMagentoDBHostPort(testCase.raw) + host, port := remote.ParseDatabaseHostPort(testCase.raw) if host != testCase.expectH { t.Fatalf("host mismatch: got %q want %q", host, testCase.expectH) } @@ -53,7 +55,7 @@ func TestNormalizeMagentoVersion(t *testing.T) { for _, testCase := range testCases { testCase := testCase t.Run(testCase.name, func(t *testing.T) { - actual := remote.NormalizeMagentoVersion(testCase.raw) + actual := magento2.NormalizeMagentoVersion(testCase.raw) if actual != testCase.expect { t.Fatalf("version mismatch: got %q want %q", actual, testCase.expect) } @@ -70,7 +72,7 @@ func TestDecodeMagento2EnvironmentPayloadIncludesTablePrefix(t *testing.T) { "table_prefix": "demo_", }) - metadata, err := remote.DecodeMagento2EnvironmentPayloadForTest(encoded) + metadata, err := magento2.DecodeMagento2EnvironmentPayloadForTest(encoded) if err != nil { t.Fatalf("DecodeMagento2EnvironmentPayloadForTest() error = %v", err) } @@ -88,7 +90,7 @@ func TestDecodeMagento1EnvironmentPayloadIncludesTablePrefix(t *testing.T) { "table_prefix": "demo_", }) - metadata, err := remote.DecodeMagento1EnvironmentPayloadForTest(encoded) + metadata, err := magento1.DecodeMagento1EnvironmentPayloadForTest(encoded) if err != nil { t.Fatalf("DecodeMagento1EnvironmentPayloadForTest() error = %v", err) } diff --git a/tests/remote_prestashop_metadata_test.go b/tests/remote_prestashop_metadata_test.go index 79d9b402..0c5c1fd3 100644 --- a/tests/remote_prestashop_metadata_test.go +++ b/tests/remote_prestashop_metadata_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - "govard/internal/engine/remote" + "govard/internal/frameworks/prestashop" ) func TestDecodePrestaShopEnvironmentPayload(t *testing.T) { @@ -22,7 +22,7 @@ func TestDecodePrestaShopEnvironmentPayload(t *testing.T) { } encoded := base64.StdEncoding.EncodeToString(raw) - env, err := remote.DecodePrestaShopEnvironmentPayloadForTest(encoded) + env, err := prestashop.DecodePrestaShopEnvironmentPayloadForTest(encoded) if err != nil { t.Fatalf("decode payload: %v", err) } @@ -65,7 +65,7 @@ func TestDecodePrestaShopEnvironmentPayloadIncludesSecrets(t *testing.T) { } encoded := base64.StdEncoding.EncodeToString(raw) - env, err := remote.DecodePrestaShopEnvironmentPayloadForTest(encoded) + env, err := prestashop.DecodePrestaShopEnvironmentPayloadForTest(encoded) if err != nil { t.Fatalf("decode payload: %v", err) } @@ -96,7 +96,7 @@ func TestDecodePrestaShopEnvironmentPayloadSecretsOptional(t *testing.T) { } encoded := base64.StdEncoding.EncodeToString(raw) - env, err := remote.DecodePrestaShopEnvironmentPayloadForTest(encoded) + env, err := prestashop.DecodePrestaShopEnvironmentPayloadForTest(encoded) if err != nil { t.Fatalf("expected no error when secrets are absent, got: %v", err) } @@ -110,13 +110,13 @@ func TestDecodePrestaShopEnvironmentPayloadMissingRequiredFields(t *testing.T) { raw, _ := json.Marshal(payload) encoded := base64.StdEncoding.EncodeToString(raw) - if _, err := remote.DecodePrestaShopEnvironmentPayloadForTest(encoded); err == nil { + if _, err := prestashop.DecodePrestaShopEnvironmentPayloadForTest(encoded); err == nil { t.Fatal("expected error when username/dbname are missing") } } func TestDecodePrestaShopEnvironmentPayloadEmpty(t *testing.T) { - if _, err := remote.DecodePrestaShopEnvironmentPayloadForTest(""); err == nil { + if _, err := prestashop.DecodePrestaShopEnvironmentPayloadForTest(""); err == nil { t.Fatal("expected error for empty payload") } } diff --git a/tests/remote_wordpress_metadata_test.go b/tests/remote_wordpress_metadata_test.go new file mode 100644 index 00000000..2b8eecf0 --- /dev/null +++ b/tests/remote_wordpress_metadata_test.go @@ -0,0 +1,46 @@ +package tests + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "govard/internal/frameworks/wordpress" +) + +// This catches a regression where the WordPress remote probe lived in generic +// engine code and its framework-specific wp-config.php contract was lost. +func TestDecodeWordPressEnvironmentPayload(t *testing.T) { + raw, err := json.Marshal(map[string]string{ + "host": "wordpress-db:3307", + "username": "wordpress", + "password": "secret", + "dbname": "wordpress", + }) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + + environment, err := wordpress.DecodeEnvironmentPayloadForTest(base64.StdEncoding.EncodeToString(raw)) + if err != nil { + t.Fatalf("decode payload: %v", err) + } + + if environment.DB.Host != "wordpress-db" || environment.DB.Port != 3307 { + t.Fatalf("expected wordpress-db:3307, got %s:%d", environment.DB.Host, environment.DB.Port) + } + if environment.DB.Username != "wordpress" || environment.DB.Password != "secret" || environment.DB.Database != "wordpress" { + t.Fatalf("expected decoded WordPress credentials, got %+v", environment.DB) + } +} + +func TestDecodeWordPressEnvironmentPayloadRejectsMissingCredentials(t *testing.T) { + raw, err := json.Marshal(map[string]string{"host": "wordpress-db"}) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + + if _, err := wordpress.DecodeEnvironmentPayloadForTest(base64.StdEncoding.EncodeToString(raw)); err == nil { + t.Fatal("expected missing DB_USER and DB_NAME to be rejected") + } +} diff --git a/tests/snapshot_engine_test.go b/tests/snapshot_engine_test.go index 4cebc5c4..a7f89d4c 100644 --- a/tests/snapshot_engine_test.go +++ b/tests/snapshot_engine_test.go @@ -72,6 +72,17 @@ func TestBuildSnapshotDumpCommandUsesEnvPassword(t *testing.T) { } } +func TestBuildSnapshotDumpCommandUsesGenericFallbackWithoutFrameworkCredentials(t *testing.T) { + args := engine.BuildSnapshotDumpCommandForTest("example-db-1", "", "", "") + joined := strings.Join(args, " ") + if !strings.Contains(joined, "mysqldump -u root --all-databases") { + t.Fatalf("expected generic root/all-databases fallback, got: %s", joined) + } + if strings.Contains(joined, "magento") { + t.Fatalf("snapshot fallback must not encode Magento defaults, got: %s", joined) + } +} + func TestBuildSnapshotImportCommandUsesEnvPassword(t *testing.T) { args := engine.BuildSnapshotImportCommandForTest("example-db-1", "app", "secret", "shop") joined := strings.Join(args, " ") diff --git a/tests/table_prefix_mageos_test.go b/tests/table_prefix_mageos_test.go index 8dbce50b..44f84b66 100644 --- a/tests/table_prefix_mageos_test.go +++ b/tests/table_prefix_mageos_test.go @@ -6,6 +6,7 @@ import ( "testing" "govard/internal/engine" + "govard/internal/frameworks/magento2" ) func TestMageOSSupportsTablePrefix(t *testing.T) { @@ -40,11 +41,11 @@ return [ t.Fatalf("write env.php: %v", err) } - if got := engine.DetectMagento2TablePrefix(root); got != "mos_" { - t.Fatalf("expected table prefix 'mos_' via DetectMagento2TablePrefix, got %q", got) + if got := magento2.DetectTablePrefix(root); got != "mos_" { + t.Fatalf("expected table prefix 'mos_' via Magento 2 capability, got %q", got) } - if got := engine.DetectMagentoTablePrefix(root, "mageos"); got != "mos_" { - t.Fatalf("expected DetectMagentoTablePrefix to return 'mos_' for mageos, got %q", got) + if got := engine.DetectFrameworkTablePrefix(root, "mageos"); got != "mos_" { + t.Fatalf("expected generic detector to return 'mos_' for mageos, got %q", got) } } diff --git a/tests/table_prefix_prestashop_test.go b/tests/table_prefix_prestashop_test.go index 41dfc245..bded0492 100644 --- a/tests/table_prefix_prestashop_test.go +++ b/tests/table_prefix_prestashop_test.go @@ -6,6 +6,7 @@ import ( "testing" "govard/internal/engine" + "govard/internal/frameworks/prestashop" ) func TestPrestaShopSupportsTablePrefix(t *testing.T) { @@ -38,19 +39,19 @@ return array ( t.Fatalf("write parameters.php: %v", err) } - prefix := engine.DetectPrestaShopTablePrefix(root) + prefix := prestashop.DetectTablePrefix(root) if prefix != "shop_" { t.Fatalf("expected table prefix 'shop_', got %q", prefix) } - if got := engine.DetectMagentoTablePrefix(root, "prestashop"); got != "shop_" { - t.Fatalf("expected DetectMagentoTablePrefix to return 'shop_', got %q", got) + if got := engine.DetectFrameworkTablePrefix(root, "prestashop"); got != "shop_" { + t.Fatalf("expected generic detector to return 'shop_', got %q", got) } } func TestDetectPrestaShopTablePrefixMissingFile(t *testing.T) { root := t.TempDir() - if prefix := engine.DetectPrestaShopTablePrefix(root); prefix != "" { + if prefix := prestashop.DetectTablePrefix(root); prefix != "" { t.Fatalf("expected empty prefix when parameters.php is missing, got %q", prefix) } } diff --git a/tests/up_command_test.go b/tests/up_command_test.go index bb575043..fab850f7 100644 --- a/tests/up_command_test.go +++ b/tests/up_command_test.go @@ -3,7 +3,6 @@ package tests import ( "context" "errors" - "os" "reflect" "strings" "testing" @@ -11,6 +10,7 @@ import ( "govard/internal/cmd" "govard/internal/engine" + "govard/internal/frameworks" ) func TestUpCommandQuickstartFlagExists(t *testing.T) { @@ -365,69 +365,19 @@ func TestApplyQuickstartProfileDisablesOptionalServices(t *testing.T) { } } -func TestCheckMagentoRuntimeSyncReturnsWarnings(t *testing.T) { - // Setup a temporary project environment so LoadRawConfigFromDir finds an empty config - tmpDir := t.TempDir() - origWd, _ := os.Getwd() - defer func() { - _ = os.Chdir(origWd) - }() - if err := os.Chdir(tmpDir); err != nil { - t.Fatal(err) +func TestFrameworkLifecycleHooksAreOwnedByDefinitions(t *testing.T) { + wordpress, ok := frameworks.Get("wordpress") + if !ok || wordpress.PostEnvironmentUp == nil { + t.Fatal("expected WordPress to own its post-environment compatibility hook") } - // Create a raw config that lacks explicit versions (so it's not 'intentional') - rawContent := `project_name: test-sync -framework: magento2 -` - if err := os.WriteFile(".govard.yml", []byte(rawContent), 0644); err != nil { - t.Fatal(err) + magento, ok := frameworks.Get("magento2") + if !ok || magento.ConfigureAfterProfileShift == nil { + t.Fatal("expected Magento 2 to own its profile-shift configuration hook") } - config := engine.Config{ - Framework: "magento2", - Stack: engine.Stack{ - PHPVersion: "8.1", // This would be the normalized result if we were testing normalization - Services: engine.Services{ - Search: "elasticsearch", - }, - }, - } - - warnings := cmd.CheckMagentoRuntimeSync(config, engine.ProjectMetadata{ - Framework: "magento2", - Version: "2.4.7-p3", - }) - - if len(warnings) == 0 { - t.Fatal("expected warnings for out of sync profile when versions are not explicitly set in raw config") - } - - warningMsg := warnings[0] - // Magento 2.4.7-p3 expects PHP 8.3. - // Since raw config is empty, CheckMagentoRuntimeSync should warn about the mismatch. - if !strings.Contains(warningMsg, "PHP") { - t.Errorf("expected warning about PHP mismatch, got: %s", warningMsg) - } -} - -func TestCheckMagentoRuntimeSyncReturnsNilWhenSynced(t *testing.T) { - config := engine.Config{ - Framework: "magento2", - Stack: engine.Stack{ - PHPVersion: "8.3", - Services: engine.Services{ - Search: "opensearch", - }, - }, - } - - warnings := cmd.CheckMagentoRuntimeSync(config, engine.ProjectMetadata{ - Framework: "magento2", - Version: "2.4.7-p3", - }) - - if len(warnings) > 0 { - t.Fatalf("expected no warnings for synced profile, got: %v", warnings) + mageOS, ok := frameworks.Get("mageos") + if !ok || mageOS.ConfigureAfterProfileShift == nil { + t.Fatal("expected Mage-OS to inherit the Magento profile-shift configuration hook") } } diff --git a/tests/varnish_template_test.go b/tests/varnish_template_test.go index de7e5e88..f9002707 100644 --- a/tests/varnish_template_test.go +++ b/tests/varnish_template_test.go @@ -9,7 +9,7 @@ import ( func TestMagento2VarnishTemplateTracksOfficialMagentoDefaults(t *testing.T) { projectRoot := testProjectRoot(t) - vclPath := filepath.Join(projectRoot, "internal", "blueprints", "files", "magento2", "varnish", "default.vcl") + vclPath := filepath.Join(projectRoot, "internal", "frameworks", "magento2", "blueprint", "varnish", "default.vcl") content, err := os.ReadFile(vclPath) if err != nil { diff --git a/tests/web_server_support_template_test.go b/tests/web_server_support_template_test.go index f185a04d..37d6852b 100644 --- a/tests/web_server_support_template_test.go +++ b/tests/web_server_support_template_test.go @@ -16,11 +16,39 @@ func TestApacheSupportTemplateMatchesDockerTemplate(t *testing.T) { ) } +// frameworkNginxTemplateOwners maps an nginx support template's file name to +// the framework package that now owns it (relocated out of +// internal/blueprints/files/support/nginx/templates into +// internal/frameworks//blueprint/ by the framework consolidation +// refactor). Templates with no entry here (default.conf, hybrid.conf) are +// generic and still live directly under internal/blueprints/files/support/nginx/templates. +var frameworkNginxTemplateOwners = map[string]string{ + "cakephp.conf": "cakephp", + "drupal.conf": "drupal", + "laravel.conf": "laravel", + "magento1.conf": "magento1", + "magento2.conf": "magento2", + "prestashop.conf": "prestashop", + "shopware.conf": "shopware", + "symfony.conf": "symfony", + "wordpress.conf": "wordpress", +} + +// resolveNginxSupportTemplatePath returns the current on-disk path for an +// nginx support template, whether it still lives in the shared +// internal/blueprints/files support tree or was relocated to its owning +// framework package's blueprint/ directory. +func resolveNginxSupportTemplatePath(projectRoot, name string) string { + if owner, ok := frameworkNginxTemplateOwners[name]; ok { + return filepath.Join(projectRoot, "internal", "frameworks", owner, "blueprint", name) + } + return filepath.Join(projectRoot, "internal", "blueprints", "files", "support", "nginx", "templates", name) +} + func TestNginxSupportTemplatesMatchDockerTemplates(t *testing.T) { projectRoot := testProjectRoot(t) dockerDir := filepath.Join(projectRoot, "docker", "nginx", "etc", "templates") - supportDir := filepath.Join(projectRoot, "internal", "blueprints", "files", "support", "nginx", "templates") entries, err := os.ReadDir(dockerDir) if err != nil { @@ -34,7 +62,7 @@ func TestNginxSupportTemplatesMatchDockerTemplates(t *testing.T) { assertFilesEqual( t, filepath.Join(dockerDir, entry.Name()), - filepath.Join(supportDir, entry.Name()), + resolveNginxSupportTemplatePath(projectRoot, entry.Name()), ) } }