Skip to content

Commit b9c8e85

Browse files
committed
feat: add command to copy files from vendor to theme
1 parent 084b528 commit b9c8e85

3 files changed

Lines changed: 231 additions & 0 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenForgeProject\MageForge\Console\Command\Theme;
6+
7+
use InvalidArgumentException;
8+
use Laravel\Prompts\SearchPrompt;
9+
use Magento\Framework\Filesystem\DirectoryList;
10+
use Magento\Framework\Filesystem;
11+
use OpenForgeProject\MageForge\Console\Command\AbstractCommand;
12+
use OpenForgeProject\MageForge\Model\ThemeList;
13+
use OpenForgeProject\MageForge\Service\VendorFileMapper;
14+
use Symfony\Component\Console\Input\InputArgument;
15+
use Symfony\Component\Console\Input\InputInterface;
16+
use Symfony\Component\Console\Output\OutputInterface;
17+
18+
use function Laravel\Prompts\search;
19+
20+
class CopyFromVendorCommand extends AbstractCommand
21+
{
22+
public function __construct(
23+
private readonly ThemeList $themeList,
24+
private readonly VendorFileMapper $vendorFileMapper,
25+
private readonly Filesystem $filesystem,
26+
private readonly DirectoryList $directoryList
27+
) {
28+
parent::__construct();
29+
}
30+
31+
protected function configure(): void
32+
{
33+
$this->setName('mageforge:theme:copy-from-vendor')
34+
->setDescription('Copy a file from vendor/ to a specific theme with correct path resolution')
35+
->setAliases(['m:t:cfv'])
36+
->addArgument('file', InputArgument::REQUIRED, 'Path to the source file (vendor/...)')
37+
->addArgument('theme', InputArgument::OPTIONAL, 'Target theme code (e.g. Magento/luma)');
38+
}
39+
40+
protected function executeCommand(InputInterface $input, OutputInterface $output): int
41+
{
42+
$sourceFile = $input->getArgument('file');
43+
$themeCode = $input->getArgument('theme');
44+
45+
// 1. Verify Source File
46+
$rootPath = $this->directoryList->getRoot();
47+
// If absolute path provided
48+
if (str_starts_with($sourceFile, '/')) {
49+
$absoluteSourcePath = $sourceFile;
50+
// Make relative for display/proecessing
51+
if (str_starts_with($sourceFile, $rootPath . '/')) {
52+
$sourceFile = substr($sourceFile, strlen($rootPath) + 1);
53+
}
54+
} else {
55+
$absoluteSourcePath = $rootPath . '/' . $sourceFile;
56+
}
57+
58+
if (!file_exists($absoluteSourcePath)) {
59+
$this->io->error("Source file not found: $absoluteSourcePath");
60+
return self::RETURN_FAILURE;
61+
}
62+
63+
// 2. Select Theme if missing
64+
if (!$themeCode) {
65+
$themes = $this->themeList->getAllThemes();
66+
$options = [];
67+
foreach ($themes as $theme) {
68+
$options[$theme->getCode()] = $theme->getCode();
69+
}
70+
71+
if (empty($options)) {
72+
$this->io->error('No themes found to copy to.');
73+
return self::RETURN_FAILURE;
74+
}
75+
76+
// Fix Environment for DDEV (Required for Laravel Prompts)
77+
$this->fixPromptEnvironment();
78+
79+
$themeCode = search(
80+
label: 'Select target theme',
81+
options: fn (string $value) => array_filter(
82+
$options,
83+
fn ($option) => str_contains(strtolower($option), strtolower($value))
84+
),
85+
placeholder: 'Search for a theme...'
86+
);
87+
}
88+
89+
// 3. Resolve Theme Path
90+
$theme = $this->themeList->getThemeByCode($themeCode);
91+
if (!$theme) {
92+
$this->io->error("Theme not found: $themeCode");
93+
return self::RETURN_FAILURE;
94+
}
95+
96+
// Use View\Design\ThemeInterface::getFullPath() if available,
97+
// fallback to calculating path assuming app/design/frontend structure if needed,
98+
// but Theme model normally has getFullPath().
99+
// Let's verify what interface we have. We likely have Magento\Theme\Model\Theme which has getFullPath().
100+
if (!method_exists($theme, 'getFullPath')) {
101+
// Fallback logic
102+
$themePath = 'app/design/frontend/' . $theme->getThemePath();
103+
} else {
104+
$themeAbsolutePath = $theme->getFullPath(); // This is absolute path
105+
// Make relative
106+
if (str_starts_with($themeAbsolutePath, $rootPath . '/')) {
107+
$themePath = substr($themeAbsolutePath, strlen($rootPath) + 1);
108+
} else {
109+
$themePath = $themeAbsolutePath;
110+
}
111+
}
112+
113+
// 4. Calculate Destination
114+
try {
115+
$destinationRelative = $this->vendorFileMapper->mapToThemePath($sourceFile, $themePath);
116+
} catch (\Exception $e) {
117+
$this->io->error($e->getMessage());
118+
return self::RETURN_FAILURE;
119+
}
120+
121+
$absoluteDestPath = $rootPath . '/' . $destinationRelative;
122+
123+
// 5. Preview & Confirm
124+
$this->io->section('Copy Preview');
125+
$this->io->text([
126+
"Source: <info>$sourceFile</info>",
127+
"Target: <info>$destinationRelative</info>",
128+
]);
129+
$this->io->newLine();
130+
131+
if (file_exists($absoluteDestPath)) {
132+
$this->io->warning("File already exists at destination!");
133+
if (!$this->io->confirm('Overwrite existing file?', false)) {
134+
return self::RETURN_SUCCESS;
135+
}
136+
} else {
137+
if (!$this->io->confirm('Proceed with copy?', true)) {
138+
return self::RETURN_SUCCESS;
139+
}
140+
}
141+
142+
// 6. Perform Copy
143+
try {
144+
$directory = dirname($absoluteDestPath);
145+
if (!is_dir($directory)) {
146+
if (!mkdir($directory, 0777, true) && !is_dir($directory)) {
147+
throw new \RuntimeException(sprintf('Directory "%s" was not created', $directory));
148+
}
149+
}
150+
copy($absoluteSourcePath, $absoluteDestPath);
151+
$this->io->success("File copied successfully.");
152+
} catch (\Exception $e) {
153+
$this->io->error("Failed to copy file: " . $e->getMessage());
154+
return self::RETURN_FAILURE;
155+
}
156+
157+
return self::RETURN_SUCCESS;
158+
}
159+
160+
private function fixPromptEnvironment(): void
161+
{
162+
if (getenv('DDEV_PROJECT')) {
163+
putenv('COLUMNS=100');
164+
putenv('LINES=40');
165+
putenv('TERM=xterm-256color');
166+
}
167+
}
168+
}

src/Service/VendorFileMapper.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenForgeProject\MageForge\Service;
6+
7+
use Magento\Framework\Component\ComponentRegistrar;
8+
use Magento\Framework\Component\ComponentRegistrarInterface;
9+
use Magento\Framework\Filesystem\DirectoryList;
10+
use RuntimeException;
11+
12+
class VendorFileMapper
13+
{
14+
public function __construct(
15+
private readonly ComponentRegistrarInterface $componentRegistrar,
16+
private readonly DirectoryList $directoryList
17+
) {}
18+
19+
public function mapToThemePath(string $sourcePath, string $themePath): string
20+
{
21+
// 1. Normalize: Ensure $sourcePath is relative from Magento Root if it's absolute
22+
$rootPath = rtrim($this->directoryList->getRoot(), '/');
23+
if (str_starts_with($sourcePath, $rootPath . '/')) {
24+
$sourcePath = substr($sourcePath, strlen($rootPath) + 1);
25+
}
26+
27+
// 2. Detect "Nested Module" Pattern (Priority 1) - Works for Hyva Compat & Vendor Themes
28+
// Regex search for a segment matching Vendor_Module (e.g. Magento_Catalog).
29+
// Captures (Group 1): "Vendor_Module"
30+
if (preg_match('/([A-Z][a-zA-Z0-9]*_[A-Z][a-zA-Z0-9]*)/', $sourcePath, $matches, PREG_OFFSET_CAPTURE)) {
31+
$offset = $matches[1][1];
32+
33+
// Extract from Vendor_Module onwards (e.g. "Mollie_Payment/templates/file.phtml")
34+
$relativePath = substr($sourcePath, $offset);
35+
36+
return rtrim($themePath, '/') . '/' . ltrim($relativePath, '/');
37+
}
38+
39+
// 3. Detect "Standard Module" Pattern (Priority 2)
40+
$modules = $this->componentRegistrar->getPaths(ComponentRegistrar::MODULE);
41+
foreach ($modules as $moduleName => $path) {
42+
// Normalize module path relative to root
43+
if (str_starts_with($path, $rootPath . '/')) {
44+
$path = substr($path, strlen($rootPath) + 1);
45+
}
46+
47+
// Check if source starts with this module path
48+
if (str_starts_with($sourcePath, $path . '/')) {
49+
$pathInsideModule = substr($sourcePath, strlen($path) + 1);
50+
51+
// Remove view/frontend/ or view/base/ from the path
52+
$cleanPath = preg_replace('#^view/(frontend|base)/#', '', $pathInsideModule);
53+
54+
return rtrim($themePath, '/') . '/' . $moduleName . '/' . ltrim($cleanPath, '/');
55+
}
56+
}
57+
58+
// 4. Fallback
59+
throw new RuntimeException("Could not determine target module or theme structure for file: " . $sourcePath);
60+
}
61+
}

src/etc/di.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
OpenForgeProject\MageForge\Console\Command\Theme\TokensCommand</item>
2626
<item name="mageforge_dev_inspector" xsi:type="object">
2727
OpenForgeProject\MageForge\Console\Command\Dev\InspectorCommand</item>
28+
<item name="mageforge_theme_copy_from_vendor" xsi:type="object">
29+
OpenForgeProject\MageForge\Console\Command\Theme\CopyFromVendorCommand</item>
2830
</argument>
2931
</arguments>
3032
</type>

0 commit comments

Comments
 (0)