diff --git a/src/Composer/RootComposerJsonFile.php b/src/Composer/RootComposerJsonFile.php index d355fd05..41427dcf 100644 --- a/src/Composer/RootComposerJsonFile.php +++ b/src/Composer/RootComposerJsonFile.php @@ -73,4 +73,65 @@ public function writeFile(string $path) { file_put_contents($path, $this->render()); } + + /** + * Get the raw content object + */ + public function getContent(): stdClass + { + return $this->content; + } + + /** + * Deep-merge sections from a source composer.json into this one. + * + * Adds missing keys from source without overwriting existing entries. + * Used to ensure bundle baseline deps are present while preserving + * user-added dependencies. + */ + public function mergeFrom(self $source): self + { + $sourceContent = $source->getContent(); + + foreach (['require', 'require-dev', 'suggest'] as $section) { + if (!isset($sourceContent->$section)) { + continue; + } + if (!isset($this->content->$section)) { + $this->content->$section = new stdClass(); + } + foreach ($sourceContent->$section as $package => $constraint) { + if (!isset($this->content->$section->$package)) { + $this->content->$section->$package = $constraint; + } + } + } + + if (isset($sourceContent->config->{'allow-plugins'})) { + if (!isset($this->content->config)) { + $this->content->config = new stdClass(); + } + if (!isset($this->content->config->{'allow-plugins'})) { + $this->content->config->{'allow-plugins'} = new stdClass(); + } + foreach ($sourceContent->config->{'allow-plugins'} as $plugin => $allowed) { + if (!isset($this->content->config->{'allow-plugins'}->$plugin)) { + $this->content->config->{'allow-plugins'}->$plugin = $allowed; + } + } + } + + if (isset($sourceContent->extra->{'installer-types'})) { + if (!isset($this->content->extra)) { + $this->content->extra = new stdClass(); + } + $existing = (array) ($this->content->extra->{'installer-types'} ?? []); + $sourceTypes = (array) $sourceContent->extra->{'installer-types'}; + $this->content->extra->{'installer-types'} = array_values( + array_unique(array_merge($existing, $sourceTypes)) + ); + } + + return $this; + } } diff --git a/src/Helper/Composer.php b/src/Helper/Composer.php index 2d8e88e2..a48fb411 100644 --- a/src/Helper/Composer.php +++ b/src/Helper/Composer.php @@ -485,8 +485,9 @@ protected function _setAuthors(WrapperHordeYml $package, stdClass $composerDefin */ protected function _setAutoload(WrapperHordeYml $package, stdClass $composerDefinition): void { - // Extensions don't need PHP autoloading (they're compiled C code) - if ($package['type'] === 'extension') { + // Extensions and themes don't need PHP autoloading + if (in_array($package['type'], ['extension', 'horde-theme'], true)) { + $composerDefinition->autoload = new stdClass(); return; } @@ -523,6 +524,9 @@ protected function _setAutoload(WrapperHordeYml $package, stdClass $composerDefi $composerDefinition->autoload['psr-4'] = [$Psr4Name => 'src/']; } } + if (empty($composerDefinition->autoload)) { + $composerDefinition->autoload = new stdClass(); + } } /** * Configure Autoloading @@ -534,8 +538,9 @@ protected function _setAutoload(WrapperHordeYml $package, stdClass $composerDefi */ protected function _setAutoloadDev(WrapperHordeYml $package, stdClass $composerDefinition): void { - // Extensions don't need PHP autoloading (they're compiled C code) - if ($package['type'] === 'extension') { + // Extensions and themes don't need PHP autoloading + if (in_array($package['type'], ['extension', 'horde-theme'], true)) { + $composerDefinition->{'autoload-dev'} = new stdClass(); return; } diff --git a/src/Qc/Task/Gitignore.php b/src/Qc/Task/Gitignore.php index e9226680..ff1c4709 100644 --- a/src/Qc/Task/Gitignore.php +++ b/src/Qc/Task/Gitignore.php @@ -11,6 +11,8 @@ namespace Horde\Components\Qc\Task; +use Horde\HordeYmlFile\HordeYmlFile; + /** * Components_Qc_Task_Gitignore:: checks .gitignore for required entries. * @@ -44,6 +46,19 @@ class Gitignore extends Base '/.phpstan.cache/' => 'PHPStan cache directory', ]; + /** + * Additional entries for components using the horde-installer-plugin + */ + private const INSTALLER_PLUGIN_ENTRIES = [ + '/var/' => 'Horde installer plugin runtime data', + '/web/' => 'Horde installer plugin web-accessible directory', + ]; + + /** + * Component types that use the horde-installer-plugin + */ + private const INSTALLER_PLUGIN_TYPES = ['library', 'application', 'component', 'horde-theme']; + /** * Get the name of this task. * @@ -69,13 +84,14 @@ public function run(array &$options = []): int $componentPath = getcwd(); } + $requiredEntries = $this->getRequiredEntries($componentPath); $gitignorePath = $this->findGitignore($componentPath); if ($gitignorePath === null) { $this->getOutput()->warn('No .gitignore file found'); if (!empty($options['fix_qc_issues'])) { - return $this->createGitignore($componentPath); + return $this->createGitignore($componentPath, $requiredEntries); } return 1; // Error: no .gitignore found @@ -89,7 +105,7 @@ public function run(array &$options = []): int // Check for required entries $missing = []; - foreach (self::REQUIRED_ENTRIES as $pattern => $description) { + foreach ($requiredEntries as $pattern => $description) { if (!$this->hasPattern($lines, $pattern)) { $missing[$pattern] = $description; } @@ -180,17 +196,18 @@ private function hasPattern(array $lines, string $pattern): bool * Create a new .gitignore file with required entries. * * @param string $componentPath Path to the component. + * @param array $entries Required patterns and descriptions. * * @return int Number of errors (0 = success). */ - private function createGitignore(string $componentPath): int + private function createGitignore(string $componentPath, array $entries): int { $gitignorePath = $componentPath . '/.gitignore'; $content = "# Generated by horde-components QC\n"; $content .= "# Standard ignores for Horde components\n\n"; - foreach (self::REQUIRED_ENTRIES as $pattern => $description) { + foreach ($entries as $pattern => $description) { $content .= "# $description\n"; $content .= "$pattern\n\n"; } @@ -236,4 +253,30 @@ private function addMissingEntries(string $gitignorePath, array $missing): int $this->getOutput()->ok('Added ' . count($missing) . ' missing entries to .gitignore'); return 0; } + + /** + * Get the full set of required entries for this component. + * + * Includes installer-plugin entries (var/, web/) when the component + * type uses the horde-installer-plugin. + * + * @param string $componentPath Path to the component. + * + * @return array Pattern => description pairs. + */ + private function getRequiredEntries(string $componentPath): array + { + $entries = self::REQUIRED_ENTRIES; + + $hordeYmlPath = $componentPath . '/.horde.yml'; + if (file_exists($hordeYmlPath)) { + $hordeYml = new HordeYmlFile($hordeYmlPath); + $type = $hordeYml->getType(); + if (in_array($type, self::INSTALLER_PLUGIN_TYPES, true)) { + $entries = array_merge($entries, self::INSTALLER_PLUGIN_ENTRIES); + } + } + + return $entries; + } } diff --git a/src/Runner/InstallRunner.php b/src/Runner/InstallRunner.php index 62207b9f..8e3972a3 100644 --- a/src/Runner/InstallRunner.php +++ b/src/Runner/InstallRunner.php @@ -6,6 +6,7 @@ use Horde\Components\Composer\InstallationDirectory; use Horde\Components\Composer\PathRepositoryDefinition; +use Horde\Components\Composer\RootComposerJsonFile; use Horde\Components\RuntimeContext\GitCheckoutDirectory; use Horde\Components\Output; use Horde\Components\Wrapper\HordeYml; @@ -79,9 +80,22 @@ public function run() filter: [ 'vendor', 'composer.lock', + 'composer.json', ], ); $copyHelper->copy(); + + // Handle composer.json individually for idempotency + if (!$this->installationDirectory->hasComposerJson()) { + copy( + $baseComponentGitDir . '/composer.json', + $this->installationDirectory->getComposerJsonPath() + ); + $this->output->ok('Copied composer.json from bundle'); + } else { + $this->output->info('Existing composer.json found — merging bundle dependencies'); + } + // Inject all horde apps as local sources. try { $composerJson = $this->installationDirectory->getComposerJson(); @@ -89,6 +103,10 @@ public function run() $this->output->fail('Could not read composer.json file from installation directory: ' . $this->installationDirectory); return; } + + // Merge bundle's baseline sections into existing file (noop for fresh installs) + $bundleJson = RootComposerJsonFile::loadFile($baseComponentGitDir . '/composer.json'); + $composerJson->mergeFrom($bundleJson); foreach ($this->gitCheckoutDirectory->getHordeYmlDirs() as $hordeYmlDir) { // Load HordeYml to get the ComponentVersion $hordeYml = new HordeYml($hordeYmlDir); diff --git a/src/Task/Release/AddChangelogEntryTask.php b/src/Task/Release/AddChangelogEntryTask.php index e519c4e2..e60547ae 100644 --- a/src/Task/Release/AddChangelogEntryTask.php +++ b/src/Task/Release/AddChangelogEntryTask.php @@ -69,10 +69,31 @@ public function __construct( } + /** + * Component types that do not use doc/changelog.yml. + */ + private const TYPES_WITHOUT_CHANGELOG = [ + 'horde-theme', + ]; + public function getName(): string { return "Ad\1 \2hangelo\1 \2ntry"; } + + public function shouldSkip(Context $context): bool + { + $componentPath = $context->getComponentPath(); + $hordeYmlPath = $componentPath . '/.horde.yml'; + if (file_exists($hordeYmlPath)) { + $hordeYml = new HordeYmlFile($hordeYmlPath); + if (in_array($hordeYml->getType(), self::TYPES_WITHOUT_CHANGELOG, true)) { + return true; + } + } + return false; + } + public function run(Context $context): Result { $componentPath = $context->getComponentPath();