diff --git a/.github/workflows/functional-tests.yml b/.github/workflows/functional-tests.yml
index 1329d88..dad47a8 100644
--- a/.github/workflows/functional-tests.yml
+++ b/.github/workflows/functional-tests.yml
@@ -126,10 +126,10 @@ jobs:
bin/magento mageforge:hyva:compatibility:check --show-all
echo "Third party only:"
- bin/magento m:h:c:c --third-party-only
+ bin/magento mageforge:hyva:compatibility:check --third-party-only
echo "Detailed output:"
- bin/magento m:h:c:c --show-all --detailed
+ bin/magento mageforge:hyva:compatibility:check --show-all --detailed
- name: Test Theme Cleaner
working-directory: magento2
@@ -139,7 +139,6 @@ jobs:
bin/magento mageforge:theme:clean --all --dry-run
echo "Test aliases:"
- bin/magento m:t:c --help
bin/magento frontend:clean --help
- name: Test Theme Name Suggestions
@@ -155,11 +154,24 @@ jobs:
echo "CleanCommand with invalid name:"
bin/magento mageforge:theme:clean Magent/lum --dry-run || echo "Expected failure - suggestions shown"
- - name: Test Inspector Status
+ - name: Test Copy From Vendor
working-directory: magento2
run: |
- echo "=== Inspector Tests ==="
- bin/magento mageforge:theme:inspector status
+ echo "=== Copy From Vendor Tests ==="
+
+ echo "Test help command:"
+ bin/magento mageforge:theme:copy-from-vendor --help
+
+ echo "Test alias help:"
+ bin/magento theme:copy --help
+
+ echo "Test dry-run without required arguments (expect validation failure but command should execute):"
+ bin/magento mageforge:theme:copy-from-vendor --dry-run || echo "Expected failure - missing or invalid arguments for copy-from-vendor"
+
+ echo "Test alias dry-run without required arguments (expect validation failure but alias should execute):"
+ bin/magento theme:copy --dry-run || echo "Expected failure - missing or invalid arguments for theme:copy alias"
+
+ echo "✓ Copy from vendor command and alias available and basic execution paths exercised"
- name: Test Inspector Functionality
working-directory: magento2
@@ -496,7 +508,6 @@ jobs:
bin/magento mageforge:theme:build Magento/blank --verbose || echo "Build attempted (may need additional setup)"
echo "Test build aliases:"
- bin/magento m:t:b --help
bin/magento frontend:build --help
- name: Test Summary
diff --git a/.github/workflows/magento-compatibility.yml b/.github/workflows/magento-compatibility.yml
index 845abf0..1f2902c 100644
--- a/.github/workflows/magento-compatibility.yml
+++ b/.github/workflows/magento-compatibility.yml
@@ -32,15 +32,15 @@ jobs:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
- opensearch:
- image: opensearchproject/opensearch:2.11.0
- ports:
- - 9200:9200
+ elasticsearch:
+ image: elasticsearch:7.17.25
env:
discovery.type: single-node
- plugins.security.disabled: true
- OPENSEARCH_JAVA_OPTS: -Xms512m -Xmx512m
- options: --health-cmd="curl http://localhost:9200/_cluster/health" --health-interval=10s --health-timeout=5s --health-retries=10
+ xpack.security.enabled: false
+ ES_JAVA_OPTS: "-Xms512m -Xmx512m"
+ ports:
+ - 9200:9200
+ options: --health-cmd="curl -s http://localhost:9200/_cluster/health" --health-interval=10s --health-timeout=5s --health-retries=10
steps:
- name: Checkout code
@@ -136,22 +136,103 @@ jobs:
bin/magento mageforge:theme:inspector --help
bin/magento mageforge:hyva:compatibility:check --help
bin/magento mageforge:hyva:tokens --help
+ bin/magento mageforge:theme:copy-from-vendor --help
echo "Verify command aliases work:"
- bin/magento m:s:v --help
- bin/magento m:s:c --help
- bin/magento m:t:l --help
- bin/magento m:t:b --help
- bin/magento m:t:w --help
- bin/magento m:t:c --help
- bin/magento m:h:c:c --help
bin/magento frontend:list --help
bin/magento frontend:build --help
bin/magento frontend:watch --help
bin/magento frontend:clean --help
+ bin/magento theme:copy --help
bin/magento hyva:check --help
bin/magento hyva:tokens --help
+ - name: Test Copy Command Functionality
+ working-directory: magento2
+ run: |
+ echo "Finding available test files..."
+
+ # Find a frontend template file from any core module
+ FRONTEND_FILE=$(find vendor/magento/module-*/view/frontend/templates -type f -name "*.phtml" 2>/dev/null | head -1)
+ if [ -z "$FRONTEND_FILE" ]; then
+ echo "No frontend template files found, trying layout files..."
+ FRONTEND_FILE=$(find vendor/magento/module-*/view/frontend/layout -type f -name "*.xml" 2>/dev/null | head -1)
+ fi
+
+ # Find a base template file
+ BASE_FILE=$(find vendor/magento/module-*/view/base/templates -type f -name "*.phtml" 2>/dev/null | head -1)
+ if [ -z "$BASE_FILE" ]; then
+ echo "No base template files found, trying web files..."
+ BASE_FILE=$(find vendor/magento/module-*/view/base/web -type f \( -name "*.js" -o -name "*.css" \) 2>/dev/null | head -1)
+ fi
+
+ # Find an etc file for negative test
+ ETC_FILE=$(find vendor/magento/module-catalog/etc -type f -name "*.xml" 2>/dev/null | head -1)
+
+ echo "Test files found:"
+ echo "Frontend: $FRONTEND_FILE"
+ echo "Base: $BASE_FILE"
+ echo "Etc: $ETC_FILE"
+ echo ""
+
+ if [ -n "$FRONTEND_FILE" ]; then
+ echo "Test 1: Copy frontend file to frontend theme (dry-run):"
+ bin/magento mageforge:theme:copy-from-vendor \
+ "$FRONTEND_FILE" \
+ Magento/luma \
+ --dry-run
+ echo "✓ Frontend to frontend mapping works"
+ echo ""
+ else
+ echo "Warning: No frontend file found for testing"
+ fi
+
+ if [ -n "$BASE_FILE" ]; then
+ echo "Test 2: Copy base file to frontend theme (dry-run):"
+ bin/magento mageforge:theme:copy-from-vendor \
+ "$BASE_FILE" \
+ Magento/blank \
+ --dry-run
+ echo "✓ Base to frontend mapping works"
+ echo ""
+ else
+ echo "Warning: No base file found for testing"
+ fi
+
+ if [ -n "$ETC_FILE" ]; then
+ echo "Test 3: Verify non-view file is rejected:"
+ if bin/magento mageforge:theme:copy-from-vendor \
+ "$ETC_FILE" \
+ Magento/luma \
+ --dry-run 2>&1 | grep -q "not under a view"; then
+ echo "✓ Non-view file correctly rejected"
+ else
+ echo "✗ Non-view file validation failed"
+ exit 1
+ fi
+ echo ""
+ else
+ echo "Warning: No etc file found for negative testing"
+ fi
+
+ if [ -n "$FRONTEND_FILE" ]; then
+ echo "Test 4: Verify cross-area mapping is rejected (frontend -> adminhtml):"
+ if bin/magento mageforge:theme:copy-from-vendor \
+ "$FRONTEND_FILE" \
+ Magento/backend \
+ --dry-run 2>&1 | grep -q "Cannot map file from area"; then
+ echo "✓ Cross-area mapping correctly rejected"
+ else
+ echo "✗ Cross-area validation failed"
+ exit 1
+ fi
+ echo ""
+ else
+ echo "Warning: No frontend file found for cross-area testing"
+ fi
+
+ echo "✓ All copy command tests passed!"
+
- name: Test Summary
run: |
echo "MageForge module compatibility test with Magento ${{ matrix.magento-version }} completed"
@@ -274,22 +355,103 @@ jobs:
bin/magento mageforge:theme:inspector --help
bin/magento mageforge:hyva:compatibility:check --help
bin/magento mageforge:hyva:tokens --help
+ bin/magento mageforge:theme:copy-from-vendor --help
echo "Verify command aliases work:"
- bin/magento m:s:v --help
- bin/magento m:s:c --help
- bin/magento m:t:l --help
- bin/magento m:t:b --help
- bin/magento m:t:w --help
- bin/magento m:t:c --help
- bin/magento m:h:c:c --help
bin/magento frontend:list --help
bin/magento frontend:build --help
bin/magento frontend:watch --help
bin/magento frontend:clean --help
+ bin/magento theme:copy --help
bin/magento hyva:check --help
bin/magento hyva:tokens --help
+ - name: Test Copy Command Functionality
+ working-directory: magento2
+ run: |
+ echo "Finding available test files..."
+
+ # Find a frontend template file from any core module
+ FRONTEND_FILE=$(find vendor/magento/module-*/view/frontend/templates -type f -name "*.phtml" 2>/dev/null | head -1)
+ if [ -z "$FRONTEND_FILE" ]; then
+ echo "No frontend template files found, trying layout files..."
+ FRONTEND_FILE=$(find vendor/magento/module-*/view/frontend/layout -type f -name "*.xml" 2>/dev/null | head -1)
+ fi
+
+ # Find a base template file
+ BASE_FILE=$(find vendor/magento/module-*/view/base/templates -type f -name "*.phtml" 2>/dev/null | head -1)
+ if [ -z "$BASE_FILE" ]; then
+ echo "No base template files found, trying web files..."
+ BASE_FILE=$(find vendor/magento/module-*/view/base/web -type f \( -name "*.js" -o -name "*.css" \) 2>/dev/null | head -1)
+ fi
+
+ # Find an etc file for negative test
+ ETC_FILE=$(find vendor/magento/module-catalog/etc -type f -name "*.xml" 2>/dev/null | head -1)
+
+ echo "Test files found:"
+ echo "Frontend: $FRONTEND_FILE"
+ echo "Base: $BASE_FILE"
+ echo "Etc: $ETC_FILE"
+ echo ""
+
+ if [ -n "$FRONTEND_FILE" ]; then
+ echo "Test 1: Copy frontend file to frontend theme (dry-run):"
+ bin/magento mageforge:theme:copy-from-vendor \
+ "$FRONTEND_FILE" \
+ Magento/luma \
+ --dry-run
+ echo "✓ Frontend to frontend mapping works"
+ echo ""
+ else
+ echo "Warning: No frontend file found for testing"
+ fi
+
+ if [ -n "$BASE_FILE" ]; then
+ echo "Test 2: Copy base file to frontend theme (dry-run):"
+ bin/magento mageforge:theme:copy-from-vendor \
+ "$BASE_FILE" \
+ Magento/blank \
+ --dry-run
+ echo "✓ Base to frontend mapping works"
+ echo ""
+ else
+ echo "Warning: No base file found for testing"
+ fi
+
+ if [ -n "$ETC_FILE" ]; then
+ echo "Test 3: Verify non-view file is rejected:"
+ if bin/magento mageforge:theme:copy-from-vendor \
+ "$ETC_FILE" \
+ Magento/luma \
+ --dry-run 2>&1 | grep -q "not under a view"; then
+ echo "✓ Non-view file correctly rejected"
+ else
+ echo "✗ Non-view file validation failed"
+ exit 1
+ fi
+ echo ""
+ else
+ echo "Warning: No etc file found for negative testing"
+ fi
+
+ if [ -n "$FRONTEND_FILE" ]; then
+ echo "Test 4: Verify cross-area mapping is rejected (frontend -> adminhtml):"
+ if bin/magento mageforge:theme:copy-from-vendor \
+ "$FRONTEND_FILE" \
+ Magento/backend \
+ --dry-run 2>&1 | grep -q "Cannot map file from area"; then
+ echo "✓ Cross-area mapping correctly rejected"
+ else
+ echo "✗ Cross-area validation failed"
+ exit 1
+ fi
+ echo ""
+ else
+ echo "Warning: No frontend file found for cross-area testing"
+ fi
+
+ echo "✓ All copy command tests passed!"
+
- name: Test Summary
run: |
echo "MageForge module compatibility test with Magento 2.4.8 completed"
diff --git a/Test/Unit/Service/VendorFileMapperTest.php b/Test/Unit/Service/VendorFileMapperTest.php
new file mode 100644
index 0000000..f02e0b4
--- /dev/null
+++ b/Test/Unit/Service/VendorFileMapperTest.php
@@ -0,0 +1,299 @@
+componentRegistrar = $this->createMock(ComponentRegistrarInterface::class);
+ $this->directoryList = $this->createMock(DirectoryList::class);
+ $this->directoryList->method('getRoot')->willReturn('/var/www/html/magento');
+
+ $this->vendorFileMapper = new VendorFileMapper(
+ $this->componentRegistrar,
+ $this->directoryList
+ );
+ }
+
+ /**
+ * Test mapping from module view/frontend to frontend theme
+ */
+ public function testMapFrontendFileToFrontendTheme(): void
+ {
+ $this->componentRegistrar->method('getPaths')
+ ->willReturn([
+ 'Magento_Catalog' => '/var/www/html/magento/vendor/magento/module-catalog'
+ ]);
+
+ $sourcePath = 'vendor/magento/module-catalog/view/frontend/templates/product/list.phtml';
+ $themePath = '/var/www/html/magento/app/design/frontend/Magento/luma';
+ $themeArea = 'frontend';
+
+ $result = $this->vendorFileMapper->mapToThemePath($sourcePath, $themePath, $themeArea);
+
+ $this->assertEquals(
+ '/var/www/html/magento/app/design/frontend/Magento/luma/Magento_Catalog/templates/product/list.phtml',
+ $result
+ );
+ }
+
+ /**
+ * Test mapping from module view/base to frontend theme (base is compatible)
+ */
+ public function testMapBaseFileToFrontendTheme(): void
+ {
+ $this->componentRegistrar->method('getPaths')
+ ->willReturn([
+ 'Magento_Theme' => '/var/www/html/magento/vendor/magento/module-theme'
+ ]);
+
+ $sourcePath = 'vendor/magento/module-theme/view/base/web/css/styles.css';
+ $themePath = '/var/www/html/magento/app/design/frontend/Magento/luma';
+
+ $result = $this->vendorFileMapper->mapToThemePath($sourcePath, $themePath);
+
+ $this->assertEquals(
+ '/var/www/html/magento/app/design/frontend/Magento/luma/Magento_Theme/web/css/styles.css',
+ $result
+ );
+ }
+
+ /**
+ * Test mapping from module view/adminhtml to adminhtml theme
+ */
+ public function testMapAdminhtmlFileToAdminhtmlTheme(): void
+ {
+ $this->componentRegistrar->method('getPaths')
+ ->willReturn([
+ 'Magento_Backend' => '/var/www/html/magento/vendor/magento/module-backend'
+ ]);
+
+ $sourcePath = 'vendor/magento/module-backend/view/adminhtml/templates/dashboard.phtml';
+ $themePath = '/var/www/html/magento/app/design/adminhtml/Magento/backend';
+
+ $result = $this->vendorFileMapper->mapToThemePath($sourcePath, $themePath);
+
+ $this->assertEquals(
+ '/var/www/html/magento/app/design/adminhtml/Magento/backend/Magento_Backend/templates/dashboard.phtml',
+ $result
+ );
+ }
+
+ /**
+ * Test mapping from module view/base to adminhtml theme (base is compatible)
+ */
+ public function testMapBaseFileToAdminhtmlTheme(): void
+ {
+ $this->componentRegistrar->method('getPaths')
+ ->willReturn([
+ 'Magento_Ui' => '/var/www/html/magento/vendor/magento/module-ui'
+ ]);
+
+ $sourcePath = 'vendor/magento/module-ui/view/base/web/js/grid/columns/column.js';
+ $themePath = '/var/www/html/magento/app/design/adminhtml/Magento/backend';
+
+ $result = $this->vendorFileMapper->mapToThemePath($sourcePath, $themePath);
+
+ $this->assertEquals(
+ '/var/www/html/magento/app/design/adminhtml/Magento/backend/Magento_Ui/web/js/grid/columns/column.js',
+ $result
+ );
+ }
+
+ /**
+ * Test that adminhtml files cannot be mapped to frontend themes
+ */
+ public function testAdminhtmlFileToFrontendThemeThrowsException(): void
+ {
+ $this->componentRegistrar->method('getPaths')
+ ->willReturn([
+ 'Magento_Backend' => '/var/www/html/magento/vendor/magento/module-backend'
+ ]);
+
+ $sourcePath = 'vendor/magento/module-backend/view/adminhtml/templates/dashboard.phtml';
+ $themePath = '/var/www/html/magento/app/design/frontend/Magento/luma';
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage("Cannot map file from area 'adminhtml' to frontend theme");
+
+ $this->vendorFileMapper->mapToThemePath($sourcePath, $themePath);
+ }
+
+ /**
+ * Test that frontend files cannot be mapped to adminhtml themes
+ */
+ public function testFrontendFileToAdminhtmlThemeThrowsException(): void
+ {
+ $this->componentRegistrar->method('getPaths')
+ ->willReturn([
+ 'Magento_Catalog' => '/var/www/html/magento/vendor/magento/module-catalog'
+ ]);
+
+ $sourcePath = 'vendor/magento/module-catalog/view/frontend/templates/product/list.phtml';
+ $themePath = '/var/www/html/magento/app/design/adminhtml/Magento/backend';
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage("Cannot map file from area 'frontend' to adminhtml theme");
+
+ $this->vendorFileMapper->mapToThemePath($sourcePath, $themePath);
+ }
+
+ /**
+ * Test that files outside view/ directory throw exception
+ */
+ public function testNonViewFileThrowsException(): void
+ {
+ $this->componentRegistrar->method('getPaths')
+ ->willReturn([
+ 'Magento_Catalog' => '/var/www/html/magento/vendor/magento/module-catalog'
+ ]);
+
+ $sourcePath = 'vendor/magento/module-catalog/etc/di.xml';
+ $themePath = '/var/www/html/magento/app/design/frontend/Magento/luma';
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage("File is not under a view/ directory");
+
+ $this->vendorFileMapper->mapToThemePath($sourcePath, $themePath);
+ }
+
+ /**
+ * Test nested module pattern (e.g., from Hyva compatibility modules)
+ */
+ public function testNestedModulePattern(): void
+ {
+ $this->componentRegistrar->method('getPaths')
+ ->willReturn([]);
+
+ $sourcePath = 'vendor/hyva-themes/magento2-hyva-checkout/src/view/frontend/Magento_Checkout/templates/cart.phtml';
+ $themePath = '/var/www/html/magento/app/design/frontend/Hyva/default';
+
+ $result = $this->vendorFileMapper->mapToThemePath($sourcePath, $themePath);
+
+ $this->assertEquals(
+ '/var/www/html/magento/app/design/frontend/Hyva/default/Magento_Checkout/templates/cart.phtml',
+ $result
+ );
+ }
+
+ /**
+ * Test nested module pattern with area validation
+ */
+ public function testNestedModulePatternWithWrongArea(): void
+ {
+ $this->componentRegistrar->method('getPaths')
+ ->willReturn([]);
+
+ $sourcePath = 'vendor/some-vendor/module/src/view/adminhtml/Magento_Backend/templates/test.phtml';
+ $themePath = '/var/www/html/magento/app/design/frontend/Magento/luma';
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage("Cannot map file from area 'adminhtml' to frontend theme");
+
+ $this->vendorFileMapper->mapToThemePath($sourcePath, $themePath);
+ }
+
+ /**
+ * Test absolute path normalization
+ */
+ public function testAbsolutePathNormalization(): void
+ {
+ $this->componentRegistrar->method('getPaths')
+ ->willReturn([
+ 'Magento_Catalog' => '/var/www/html/magento/vendor/magento/module-catalog'
+ ]);
+
+ $sourcePath = '/var/www/html/magento/vendor/magento/module-catalog/view/frontend/templates/product/list.phtml';
+ $themePath = '/var/www/html/magento/app/design/frontend/Magento/luma';
+
+ $result = $this->vendorFileMapper->mapToThemePath($sourcePath, $themePath);
+
+ $this->assertEquals(
+ '/var/www/html/magento/app/design/frontend/Magento/luma/Magento_Catalog/templates/product/list.phtml',
+ $result
+ );
+ }
+
+ /**
+ * Test Hyvä theme with base file
+ */
+ public function testHyvaThemeWithBaseFile(): void
+ {
+ $this->componentRegistrar->method('getPaths')
+ ->willReturn([
+ 'Hyva_Theme' => '/var/www/html/magento/vendor/hyva-themes/magento2-default-theme'
+ ]);
+
+ $sourcePath = 'vendor/hyva-themes/magento2-default-theme/view/base/web/tailwind/tailwind.css';
+ $themePath = '/var/www/html/magento/app/design/frontend/Hyva/default';
+
+ $result = $this->vendorFileMapper->mapToThemePath($sourcePath, $themePath);
+
+ $this->assertEquals(
+ '/var/www/html/magento/app/design/frontend/Hyva/default/Hyva_Theme/web/tailwind/tailwind.css',
+ $result
+ );
+ }
+
+ /**
+ * Test custom theme (Tailwind-based without Hyvä)
+ */
+ public function testCustomTailwindTheme(): void
+ {
+ $this->componentRegistrar->method('getPaths')
+ ->willReturn([
+ 'Magento_Theme' => '/var/www/html/magento/vendor/magento/module-theme'
+ ]);
+
+ $sourcePath = 'vendor/magento/module-theme/view/frontend/layout/default.xml';
+ $themePath = '/var/www/html/magento/app/design/frontend/Custom/tailwind';
+
+ $result = $this->vendorFileMapper->mapToThemePath($sourcePath, $themePath);
+
+ $this->assertEquals(
+ '/var/www/html/magento/app/design/frontend/Custom/tailwind/Magento_Theme/layout/default.xml',
+ $result
+ );
+ }
+
+ /**
+ * Test that theme path without area throws exception
+ */
+ public function testThemePathWithoutAreaThrowsException(): void
+ {
+ $this->componentRegistrar->method('getPaths')
+ ->willReturn([
+ 'Magento_Catalog' => '/var/www/html/magento/vendor/magento/module-catalog'
+ ]);
+
+ $sourcePath = 'vendor/magento/module-catalog/view/frontend/templates/test.phtml';
+ $themePath = '/var/www/html/magento/app/design/Magento/luma'; // Missing frontend/adminhtml
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage("Could not determine theme area from path");
+
+ $this->vendorFileMapper->mapToThemePath($sourcePath, $themePath);
+ }
+}
diff --git a/docs/testing_copy_command.md b/docs/testing_copy_command.md
new file mode 100644
index 0000000..ac39b7a
--- /dev/null
+++ b/docs/testing_copy_command.md
@@ -0,0 +1,309 @@
+# Testing Guide: Copy From Vendor Command
+
+This guide describes how to test the `mageforge:theme:copy-from-vendor` command with different theme types and scenarios.
+
+## Automated Tests
+
+Run unit tests:
+
+```bash
+ddev magento dev:tests:run unit vendor/openforgeproject/mageforge/Test/Unit/Service/VendorFileMapperTest.php
+```
+
+## Manual Testing Scenarios
+
+### Prerequisites
+
+```bash
+ddev start
+ddev magento cache:clean
+ddev magento setup:upgrade
+```
+
+### 1. Frontend Theme Tests
+
+#### Test 1.1: Copy to Magento/luma (Standard Frontend Theme)
+
+```bash
+# Test with view/frontend file
+ddev magento mageforge:theme:copy-from-vendor \
+ vendor/magento/module-catalog/view/frontend/templates/product/list.phtml \
+ Magento/luma \
+ --dry-run
+
+# Expected: app/design/frontend/Magento/luma/Magento_Catalog/templates/product/list.phtml
+```
+
+#### Test 1.2: Copy to Magento/blank (Standard Frontend Theme)
+
+```bash
+# Test with view/base file (should work with frontend theme)
+ddev magento mageforge:theme:copy-from-vendor \
+ vendor/magento/module-theme/view/base/web/css/print.css \
+ Magento/blank \
+ --dry-run
+
+# Expected: app/design/frontend/Magento/blank/Magento_Theme/web/css/print.css
+```
+
+#### Test 1.3: Copy to Hyvä Theme (if available)
+
+```bash
+# Test with Hyvä-specific file
+ddev magento mageforge:theme:copy-from-vendor \
+ vendor/hyva-themes/magento2-default-theme/Magento_Catalog/templates/product/list/item.phtml \
+ Hyva/default \
+ --dry-run
+
+# Expected: app/design/frontend/Hyva/default/Magento_Catalog/templates/product/list/item.phtml
+```
+
+### 2. Adminhtml Theme Tests
+
+#### Test 2.1: Copy to Adminhtml Theme
+
+```bash
+# Test with view/adminhtml file
+ddev magento mageforge:theme:copy-from-vendor \
+ vendor/magento/module-backend/view/adminhtml/templates/page/header.phtml \
+ Magento/backend \
+ --dry-run
+
+# Expected: app/design/adminhtml/Magento/backend/Magento_Backend/templates/page/header.phtml
+```
+
+#### Test 2.2: Copy base file to Adminhtml Theme
+
+```bash
+# Test with view/base file (should work with adminhtml theme)
+ddev magento mageforge:theme:copy-from-vendor \
+ vendor/magento/module-ui/view/base/web/js/grid/columns/column.js \
+ Magento/backend \
+ --dry-run
+
+# Expected: app/design/adminhtml/Magento/backend/Magento_Ui/web/js/grid/columns/column.js
+```
+
+### 3. Negative Tests (Should Fail)
+
+#### Test 3.1: Cross-Area Mapping (Frontend → Adminhtml)
+
+```bash
+# This should FAIL with clear error message
+ddev magento mageforge:theme:copy-from-vendor \
+ vendor/magento/module-catalog/view/frontend/templates/product/list.phtml \
+ Magento/backend \
+ --dry-run
+
+# Expected: RuntimeException - "Cannot map file from area 'frontend' to adminhtml theme"
+```
+
+#### Test 3.2: Cross-Area Mapping (Adminhtml → Frontend)
+
+```bash
+# This should FAIL with clear error message
+ddev magento mageforge:theme:copy-from-vendor \
+ vendor/magento/module-backend/view/adminhtml/templates/dashboard.phtml \
+ Magento/luma \
+ --dry-run
+
+# Expected: RuntimeException - "Cannot map file from area 'adminhtml' to frontend theme"
+```
+
+#### Test 3.3: Non-View File
+
+```bash
+# This should FAIL with clear error message
+ddev magento mageforge:theme:copy-from-vendor \
+ vendor/magento/module-catalog/etc/di.xml \
+ Magento/luma \
+ --dry-run
+
+# Expected: RuntimeException - "File is not under a view/ directory"
+```
+
+#### Test 3.4: Non-Existent File
+
+```bash
+# This should FAIL with clear error message
+ddev magento mageforge:theme:copy-from-vendor \
+ vendor/magento/module-catalog/view/frontend/templates/nonexistent.phtml \
+ Magento/luma \
+ --dry-run
+
+# Expected: RuntimeException - "Source file not found"
+```
+
+### 4. Interactive Mode Tests
+
+#### Test 4.1: Theme Selection Prompt
+
+```bash
+# Test interactive theme selection (omit theme argument)
+ddev magento mageforge:theme:copy-from-vendor \
+ vendor/magento/module-catalog/view/frontend/templates/product/view.phtml \
+ --dry-run
+
+# Expected: Interactive prompt to select theme
+# Verify all available themes are listed
+# Verify search functionality works
+```
+
+### 5. Real Copy Tests (Without --dry-run)
+
+**⚠️ Warning: These tests will actually modify files**
+
+#### Test 5.1: Create New File
+
+```bash
+# Copy a file that doesn't exist in theme yet
+ddev magento mageforge:theme:copy-from-vendor \
+ vendor/magento/module-catalog/view/frontend/templates/product/list/toolbar.phtml \
+ Magento/luma
+
+# Verify:
+# 1. File created at correct location
+# 2. Directory structure created if needed
+# 3. Success message displayed
+```
+
+#### Test 5.2: Overwrite Existing File
+
+```bash
+# Copy to same location again
+ddev magento mageforge:theme:copy-from-vendor \
+ vendor/magento/module-catalog/view/frontend/templates/product/list/toolbar.phtml \
+ Magento/luma
+
+# Verify:
+# 1. Warning about existing file
+# 2. Confirmation prompt appears
+# 3. File overwritten only if confirmed
+```
+
+#### Test 5.3: Cleanup After Tests
+
+```bash
+# Remove test files
+rm -f app/design/frontend/Magento/luma/Magento_Catalog/templates/product/list/toolbar.phtml
+
+# Clear cache
+ddev magento cache:clean
+```
+
+### 6. Theme Type Verification
+
+#### Test 6.1: Verify Theme Types are Correctly Identified
+
+```bash
+# List all themes with their types
+ddev magento mageforge:theme:list
+
+# Expected output should show:
+# - Theme code
+# - Area (frontend/adminhtml)
+# - Path
+# - Builder type (if shown)
+```
+
+#### Test 6.2: Verify Theme Path Resolution
+
+```bash
+# Check system info
+ddev magento mageforge:system:check
+
+# Verify theme registration is working correctly
+ddev magento theme:list
+```
+
+## Test Matrix
+
+| Source Area | Target Theme Area | Expected Result |
+|-------------|-------------------|-----------------|
+| frontend | frontend | ✅ Success |
+| frontend | adminhtml | ❌ Exception |
+| adminhtml | frontend | ❌ Exception |
+| adminhtml | adminhtml | ✅ Success |
+| base | frontend | ✅ Success |
+| base | adminhtml | ✅ Success |
+| etc/ | frontend | ❌ Exception |
+| etc/ | adminhtml | ❌ Exception |
+
+## CI/CD Integration
+
+The command should be tested in CI/CD pipeline:
+
+```yaml
+# Add to .github/workflows/magento-compatibility.yml
+- name: Test Copy Command
+ run: |
+ # Test basic functionality
+ bin/magento mageforge:theme:copy-from-vendor --help
+
+ # Test dry-run mode
+ bin/magento mageforge:theme:copy-from-vendor \
+ vendor/magento/module-catalog/view/frontend/templates/product/list.phtml \
+ Magento/luma \
+ --dry-run
+
+ # Test error handling
+ if bin/magento mageforge:theme:copy-from-vendor \
+ vendor/magento/module-catalog/etc/di.xml \
+ Magento/luma \
+ --dry-run 2>&1 | grep -q "not under a view"; then
+ echo "✓ Non-view file correctly rejected"
+ else
+ echo "✗ Non-view file validation failed"
+ exit 1
+ fi
+```
+
+## Troubleshooting
+
+### Issue: Theme not found
+
+**Solution**: Verify theme is registered:
+```bash
+ddev magento theme:list
+ddev magento mageforge:theme:list
+```
+
+### Issue: Wrong path mapping
+
+**Solution**: Check VendorFileMapper logic with verbose output or unit tests
+
+### Issue: Permission denied
+
+**Solution**: Check file permissions:
+```bash
+ddev exec chmod -R 775 app/design/
+```
+
+## Performance Testing
+
+For large files or batch operations:
+
+```bash
+# Time the operation
+time ddev magento mageforge:theme:copy-from-vendor \
+ vendor/magento/module-catalog/view/frontend/layout/catalog_product_view.xml \
+ Magento/luma \
+ --dry-run
+
+# Verify memory usage is reasonable
+```
+
+## Continuous Validation
+
+After each deployment or environment update:
+
+```bash
+# Run automated tests
+ddev magento dev:tests:run unit vendor/openforgeproject/mageforge/Test/
+
+# Run smoke test
+ddev magento mageforge:theme:copy-from-vendor \
+ vendor/magento/module-theme/view/frontend/templates/page/copyright.phtml \
+ Magento/luma \
+ --dry-run
+```
diff --git a/src/Console/Command/Theme/CopyFromVendorCommand.php b/src/Console/Command/Theme/CopyFromVendorCommand.php
new file mode 100644
index 0000000..a984610
--- /dev/null
+++ b/src/Console/Command/Theme/CopyFromVendorCommand.php
@@ -0,0 +1,322 @@
+setName($this->getCommandName('theme', 'copy-from-vendor'))
+ ->setDescription('Copy a file from vendor/ to a specific theme with correct path resolution')
+ ->setAliases(['theme:copy'])
+ ->addArgument('file', InputArgument::REQUIRED, 'Path to the source file (vendor/...)')
+ ->addArgument('theme', InputArgument::OPTIONAL, 'Target theme code (e.g. Magento/luma)')
+ ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Preview the copy operation without performing it');
+ }
+
+ /**
+ * Execute the command
+ *
+ * @param InputInterface $input
+ * @param OutputInterface $output
+ * @return int
+ */
+ protected function executeCommand(InputInterface $input, OutputInterface $output): int
+ {
+ $sourceFileArg = $input->getArgument('file');
+ $isDryRun = $input->getOption('dry-run');
+ $absoluteSourcePath = $this->getAbsoluteSourcePath($sourceFileArg);
+
+ // Update sourceFileArg if it was normalized to relative path
+ $rootPath = $this->directoryList->getRoot();
+ $sourceFile = str_starts_with($absoluteSourcePath, $rootPath . '/')
+ ? substr($absoluteSourcePath, strlen($rootPath) + 1)
+ : $sourceFileArg;
+
+ $themeCode = $this->getThemeCode($input);
+ [$themePath, $themeArea] = $this->getThemePathAndArea($themeCode);
+
+ $destinationPath = $this->vendorFileMapper->mapToThemePath($sourceFile, $themePath, $themeArea);
+ $absoluteDestPath = $this->getAbsoluteDestPath($destinationPath, $rootPath);
+
+ if ($isDryRun) {
+ $this->showDryRunPreview($sourceFile, $absoluteDestPath, $rootPath);
+ return Cli::RETURN_SUCCESS;
+ }
+
+ if (!$this->confirmCopy($sourceFile, $absoluteDestPath, $rootPath)) {
+ return Cli::RETURN_SUCCESS;
+ }
+
+ $this->performCopy($absoluteSourcePath, $absoluteDestPath);
+ $this->io->success("File copied successfully.");
+
+ return Cli::RETURN_SUCCESS;
+ }
+
+ /**
+ * Get absolute source path
+ *
+ * @param string $sourceFile
+ * @return string
+ * @throws \RuntimeException
+ */
+ private function getAbsoluteSourcePath(string $sourceFile): string
+ {
+ $rootPath = $this->directoryList->getRoot();
+ if (str_starts_with($sourceFile, '/')) {
+ $absoluteSourcePath = $sourceFile;
+ } else {
+ $absoluteSourcePath = $rootPath . '/' . $sourceFile;
+ }
+
+ if (!$this->fileDriver->isExists($absoluteSourcePath)) {
+ throw new \RuntimeException("Source file not found: $absoluteSourcePath");
+ }
+
+ return $absoluteSourcePath;
+ }
+
+ /**
+ * Get theme code
+ *
+ * @param InputInterface $input
+ * @return string
+ * @throws \RuntimeException
+ */
+ private function getThemeCode(InputInterface $input): string
+ {
+ $themeCode = $input->getArgument('theme');
+ if ($themeCode) {
+ return $themeCode;
+ }
+
+ $themes = $this->themeList->getAllThemes();
+ $options = [];
+ foreach ($themes as $theme) {
+ $options[$theme->getCode()] = $theme->getCode();
+ }
+
+ if (empty($options)) {
+ throw new \RuntimeException('No themes found to copy to.');
+ }
+
+ if (!$input->isInteractive()) {
+ $themeList = implode(', ', array_keys($options));
+ throw new \RuntimeException(
+ "Theme argument is missing. Available themes: $themeList"
+ );
+ }
+
+ $this->fixPromptEnvironment();
+
+ try {
+ $result = search(
+ label: 'Select target theme',
+ options: fn (string $value) => array_filter(
+ $options,
+ fn ($option) => str_contains(strtolower($option), strtolower($value))
+ ),
+ placeholder: 'Search for a theme...'
+ );
+ \Laravel\Prompts\Prompt::terminal()->restoreTty();
+ return (string) $result;
+ } finally {
+ $this->resetPromptEnvironment();
+ }
+ }
+
+ /**
+ * Get theme path and area from theme code
+ *
+ * @param string $themeCode
+ * @return array{0: string, 1: string} [themePath, themeArea]
+ */
+ private function getThemePathAndArea(string $themeCode): array
+ {
+ $theme = $this->themeList->getThemeByCode($themeCode);
+ if (!$theme) {
+ throw new \RuntimeException("Theme not found: $themeCode");
+ }
+
+ $themeArea = $theme->getArea();
+ $regName = $themeArea . '/' . $theme->getCode();
+ $themePath = $this->componentRegistrar->getPath(ComponentRegistrar::THEME, $regName);
+
+ if (!$themePath) {
+ $this->io->warning(
+ "Theme path not found via ComponentRegistrar for $regName, falling back to getFullPath()"
+ );
+ $themePath = $theme->getFullPath();
+ }
+
+ return [$themePath, $themeArea];
+ }
+
+ /**
+ * Get absolute destination path
+ *
+ * @param string $destinationPath
+ * @param string $rootPath
+ * @return string
+ */
+ private function getAbsoluteDestPath(string $destinationPath, string $rootPath): string
+ {
+ if (str_starts_with($destinationPath, '/')) {
+ return $destinationPath;
+ }
+ return $rootPath . '/' . $destinationPath;
+ }
+
+ /**
+ * Confirm copy operation
+ *
+ * @param string $sourceFile
+ * @param string $absoluteDestPath
+ * @param string $rootPath
+ * @return bool
+ */
+ private function confirmCopy(string $sourceFile, string $absoluteDestPath, string $rootPath): bool
+ {
+ $destinationDisplay = str_starts_with($absoluteDestPath, $rootPath . '/')
+ ? substr($absoluteDestPath, strlen($rootPath) + 1)
+ : $absoluteDestPath;
+
+ $this->io->section('Copy Preview');
+ $this->io->text([
+ "Source: $sourceFile",
+ "Target: $destinationDisplay",
+ "Absolute Target: $absoluteDestPath"
+ ]);
+ $this->io->newLine();
+
+ $this->setPromptEnvironment();
+
+ try {
+ if ($this->fileDriver->isExists($absoluteDestPath)) {
+ $this->io->warning("File already exists at destination!");
+ $result = confirm(
+ label: 'Overwrite existing file?',
+ default: false
+ );
+ \Laravel\Prompts\Prompt::terminal()->restoreTty();
+ $this->resetPromptEnvironment();
+ return $result;
+ }
+
+ $result = confirm(
+ label: 'Proceed with copy?',
+ default: true
+ );
+ \Laravel\Prompts\Prompt::terminal()->restoreTty();
+ $this->resetPromptEnvironment();
+ return $result;
+ } catch (\Exception $e) {
+ $this->resetPromptEnvironment();
+ $this->io->error('Interactive mode failed: ' . $e->getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Perform copy operation
+ *
+ * @param string $absoluteSourcePath
+ * @param string $absoluteDestPath
+ * @return void
+ * @throws \RuntimeException
+ */
+ private function performCopy(string $absoluteSourcePath, string $absoluteDestPath): void
+ {
+ $directory = $this->fileDriver->getParentDirectory($absoluteDestPath);
+ if (!$this->fileDriver->isDirectory($directory)) {
+ $this->fileDriver->createDirectory($directory);
+ }
+ $this->fileDriver->copy($absoluteSourcePath, $absoluteDestPath);
+ }
+
+ /**
+ * Show dry run preview
+ *
+ * @param string $sourceFile
+ * @param string $absoluteDestPath
+ * @param string $rootPath
+ * @return void
+ */
+ private function showDryRunPreview(string $sourceFile, string $absoluteDestPath, string $rootPath): void
+ {
+ $destinationDisplay = str_starts_with($absoluteDestPath, $rootPath . '/')
+ ? substr($absoluteDestPath, strlen($rootPath) + 1)
+ : $absoluteDestPath;
+
+ $this->io->section('Dry Run - Copy Preview');
+ $this->io->text([
+ "Source: $sourceFile",
+ "Target: $destinationDisplay",
+ "Absolute Target: $absoluteDestPath"
+ ]);
+ $this->io->newLine();
+
+ if ($this->fileDriver->isExists($absoluteDestPath)) {
+ $this->io->warning("File already exists at destination and would be overwritten!");
+ } else {
+ $this->io->info("File would be created at destination.");
+ }
+
+ $this->io->note("No files were modified (dry-run mode).");
+ }
+
+ /**
+ * Fix prompt environment
+ *
+ * @return void
+ */
+ private function fixPromptEnvironment(): void
+ {
+ $this->setPromptEnvironment();
+ }
+}
diff --git a/src/Model/ThemeList.php b/src/Model/ThemeList.php
index f08a8b8..dc82839 100644
--- a/src/Model/ThemeList.php
+++ b/src/Model/ThemeList.php
@@ -27,4 +27,21 @@ public function getAllThemes(): array
{
return $this->magentoThemeList->getItems();
}
+
+ /**
+ * Get theme by code
+ *
+ * @param string $code Theme code (e.g., 'Magento/luma')
+ * @return \Magento\Framework\View\Design\ThemeInterface|null
+ */
+ public function getThemeByCode(string $code): ?\Magento\Framework\View\Design\ThemeInterface
+ {
+ $themes = $this->getAllThemes();
+ foreach ($themes as $theme) {
+ if ($theme->getCode() === $code) {
+ return $theme;
+ }
+ }
+ return null;
+ }
}
diff --git a/src/Service/VendorFileMapper.php b/src/Service/VendorFileMapper.php
new file mode 100644
index 0000000..d7440d3
--- /dev/null
+++ b/src/Service/VendorFileMapper.php
@@ -0,0 +1,410 @@
+extractThemeArea($themePath);
+
+ // 2. Normalize: Ensure $sourcePath is relative from Magento Root if it's absolute
+ $rootPath = rtrim($this->directoryList->getRoot(), '/');
+ if (str_starts_with($sourcePath, $rootPath . '/')) {
+ $sourcePath = substr($sourcePath, strlen($rootPath) + 1);
+ }
+
+ // 3. Detect "Standard Module" Pattern (Priority 1) - Best for Local Modules & Composer Packages
+ $modules = $this->componentRegistrar->getPaths(ComponentRegistrar::MODULE);
+ foreach ($modules as $moduleName => $path) {
+ // Normalize module path relative to root
+ if (str_starts_with($path, $rootPath . '/')) {
+ $path = substr($path, strlen($rootPath) + 1);
+ }
+
+ // Check if source starts with this module path
+ if (str_starts_with($sourcePath, $path . '/')) {
+ $pathInsideModule = substr($sourcePath, strlen($path) + 1);
+
+ // Validate area and extract clean path
+ $cleanPath = $this->validateAndExtractViewPath($pathInsideModule, $themeArea, $sourcePath);
+
+ // Priority 1A: Check if this is a Hyva Compatibility Module
+ // If so, map to its registered "original_module"
+ $originalModule = $this->getOriginalModuleFromCompatRegistry($moduleName);
+
+ if ($originalModule) {
+ // Start with clean path (e.g. templates/Original_Module/foo.phtml or templates/foo.phtml)
+ $targetPath = ltrim($cleanPath, '/');
+
+ // If path contains the Original Module name as a subdirectory (Hyva convention), strip it
+ // Example: templates/Mollie_Payment/foo.phtml -> templates/foo.phtml
+ // This prevents Theme/Mollie_Payment/templates/Mollie_Payment/foo.phtml
+ // Note: Check both strict and case-insensitive to be safe
+ if (str_contains($targetPath, '/' . $originalModule . '/')) {
+ $targetPath = str_replace('/' . $originalModule . '/', '/', $targetPath);
+ } elseif (stripos($targetPath, '/' . $originalModule . '/') !== false) {
+ // Case-insensitive replacement if strict failed
+ $targetPath = str_ireplace('/' . $originalModule . '/', '/', $targetPath);
+ }
+
+ return rtrim($themePath, '/') . '/' . $originalModule . '/' . $targetPath;
+ }
+
+ return rtrim($themePath, '/') . '/' . $moduleName . '/' . ltrim($cleanPath, '/');
+ }
+ }
+
+ // 4. Detect "Nested Module" Pattern (Priority 2) - Works for Hyva Compat & Vendor Themes
+ // Regex search for a segment matching Vendor_Module (e.g. Magento_Catalog).
+ // Captures (Group 1): "Vendor_Module"
+ if (preg_match('/([A-Z][a-zA-Z0-9]*_[A-Z][a-zA-Z0-9]*)/', $sourcePath, $matches, PREG_OFFSET_CAPTURE)) {
+ $offset = $matches[1][1];
+
+ // Extract from Vendor_Module onwards (e.g. "Mollie_Payment/templates/file.phtml")
+ $relativePath = substr($sourcePath, $offset);
+
+ // Validate that this path contains a valid view area
+ // Extract the part after Vendor_Module to check
+ $parts = explode('/', $relativePath, 3);
+ if (count($parts) >= 3 && $parts[1] === 'view') {
+ // Format: Vendor_Module/view/{area}/...
+ $area = $parts[2];
+ if (!$this->isAreaCompatible($area, $themeArea)) {
+ throw new RuntimeException(
+ sprintf(
+ "Cannot map file from area '%s' to %s theme. File: %s",
+ $area,
+ $themeArea,
+ $sourcePath
+ )
+ );
+ }
+ }
+
+ return rtrim($themePath, '/') . '/' . ltrim($relativePath, '/');
+ }
+
+ // 5. Fallback
+ throw new RuntimeException("Could not determine target module or theme structure for file: " . $sourcePath);
+ }
+
+ /**
+ * Extract theme area from theme path
+ *
+ * @param string $themePath
+ * @return string
+ * @throws RuntimeException
+ */
+ private function extractThemeArea(string $themePath): string
+ {
+ if (preg_match('#/(frontend|adminhtml)/#', $themePath, $matches)) {
+ return $matches[1];
+ }
+
+ throw new RuntimeException("Could not determine theme area from path: " . $themePath);
+ }
+
+ /**
+ * Validate that the path is under view/{area}/ and compatible with target theme area
+ *
+ * @param string $pathInsideModule
+ * @param string $targetArea
+ * @param string $originalPath
+ * @return string Clean path without view/{area}/ prefix
+ * @throws RuntimeException
+ */
+ private function validateAndExtractViewPath(
+ string $pathInsideModule,
+ string $targetArea,
+ string $originalPath
+ ): string {
+ // Check if path starts with view/{area}/
+ if (!preg_match('#^view/([^/]+)/#', $pathInsideModule, $matches)) {
+ throw new RuntimeException(
+ sprintf(
+ "File is not under a view/ directory. " .
+ "Only files under view/{area}/ can be mapped to themes. File: %s",
+ $originalPath
+ )
+ );
+ }
+
+ $sourceArea = $matches[1];
+
+ // Validate area compatibility
+ if (!$this->isAreaCompatible($sourceArea, $targetArea)) {
+ throw new RuntimeException(
+ sprintf(
+ "Cannot map file from area '%s' to %s theme. File: %s",
+ $sourceArea,
+ $targetArea,
+ $originalPath
+ )
+ );
+ }
+
+ // Remove view/{area}/ prefix
+ return (string) preg_replace('#^view/[^/]+/#', '', $pathInsideModule);
+ }
+
+ /**
+ * Check if source area is compatible with target theme area
+ *
+ * @param string $sourceArea
+ * @param string $targetArea
+ * @return bool
+ */
+ private function isAreaCompatible(string $sourceArea, string $targetArea): bool
+ {
+ // Exact match
+ if ($sourceArea === $targetArea) {
+ return true;
+ }
+
+ // 'base' area is compatible with both frontend and adminhtml
+ if ($sourceArea === 'base') {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if module is a registered Hyva compatibility module and retrieve its original module.
+ *
+ * @param string $compatModuleName
+ * @return string|null
+ * @phpcs:disable Magento2.PHP.LiteralNamespaces.LiteralClassUsage
+ */
+ private function getOriginalModuleFromCompatRegistry(string $compatModuleName): ?string
+ {
+ $registryClass = 'Hyva\CompatModuleFallback\Model\CompatModuleRegistry';
+ //phpcs:enable
+
+ // 1. Try Registry (via Emulated Area)
+ if (!class_exists($registryClass)) {
+ return $this->parseCompatModuleXml($compatModuleName);
+ }
+
+ try {
+ // Emulate frontend area to load proper DI configuration for CompatModuleRegistry
+ // as CLI commands run in global scope where frontend/di.xml is ignored.
+ /** @var mixed|\Hyva\CompatModuleFallback\Model\CompatModuleRegistry|null $registry */
+ $registry = $this->appState->emulateAreaCode(
+ Area::AREA_FRONTEND,
+ function () use ($registryClass) {
+ // Use create() to ensure we get a fresh instance with the emulated configuration.
+ // get() might return a cached instance from global scope (empty).
+ return $this->objectManager->create($registryClass);
+ }
+ );
+
+ if ($registry) {
+ return $this->findOriginalModuleInRegistry($registry, $compatModuleName);
+ }
+ } catch (\Throwable $e) {
+ // Ignore errors here, continue to manual parsing
+ unset($e);
+ }
+
+ // 2. Fallback: Manual XML parsing because CLI execution might miss frontend/di.xml config
+ return $this->parseCompatModuleXml($compatModuleName);
+ }
+
+ /**
+ * Find original module in registry
+ *
+ * @param mixed $registry Hyva\CompatModuleFallback\Model\CompatModuleRegistry
+ * @param string $compatModuleName
+ * @return string|null
+ */
+ private function findOriginalModuleInRegistry($registry, string $compatModuleName): ?string
+ {
+ // Iterate through original modules to find if current module is a registered compat module
+ // We call getOrigModules inside the emulation callback ideally, but here we got the object
+ /** @var mixed $mixedRegistry */
+ $mixedRegistry = $registry;
+ foreach ($mixedRegistry->getOrigModules() as $originalModule) {
+ // Get compat modules for this original module
+ $compatModules = $mixedRegistry->getCompatModulesFor($originalModule);
+
+ // Check exact match first
+ if (in_array($compatModuleName, $compatModules, true)) {
+ return $originalModule;
+ }
+
+ // Fallback: Case-insensitive check
+ foreach ($compatModules as $compatModule) {
+ if (strnatcasecmp($compatModuleName, $compatModule) === 0) {
+ return $originalModule;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Manual fallback to parse etc/frontend/di.xml for compatibility registration.
+ *
+ * Use DOMDocument for robust XML parsing.
+ *
+ * @param string $moduleName
+ * @return string|null
+ */
+ private function parseCompatModuleXml(string $moduleName): ?string
+ {
+ try {
+ $path = $this->componentRegistrar->getPath(ComponentRegistrar::MODULE, $moduleName);
+ if (!$path) {
+ return null;
+ }
+
+ $filesToCheck = $this->getDiFilesToCheck($path);
+
+ foreach ($filesToCheck as $diPath) {
+ $originalModule = $this->parseDiFileForCompatModule($diPath, $moduleName);
+ if ($originalModule) {
+ return $originalModule;
+ }
+ }
+ } catch (\Throwable $e) {
+ return null;
+ }
+
+ return null;
+ }
+
+ /**
+ * Get DI files to check for compat module registration
+ *
+ * @param string $modulePath
+ * @return array
+ */
+ private function getDiFilesToCheck(string $modulePath): array
+ {
+ $diFile = $modulePath . '/etc/frontend/di.xml';
+ $globalDiFile = $modulePath . '/etc/di.xml';
+
+ $filesToCheck = [];
+ if ($this->fileDriver->isExists($diFile)) {
+ $filesToCheck[] = $diFile;
+ }
+ if ($this->fileDriver->isExists($globalDiFile)) {
+ $filesToCheck[] = $globalDiFile;
+ }
+
+ return $filesToCheck;
+ }
+
+ /**
+ * Parse a single di.xml file for compat module registration
+ *
+ * @param string $diPath
+ * @param string $moduleName
+ * @return string|null
+ */
+ private function parseDiFileForCompatModule(string $diPath, string $moduleName): ?string
+ {
+ if (!$this->fileDriver->isFile($diPath)) {
+ return null;
+ }
+
+ $content = $this->fileDriver->fileGetContents($diPath);
+ if (!$content) {
+ return null;
+ }
+
+ $dom = new \DOMDocument();
+ $libxmlState = libxml_use_internal_errors(true);
+ $dom->loadXML($content);
+ libxml_use_internal_errors($libxmlState);
+
+ $xpath = new \DOMXPath($dom);
+ $query = "//argument[@name='compatModules'][@xsi:type='array']/item[@xsi:type='array']";
+ $items = $xpath->query($query);
+
+ if ($items === false || $items->length === 0) {
+ return null;
+ }
+
+ return $this->findOriginalModuleInXmlItems($items, $xpath, $moduleName);
+ }
+
+ /**
+ * Find original module in XML items
+ *
+ * @param \DOMNodeList<\DOMNode> $items
+ * @param \DOMXPath $xpath
+ * @param string $moduleName
+ * @return string|null
+ */
+ private function findOriginalModuleInXmlItems(\DOMNodeList $items, \DOMXPath $xpath, string $moduleName): ?string
+ {
+ foreach ($items as $item) {
+ $compatModuleValue = null;
+ $originalModuleValue = null;
+
+ /** @var \DOMElement $item */
+ $childNodes = $xpath->query('item', $item);
+ if ($childNodes === false) {
+ continue;
+ }
+
+ foreach ($childNodes as $childNode) {
+ /** @var \DOMElement $childNode */
+ $nameAttr = $childNode->getAttribute('name');
+ $value = trim((string) $childNode->nodeValue);
+
+ if ($nameAttr === 'compat_module') {
+ $compatModuleValue = $value;
+ } elseif ($nameAttr === 'original_module') {
+ $originalModuleValue = $value;
+ }
+ }
+
+ if ($compatModuleValue === $moduleName && $originalModuleValue) {
+ return $originalModuleValue;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/etc/di.xml b/src/etc/di.xml
index 2fe48a7..6b7ff6d 100644
--- a/src/etc/di.xml
+++ b/src/etc/di.xml
@@ -25,6 +25,8 @@
OpenForgeProject\MageForge\Console\Command\Theme\TokensCommand
-
OpenForgeProject\MageForge\Console\Command\Dev\InspectorCommand
+ -
+ OpenForgeProject\MageForge\Console\Command\Theme\CopyFromVendorCommand