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..11c8e1f
--- /dev/null
+++ b/src/Console/Command/Theme/CopyFromVendorCommand.php
@@ -0,0 +1,234 @@
+setName('mageforge: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');
+ }
+
+ 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;
+ }
+
+ private function getAbsoluteSourcePath(string $sourceFile): string
+ {
+ $rootPath = $this->directoryList->getRoot();
+ if (str_starts_with($sourceFile, '/')) {
+ $absoluteSourcePath = $sourceFile;
+ } else {
+ $absoluteSourcePath = $rootPath . '/' . $sourceFile;
+ }
+
+ if (!file_exists($absoluteSourcePath)) {
+ throw new \RuntimeException("Source file not found: $absoluteSourcePath");
+ }
+
+ return $absoluteSourcePath;
+ }
+
+ 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.');
+ }
+
+ $this->fixPromptEnvironment();
+
+ return (string) 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...'
+ );
+ }
+
+ /**
+ * 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];
+ }
+
+ private function getAbsoluteDestPath(string $destinationPath, string $rootPath): string
+ {
+ if (str_starts_with($destinationPath, '/')) {
+ return $destinationPath;
+ }
+ return $rootPath . '/' . $destinationPath;
+ }
+
+ 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 (file_exists($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;
+ }
+ }
+
+ private function performCopy(string $absoluteSourcePath, string $absoluteDestPath): void
+ {
+ $directory = dirname($absoluteDestPath);
+ if (!is_dir($directory)) {
+ if (!mkdir($directory, 0777, true) && !is_dir($directory)) {
+ throw new \RuntimeException(sprintf('Directory "%s" was not created', $directory));
+ }
+ }
+ copy($absoluteSourcePath, $absoluteDestPath);
+ }
+
+ 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 (file_exists($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).");
+ }
+
+ 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..a0e868f
--- /dev/null
+++ b/src/Service/VendorFileMapper.php
@@ -0,0 +1,177 @@
+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);
+
+ 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;
+ }
+}
diff --git a/src/etc/di.xml b/src/etc/di.xml
index 2ea49d6..8952223 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