From dd1bf2231477059a616ba35ea34295e7cb59202f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Thu, 1 Oct 2026 21:29:59 +0200 Subject: [PATCH 1/4] chore: preserve pre-review workspace changes --- src/Catalog/DefaultTemplateCatalog.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Catalog/DefaultTemplateCatalog.php b/src/Catalog/DefaultTemplateCatalog.php index 5625413..ebb2b2e 100644 --- a/src/Catalog/DefaultTemplateCatalog.php +++ b/src/Catalog/DefaultTemplateCatalog.php @@ -25,7 +25,7 @@ public function __construct(array $templates = []) label: 'SymPress Starter', packageName: 'sympress/starter', repositoryUrl: 'https://github.com/SymPress/starter', - description: 'WordPress website starter with DDEV, WPStarter, SymPress kernel, WP-CLI' + description: 'WordPress website starter with DDEV, SymPress Runtime, SymPress kernel, WP-CLI' . ' and quality tooling.', ), ]; From 83447d183bc5f2c9c2bc180a4b61a7ff9bc558fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Fri, 2 Oct 2026 00:05:07 +0200 Subject: [PATCH 2/4] Pin template manifests and require explicit setup trust --- README.md | 45 ++++-- composer.json | 3 +- src/Command/CreateProjectCommand.php | 13 +- src/Command/UpdateProjectCommand.php | 41 +++-- src/Generator/EnvFileEditor.php | 35 +++-- src/Generator/ProjectGenerator.php | 100 +++++++++---- src/Generator/SymfonyProcessCommandRunner.php | 6 + src/IO/ProjectQuestionnaire.php | 35 ++++- src/Model/ProjectConfiguration.php | 2 + src/Project/ProjectMetadata.php | 12 +- src/Repository/RepositoryManifestLoader.php | 23 ++- src/Repository/TemplateRevision.php | 52 +++++++ src/Update/ProjectUpdater.php | 2 + tests/Generator/EnvFileEditorTest.php | 27 +++- .../Generator/FixtureProjectCommandRunner.php | 7 +- tests/Generator/ProjectGeneratorTest.php | 20 +++ tests/Project/ProjectMetadataStoreTest.php | 22 +++ tests/Repository/TemplateRevisionTest.php | 141 ++++++++++++++++++ 18 files changed, 503 insertions(+), 83 deletions(-) create mode 100644 src/Repository/TemplateRevision.php create mode 100644 tests/Repository/TemplateRevisionTest.php diff --git a/README.md b/README.md index b8e0ced..1fdd76e 100644 --- a/README.md +++ b/README.md @@ -93,11 +93,13 @@ sympress new shop-platform \ Use a repository-provided manifest: ```bash -sympress new my-site --manifest=https://github.com/SymPress/starter +sympress new my-site --template-version= --allow-template-execution ``` The CLI also tries to discover a manifest in the selected template repository by -default. Use `--no-remote-manifest` for offline or fully pinned runs. +default, from the same resolved immutable Git SHA used to materialize the template. +Use --no-remote-manifest with an explicit local/offline catalog when discovery +is unnecessary; creation still resolves a non-SHA selected version before download. ## Update A Project @@ -130,14 +132,17 @@ By default the update command patches `composer.json`, updates ## What It Does -1. Runs `composer create-project sympress/starter --no-install`. +1. Resolves the selected version/ref once to an immutable Git SHA, clones without + checkout and checks out that SHA detached with Git hooks disabled. Composer + installation, root scripts and plugins are not executed during materialization. 2. Writes project metadata and selected package suggestions to `composer.json`. 3. Creates `.env` from `.env.example` and fills first-run values: `WP_HOME`, `WP_SITEURL`, `WP_ADMIN_USERNAME`, `WP_ADMIN_PASSWORD`, `DDEV_PROJECT_NAME` and `DDEV_PROJECT_TLD`. 4. Writes `.sympress/project.json` so future update runs know the project type, - starter template and repository. -5. Runs the starter setup command, unless `--no-setup` is passed. + starter template, repository and actual immutable template SHA. +5. Runs the setup command from the checked-out manifest only when --allow-template-execution + explicitly authorizes it. --no-setup and dry-run never execute setup. ## Project Types @@ -166,8 +171,9 @@ sympress project:create [directory] [options] - `--template-version=1.0.x-dev` - `--manifest=./sympress-cli.json` - `--manifest=https://github.com/SymPress/starter` -- `--manifest-ref=main` +- `--manifest-ref=<40-character-Git-SHA>` - `--no-remote-manifest` +- `--allow-template-execution` - `--no-setup` - `--dry-run` @@ -179,7 +185,7 @@ Update options: - `--repository=https://github.com/SymPress/starter` - `--template-version=1.0.x-dev` - `--manifest=./sympress-cli.json` -- `--manifest-ref=main` +- `--manifest-ref=<40-character-Git-SHA>` - `--no-remote-manifest` - `--no-install` - `--dry-run` @@ -243,11 +249,26 @@ The canonical format is defined by [`schema/repository-manifest.schema.json`](schema/repository-manifest.schema.json). Malformed entries fail as a whole instead of being silently ignored. -A remote manifest is trusted executable input because `setupCommand` is run as -an argv array after project creation. Remote loading requires HTTPS. Pin -`--manifest-ref` to a reviewed tag or commit for reproducible automation. Use -`--no-setup` to inspect generated files before running a repository-provided -setup command. +Repository manifests are read from the same selected immutable SHA as template +files; there is no fallback to main. A snapshot manifest cannot redirect the +already selected package/repository/default ref. Explicit raw HTTPS JSON URLs and +local files are independent catalogs with their own provenance; remote Git +catalogs require a 40-character --manifest-ref. Materialization still inspects the +checked-out manifest before setup. + +--allow-template-execution authorizes setup commands and all executable behavior +those commands invoke (including Composer scripts/plugins). Review the repository, +SHA and argv before using it. Without the flag, setup refuses before cloning; +--no-setup can be used to inspect the source first. No shell is used and command +failures propagate. The initial Git clone/checkout stage disables Git hooks and +does not run Composer. A created project records the immutable SHA; update +catalog loading follows the selected target SHA, and changing source/version +invalidates the previous recorded identity. + +Dotenv literal values round-trip comments, dollars, quotes, backslashes, Unicode +and whitespace. Only WP_SITEURL intentionally expands ${WP_HOME}/wp. The .env +file is created/written with mode 0600. Completion and dry-run output never print +an admin password; the credential is stored privately in .env. ## Extending diff --git a/composer.json b/composer.json index 108da6e..cd88ee7 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,8 @@ "require-dev": { "phpstan/phpstan": "^2.2", "phpunit/phpunit": "^11.5", - "squizlabs/php_codesniffer": "^3.13" + "squizlabs/php_codesniffer": "^3.13", + "symfony/dotenv": "^8.1" }, "autoload": { "psr-4": { diff --git a/src/Command/CreateProjectCommand.php b/src/Command/CreateProjectCommand.php index cd2088f..7d3f066 100644 --- a/src/Command/CreateProjectCommand.php +++ b/src/Command/CreateProjectCommand.php @@ -93,7 +93,18 @@ protected function configure(): void InputOption::VALUE_REQUIRED, 'Manifest file, URL or GitHub repository to load template metadata from.', ) - ->addOption('manifest-ref', null, InputOption::VALUE_REQUIRED, 'Git ref used for remote manifests.', 'main') + ->addOption( + 'manifest-ref', + null, + InputOption::VALUE_REQUIRED, + 'Explicit immutable Git SHA for an independent manifest.' + ) + ->addOption( + 'allow-template-execution', + null, + InputOption::VALUE_NONE, + 'Trust the selected repository revision and its setup commands.' + ) ->addOption( 'no-remote-manifest', null, diff --git a/src/Command/UpdateProjectCommand.php b/src/Command/UpdateProjectCommand.php index 709ea4c..0bb1dd1 100644 --- a/src/Command/UpdateProjectCommand.php +++ b/src/Command/UpdateProjectCommand.php @@ -53,7 +53,7 @@ protected function configure(): void InputOption::VALUE_REQUIRED, 'Manifest file, URL or GitHub repository to load template metadata from.', ) - ->addOption('manifest-ref', null, InputOption::VALUE_REQUIRED, 'Git ref used for remote manifests.', 'main') + ->addOption('manifest-ref', null, InputOption::VALUE_REQUIRED, 'Immutable Git SHA for remote manifests.') ->addOption( 'no-remote-manifest', null, @@ -82,7 +82,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { $projectDir = $this->projectDir($input, getcwd() ?: '.'); $metadata = $this->metadataStore->read($projectDir); - [$templates, $profiles, $packages] = $this->catalogs($input, $metadata); + [$templates, $profiles, $packages, $revision] = $this->catalogs($input, $metadata); $level = $this->level($input, $io); $targetType = $this->targetType($input, $io, $level, $metadata, $profiles); $targetTemplate = $this->targetTemplate($input, $level, $metadata); @@ -99,6 +99,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $targetTemplate, $templateRepository, $templateVersion, + $revision, ); return $this->updater->apply( @@ -137,7 +138,7 @@ private function projectDir(InputInterface $input, string $cwd): string } /** - * @return array{0: DefaultTemplateCatalog, 1: DefaultProfileCatalog, 2: DefaultPackageCatalog} + * @return array{0: DefaultTemplateCatalog, 1: DefaultProfileCatalog, 2: DefaultPackageCatalog, 3: string|null} */ private function catalogs(InputInterface $input, ProjectMetadata $metadata): array { @@ -156,19 +157,31 @@ private function catalogs(InputInterface $input, ProjectMetadata $metadata): arr } if ((bool) $input->getOption('no-remote-manifest')) { - return [$templates, $profiles, $packages]; + return [$templates, $profiles, $packages, null]; } - - $remoteManifest = $this->manifestLoader->loadFromRepository( - $metadata->templateRepositoryUrl, - $this->manifestRef($input), - ); - + $selected = $templates->get($this->stringOption($input, 'template') ?? $metadata->templateId); + $changed = $selected->id !== $metadata->templateId; + $repository = $this->stringOption($input, 'repository') + ?? ($changed ? $selected->repositoryUrl : $metadata->templateRepositoryUrl); + $version = $this->stringOption($input, 'template-version') + ?? ($changed ? $selected->defaultVersion : $metadata->templateVersion); + $revision = $repository === $metadata->templateRepositoryUrl && $version === $metadata->templateVersion + ? $metadata->templateRevision : null; + $revision ??= (new \SymPress\Cli\Repository\TemplateRevision())->resolve($repository, $version); + $remoteManifest = $this->manifestLoader->loadFromRepository($repository, $revision); if ($remoteManifest === null) { - return [$templates, $profiles, $packages]; + return [$templates, $profiles, $packages, $revision]; } - - return $this->applyManifest($remoteManifest, $templates, $profiles, $packages); + foreach ($remoteManifest->templates as $candidate) { + if ( + $candidate->id === $selected->id && ($candidate->repositoryUrl !== $selected->repositoryUrl + || $candidate->packageName !== $selected->packageName + || $candidate->defaultVersion !== $selected->defaultVersion) + ) { + throw new RuntimeException('Snapshot manifest cannot redirect the selected template.'); + } + } + return [...$this->applyManifest($remoteManifest, $templates, $profiles, $packages), $revision]; } private function level(InputInterface $input, SymfonyStyle $io): string @@ -250,7 +263,7 @@ private function applyManifest( private function manifestRef(InputInterface $input): string { - return $this->stringOption($input, 'manifest-ref') ?: 'main'; + return $this->stringOption($input, 'manifest-ref') ?: ''; } private function stringOption(InputInterface $input, string $name): ?string diff --git a/src/Generator/EnvFileEditor.php b/src/Generator/EnvFileEditor.php index ac19702..473be43 100644 --- a/src/Generator/EnvFileEditor.php +++ b/src/Generator/EnvFileEditor.php @@ -20,14 +20,20 @@ public function apply(string $projectDir, ProjectConfiguration $configuration): $envFile = $projectDir . '/.env'; $envExample = $projectDir . '/.env.example'; - if (!is_file($envFile)) { - if (is_file($envExample)) { - $this->filesystem->copy($envExample, $envFile); - } else { - $this->filesystem->dumpFile($envFile, ''); + $previous = umask(0077); + try { + if (!is_file($envFile)) { + if (is_file($envExample)) { + $this->filesystem->copy($envExample, $envFile); + } else { + $this->filesystem->dumpFile($envFile, ''); + } } - } + chmod($envFile, 0600); + } finally { + umask($previous); + } $this->update($envFile, [ 'DDEV_PROJECT_NAME' => $configuration->projectSlug, 'DDEV_PROJECT_TLD' => $configuration->ddevTld, @@ -54,7 +60,7 @@ private function update(string $envFile, array $values): void foreach ($lines as $index => $line) { foreach ($values as $key => $value) { if (str_starts_with((string) $line, $key . '=')) { - $lines[$index] = $key . '=' . $value; + $lines[$index] = $key . '=' . ($key === 'WP_SITEURL' ? $value : $this->quote($value)); $seen[$key] = true; } } @@ -62,10 +68,21 @@ private function update(string $envFile, array $values): void foreach ($values as $key => $value) { if (!isset($seen[$key])) { - $lines[] = $key . '=' . $value; + $lines[] = $key . '=' . ($key === 'WP_SITEURL' ? $value : $this->quote($value)); } } - $this->filesystem->dumpFile($envFile, implode("\n", $lines) . "\n"); + $previous = umask(0077); + try { + $this->filesystem->dumpFile($envFile, implode("\n", $lines) . "\n"); + chmod($envFile, 0600); + } finally { + umask($previous); + } + } + + private function quote(string $value): string + { + return '"' . str_replace(['\\', '"', '$', "\r", "\n"], ['\\\\', '\\"', '\\$', '\\r', '\\n'], $value) . '"'; } } diff --git a/src/Generator/ProjectGenerator.php b/src/Generator/ProjectGenerator.php index 4d9621c..e1f96a4 100644 --- a/src/Generator/ProjectGenerator.php +++ b/src/Generator/ProjectGenerator.php @@ -32,15 +32,69 @@ public function generate(ProjectConfiguration $configuration, SymfonyStyle $io): } $this->assertTargetDirectoryIsAvailable($configuration->directory); - $this->filesystem->mkdir(dirname($configuration->directory)); - $io->section('Create starter project'); - $exitCode = $this->commandRunner->run($this->createProjectCommand($configuration), null, $io); + $repository = $configuration->templateRepository ?: $configuration->template->repositoryUrl; + $revision = $configuration->templateRevision + ?? (new \SymPress\Cli\Repository\TemplateRevision())->resolve( + $repository, + $configuration->templateVersion ?: $configuration->template->defaultVersion + ); + if ($configuration->runSetup && !$configuration->allowTemplateExecution) { + throw new RuntimeException( + 'Setup requires --allow-template-execution to trust the selected revision and its commands.', + ); + } + if (preg_match('/^[a-f0-9]{40}$/iD', $revision) !== 1) { + throw new RuntimeException('The template revision must be an immutable Git SHA.'); + } + $this->filesystem->mkdir(dirname($configuration->directory)); + $io->section('Create starter project at ' . $revision); + $exitCode = $this->commandRunner->run( + ['git', + '-c', + 'core.hooksPath=/dev/null', + 'clone', + '--no-checkout', + '--', + $repository, + $configuration->directory], + null, + $io + ); if ($exitCode !== 0) { return $exitCode; } + $exitCode = $this->commandRunner->run( + ['git', + '-c', + 'core.hooksPath=/dev/null', + 'checkout', + '--detach', + $revision], + $configuration->directory, + $io + ); + if ($exitCode !== 0) { + return $exitCode; + } + $manifest = (new \SymPress\Cli\Repository\RepositoryManifestLoader())->load($configuration->directory); + $setupTemplate = $configuration->template; + foreach ($manifest->templates ?? [] as $candidate) { + if ($candidate->id !== $setupTemplate->id) { + continue; + } + if ( + $candidate->repositoryUrl !== $setupTemplate->repositoryUrl + || $candidate->packageName !== $setupTemplate->packageName + || $candidate->defaultVersion !== $setupTemplate->defaultVersion + ) { + throw new RuntimeException('Snapshot manifest cannot redirect the selected template.'); + } + $setupTemplate = $candidate; + } + $this->filesystem->remove($configuration->directory . '/.git'); $io->section('Apply initial configuration'); $composerChanged = $this->composerJsonEditor->apply($configuration->directory, $configuration); if ($composerChanged) { @@ -50,13 +104,13 @@ public function generate(ProjectConfiguration $configuration, SymfonyStyle $io): $this->envFileEditor->apply($configuration->directory, $configuration); $this->metadataStore->write( $configuration->directory, - ProjectMetadata::fromConfiguration($configuration), + ProjectMetadata::fromConfiguration($configuration, $revision), ); if ($configuration->runSetup) { $io->section('Run starter setup'); $exitCode = $this->commandRunner->run( - $configuration->template->setupCommandFor($configuration), + $setupTemplate->setupCommandFor($configuration), $configuration->directory, $io, ); @@ -70,7 +124,7 @@ public function generate(ProjectConfiguration $configuration, SymfonyStyle $io): $io->definitionList( ['Project URL' => $configuration->wpHome()], ['Admin user' => $configuration->wpAdminUsername], - ['Admin password' => $configuration->wpAdminPassword], + ['Admin credential' => 'Stored privately in .env'], ); if (!$configuration->runSetup) { @@ -84,37 +138,13 @@ public function generate(ProjectConfiguration $configuration, SymfonyStyle $io): return 0; } - /** - * @return list - */ - private function createProjectCommand(ProjectConfiguration $configuration): array - { - $command = [ - $configuration->composerBinary, - 'create-project', - $configuration->template->packageSpec($configuration->templateVersion), - $configuration->directory, - '--no-install', - ]; - - if ($configuration->templateRepository !== null && $configuration->templateRepository !== '') { - $command[] = '--repository=' . json_encode( - [ - 'type' => 'vcs', - 'url' => $configuration->templateRepository, - ], - JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, - ); - } - - return $command; - } - private function renderPlan(ProjectConfiguration $configuration, SymfonyStyle $io): void { $io->title('SymPress CLI dry run'); $io->definitionList( ['Template' => $configuration->template->label], + ['Package' => $configuration->template->packageSpec($configuration->templateVersion)], + ['Template revision' => $configuration->templateRevision ?? 'Resolve selected ref before execution'], ['Project type' => $configuration->profile->label], ['Directory' => $configuration->directory], ['Composer name' => $configuration->composerPackageName], @@ -122,7 +152,11 @@ private function renderPlan(ProjectConfiguration $configuration, SymfonyStyle $i ['Run setup' => $configuration->runSetup ? 'yes' : 'no'], ); $io->listing([ - $this->formatCommand($this->createProjectCommand($configuration)), + $this->formatCommand(['git', '-c', 'core.hooksPath=/dev/null', 'clone', '--no-checkout', '--', + $configuration->templateRepository ?: $configuration->template->repositoryUrl, + $configuration->directory]), + $this->formatCommand(['git', '-c', 'core.hooksPath=/dev/null', 'checkout', '--detach', + $configuration->templateRevision ?? '']), 'patch composer.json', 'write .env', 'write ' . ProjectMetadataStore::RELATIVE_PATH, diff --git a/src/Generator/SymfonyProcessCommandRunner.php b/src/Generator/SymfonyProcessCommandRunner.php index f840dce..6f29ae9 100644 --- a/src/Generator/SymfonyProcessCommandRunner.php +++ b/src/Generator/SymfonyProcessCommandRunner.php @@ -12,6 +12,12 @@ final class SymfonyProcessCommandRunner implements CommandRunner public function run(array $command, ?string $cwd, OutputInterface $output): int { $process = new Process($command, $cwd); + if (($command[0] ?? null) === 'git') { + if ($cwd === null) { + $process->setWorkingDirectory(sys_get_temp_dir()); + } + $process->setEnv(['GIT_DIR' => false, 'GIT_WORK_TREE' => false, 'GIT_INDEX_FILE' => false]); + } $process->setTimeout(null); $process->run(static function (string $type, string $buffer) use ($output): void { unset($type); diff --git a/src/IO/ProjectQuestionnaire.php b/src/IO/ProjectQuestionnaire.php index f8027dc..e8e6954 100644 --- a/src/IO/ProjectQuestionnaire.php +++ b/src/IO/ProjectQuestionnaire.php @@ -60,7 +60,17 @@ public function ask(InputInterface $input, OutputInterface $output, string $cwd) $template = $this->selectTemplate($input, $io, $interactive, $templates); $templateRepository = $this->emptyToNull($input->getOption('repository')) ?: $template->repositoryUrl; - $remoteManifest = $this->loadRemoteManifest($input, $templateRepository); + $version = $this->emptyToNull($input->getOption('template-version')) ?: $template->defaultVersion; + $revision = null; + if ( + !(bool) $input->getOption('no-remote-manifest') + && (!is_dir($templateRepository) + || is_dir($templateRepository . '/.git') + || is_file($templateRepository . '/HEAD')) + ) { + $revision = (new \SymPress\Cli\Repository\TemplateRevision())->resolve($templateRepository, $version); + } + $remoteManifest = $this->loadRemoteManifest($input, $templateRepository, $revision); if ($remoteManifest !== null) { [$templates, $profiles, $packages] = $this->applyManifest( @@ -69,7 +79,15 @@ public function ask(InputInterface $input, OutputInterface $output, string $cwd) $profiles, $packages, ); - $template = $templates->get($template->id); + $updatedTemplate = $templates->get($template->id); + if ( + $updatedTemplate->repositoryUrl !== $template->repositoryUrl + || $updatedTemplate->defaultVersion !== $template->defaultVersion + || $updatedTemplate->packageName !== $template->packageName + ) { + throw new \RuntimeException('A manifest cannot redirect the selected template repository or version.'); + } + $template = $updatedTemplate; if ($this->emptyToNull($input->getOption('repository')) === null) { $templateRepository = $template->repositoryUrl; @@ -109,6 +127,8 @@ public function ask(InputInterface $input, OutputInterface $output, string $cwd) composerBinary: (string) $input->getOption('composer-bin'), templateVersion: $this->emptyToNull($input->getOption('template-version')), templateRepository: $templateRepository, + templateRevision: $revision, + allowTemplateExecution: (bool) $input->getOption('allow-template-execution'), ); } @@ -123,13 +143,16 @@ private function loadExplicitManifest(InputInterface $input): ?RepositoryManifes return $this->manifestLoader->loadRequired($source, $this->manifestRef($input)); } - private function loadRemoteManifest(InputInterface $input, string $repositoryUrl): ?RepositoryManifest - { + private function loadRemoteManifest( + InputInterface $input, + string $repositoryUrl, + ?string $revision + ): ?RepositoryManifest { if ((bool) $input->getOption('no-remote-manifest')) { return null; } - return $this->manifestLoader->loadFromRepository($repositoryUrl, $this->manifestRef($input)); + return $this->manifestLoader->loadFromRepository($repositoryUrl, $revision ?? ''); } /** @@ -154,7 +177,7 @@ private function applyManifest( private function manifestRef(InputInterface $input): string { - return $this->emptyToNull($input->getOption('manifest-ref')) ?: 'main'; + return $this->emptyToNull($input->getOption('manifest-ref')) ?: ''; } private function selectTemplate( diff --git a/src/Model/ProjectConfiguration.php b/src/Model/ProjectConfiguration.php index e1fbcf6..a3d3275 100644 --- a/src/Model/ProjectConfiguration.php +++ b/src/Model/ProjectConfiguration.php @@ -27,6 +27,8 @@ public function __construct( public string $composerBinary = 'composer', public ?string $templateVersion = null, public ?string $templateRepository = null, + public ?string $templateRevision = null, + public bool $allowTemplateExecution = false, ) { } diff --git a/src/Project/ProjectMetadata.php b/src/Project/ProjectMetadata.php index 2c61302..3afc96e 100644 --- a/src/Project/ProjectMetadata.php +++ b/src/Project/ProjectMetadata.php @@ -24,10 +24,11 @@ public function __construct( public string $templateVersion, public string $updatedAt, public int $schemaVersion = self::SCHEMA_VERSION, + public ?string $templateRevision = null, ) { } - public static function fromConfiguration(ProjectConfiguration $configuration): self + public static function fromConfiguration(ProjectConfiguration $configuration, ?string $revision = null): self { return new self( projectName: $configuration->projectName, @@ -40,6 +41,7 @@ public static function fromConfiguration(ProjectConfiguration $configuration): s templateRepositoryUrl: $configuration->templateRepository ?: $configuration->template->repositoryUrl, templateVersion: $configuration->templateVersion ?: $configuration->template->defaultVersion, updatedAt: gmdate('c'), + templateRevision: $revision ?? $configuration->templateRevision, ); } @@ -60,6 +62,7 @@ public static function fromArray(array $data): self templateVersion: self::string($data, 'templateVersion', '1.0.x-dev'), updatedAt: self::string($data, 'updatedAt'), schemaVersion: (int) ($data['schemaVersion'] ?? self::SCHEMA_VERSION), + templateRevision: self::string($data, 'templateRevision') ?: null, ); } @@ -68,7 +71,12 @@ public function withUpdate( TemplateDefinition $template, ?string $templateRepository = null, ?string $templateVersion = null, + ?string $templateRevision = null, ): self { + $repository = $templateRepository ?: $template->repositoryUrl; + $version = $templateVersion ?: $template->defaultVersion; + $preservedRevision = $repository === $this->templateRepositoryUrl && $version === $this->templateVersion + && $template->id === $this->templateId ? $this->templateRevision : null; return new self( projectName: $this->projectName, projectSlug: $this->projectSlug, @@ -81,6 +89,7 @@ public function withUpdate( templateVersion: $templateVersion ?: $template->defaultVersion, updatedAt: gmdate('c'), schemaVersion: $this->schemaVersion, + templateRevision: $templateRevision ?? $preservedRevision, ); } @@ -100,6 +109,7 @@ public function toArray(): array 'templatePackageName' => $this->templatePackageName, 'templateRepositoryUrl' => $this->templateRepositoryUrl, 'templateVersion' => $this->templateVersion, + 'templateRevision' => $this->templateRevision, 'updatedAt' => $this->updatedAt, ]; } diff --git a/src/Repository/RepositoryManifestLoader.php b/src/Repository/RepositoryManifestLoader.php index 5e9388c..7a0a449 100644 --- a/src/Repository/RepositoryManifestLoader.php +++ b/src/Repository/RepositoryManifestLoader.php @@ -22,7 +22,7 @@ public function __construct( ) { } - public function loadRequired(string $source, string $ref = 'main'): RepositoryManifest + public function loadRequired(string $source, string $ref = ''): RepositoryManifest { $manifest = $this->load($source, $ref); @@ -33,7 +33,7 @@ public function loadRequired(string $source, string $ref = 'main'): RepositoryMa return $manifest; } - public function load(string $source, string $ref = 'main'): ?RepositoryManifest + public function load(string $source, string $ref = ''): ?RepositoryManifest { $source = trim($source); @@ -42,6 +42,20 @@ public function load(string $source, string $ref = 'main'): ?RepositoryManifest } if (is_dir($source)) { + if (preg_match('/^[a-f0-9]{40}$/D', $ref) === 1) { + foreach (self::MANIFEST_PATHS as $path) { + $process = new \Symfony\Component\Process\Process(['git', + '-C', + $source, + 'show', + $ref . ':' . $path]); + $process->setEnv(['GIT_DIR' => false, 'GIT_WORK_TREE' => false, 'GIT_INDEX_FILE' => false]); + if ($process->run() === 0) { + return $this->parser->parse($process->getOutput(), $source . '@' . $ref . ':' . $path); + } + } + return null; + } return $this->loadFromDirectory($source); } @@ -49,6 +63,9 @@ public function load(string $source, string $ref = 'main'): ?RepositoryManifest return $this->parser->parse((string) file_get_contents($source), $source); } + if (!str_ends_with($source, '.json') && preg_match('/^[a-f0-9]{40}$/D', $ref) !== 1) { + throw new RuntimeException('Remote repository manifests require an explicit immutable Git SHA.'); + } foreach ($this->candidateUrls($source, $ref) as $url) { $json = $this->readUrl($url); @@ -60,7 +77,7 @@ public function load(string $source, string $ref = 'main'): ?RepositoryManifest return null; } - public function loadFromRepository(string $repositoryUrl, string $ref = 'main'): ?RepositoryManifest + public function loadFromRepository(string $repositoryUrl, string $ref = ''): ?RepositoryManifest { return $this->load($repositoryUrl, $ref); } diff --git a/src/Repository/TemplateRevision.php b/src/Repository/TemplateRevision.php new file mode 100644 index 0000000..42bd8a6 --- /dev/null +++ b/src/Repository/TemplateRevision.php @@ -0,0 +1,52 @@ +setWorkingDirectory(sys_get_temp_dir()); + $process->setEnv(['GIT_DIR' => false, 'GIT_WORK_TREE' => false, 'GIT_INDEX_FILE' => false]); + $process->mustRun(); + $matches = []; + preg_match_all('/^([a-f0-9]{40})\s+([^\r\n]+)$/m', $process->getOutput(), $matches, PREG_SET_ORDER); + if ($matches === []) { + throw new RuntimeException('Selected template ref does not exist; no main fallback is allowed.'); + } + $revisions = []; + foreach ($matches as $match) { + $revisions[$match[2]] = $match[1]; + } + foreach ($revisions as $name => $sha) { + if (str_ends_with($name, '^{}')) { + unset($revisions[substr($name, 0, -3)]); + } + } + if (count(array_unique($revisions)) !== 1) { + throw new RuntimeException('Template ref is ambiguous between different Git revisions.'); + } + return (string) reset($revisions); + } +} diff --git a/src/Update/ProjectUpdater.php b/src/Update/ProjectUpdater.php index 96b1a27..78a2eb8 100644 --- a/src/Update/ProjectUpdater.php +++ b/src/Update/ProjectUpdater.php @@ -39,6 +39,7 @@ public function plan( ?string $targetTemplate = null, ?string $templateRepository = null, ?string $templateVersion = null, + ?string $templateRevision = null, ): ProjectUpdatePlan { $level = $this->normalizeLevel($level); $targetType ??= $metadata->profileId; @@ -69,6 +70,7 @@ public function plan( $template, $nextTemplateRepository, $nextTemplateVersion, + $templateRevision, ); $runtimePackages = $this->references($packages->recommendedForProfile($profile->id, false)); $devPackages = $this->references($packages->recommendedForProfile($profile->id, true)); diff --git a/tests/Generator/EnvFileEditorTest.php b/tests/Generator/EnvFileEditorTest.php index c94823f..197f5d1 100644 --- a/tests/Generator/EnvFileEditorTest.php +++ b/tests/Generator/EnvFileEditorTest.php @@ -33,9 +33,32 @@ public function testApplyCreatesEnvFromExampleAndUpdatesProjectValues(): void $env = (string) file_get_contents($projectDir . '/.env'); - self::assertStringContainsString('WP_HOME=https://acme-website.ddev.site', $env); + self::assertStringContainsString('WP_HOME="https://acme-website.ddev.site"', $env); self::assertStringContainsString('WP_SITEURL=${WP_HOME}/wp', $env); - self::assertStringContainsString('WP_ADMIN_PASSWORD=secret-secret', $env); + self::assertStringContainsString('WP_ADMIN_PASSWORD="secret-secret"', $env); + } + + public function testValuesRoundTripThroughRealDotenvParserAndFileIsPrivate(): void + { + $directory = $this->temporaryDirectory(); + $secret = " space # hash $ dollar ' apostrophe \" quote \\ slash\nnewline\rreturn Ä "; + $configuration = new ProjectConfiguration( + template: (new DefaultTemplateCatalog())->first(), + profile: (new DefaultProfileCatalog())->default(), + directory: $directory, + projectName: 'Test', + projectSlug: 'test', + composerPackageName: 'acme/test', + ddevTld: 'ddev.site', + wpAdminUsername: $secret, + wpAdminPassword: $secret, + ); + (new EnvFileEditor())->apply($directory, $configuration); + $values = (new \Symfony\Component\Dotenv\Dotenv())->parse((string) file_get_contents($directory . '/.env')); + self::assertSame($secret, $values['WP_ADMIN_PASSWORD']); + self::assertSame($secret, $values['WP_ADMIN_USERNAME']); + self::assertSame('https://test.ddev.site/wp', $values['WP_SITEURL']); + self::assertSame(0600, fileperms($directory . '/.env') & 0777); } private function temporaryDirectory(): string diff --git a/tests/Generator/FixtureProjectCommandRunner.php b/tests/Generator/FixtureProjectCommandRunner.php index d5fb2da..5f69874 100644 --- a/tests/Generator/FixtureProjectCommandRunner.php +++ b/tests/Generator/FixtureProjectCommandRunner.php @@ -10,6 +10,7 @@ final class FixtureProjectCommandRunner implements CommandRunner { public ?bool $lockPresentDuringSetup = null; + public int $calls = 0; /** @param array $composer */ public function __construct( @@ -19,15 +20,19 @@ public function __construct( public function run(array $command, ?string $cwd, OutputInterface $output): int { + ++$this->calls; unset($output); + if ($cwd !== null && $command[0] === 'git') { + return 0; + } if ($cwd !== null) { $this->lockPresentDuringSetup = is_file($cwd . '/composer.lock'); return 0; } - $projectDir = $command[3]; + $projectDir = $command[count($command) - 1]; mkdir($projectDir, 0777, true); file_put_contents( $projectDir . '/composer.json', diff --git a/tests/Generator/ProjectGeneratorTest.php b/tests/Generator/ProjectGeneratorTest.php index 0da7771..9d091b8 100644 --- a/tests/Generator/ProjectGeneratorTest.php +++ b/tests/Generator/ProjectGeneratorTest.php @@ -62,6 +62,23 @@ public function testPreservesCopiedLockWhenComposerJsonIsUnchanged(): void self::assertFileExists($projectDir . '/composer.lock'); } + public function testSetupRequiresExplicitTrustBeforeAnyCommandOrProjectMutation(): void + { + $directory = $this->temporaryProjectPath(); + $runner = new FixtureProjectCommandRunner($this->starterComposer()); + try { + (new ProjectGenerator($runner))->generate( + $this->configuration($directory, runSetup: true, allowExecution: false), + $this->style(), + ); + self::fail('Untrusted setup accepted.'); + } catch (\RuntimeException $exception) { + self::assertStringContainsString('--allow-template-execution', $exception->getMessage()); + } + self::assertSame(0, $runner->calls); + self::assertDirectoryDoesNotExist($directory); + } + /** @return array */ private function starterComposer(): array { @@ -79,6 +96,7 @@ private function configuration( string $projectDir, array $packages = [], bool $runSetup = false, + bool $allowExecution = true, ): ProjectConfiguration { return new ProjectConfiguration( template: (new DefaultTemplateCatalog())->first(), @@ -92,6 +110,8 @@ private function configuration( wpAdminPassword: 'secret-secret', packages: $packages, runSetup: $runSetup, + allowTemplateExecution: $allowExecution, + templateRevision: str_repeat('a', 40), ); } diff --git a/tests/Project/ProjectMetadataStoreTest.php b/tests/Project/ProjectMetadataStoreTest.php index bc2d3ec..547f3df 100644 --- a/tests/Project/ProjectMetadataStoreTest.php +++ b/tests/Project/ProjectMetadataStoreTest.php @@ -6,6 +6,8 @@ use PHPUnit\Framework\TestCase; use SymPress\Cli\Project\ProjectMetadata; +use SymPress\Cli\Catalog\DefaultProfileCatalog; +use SymPress\Cli\Catalog\DefaultTemplateCatalog; use SymPress\Cli\Project\ProjectMetadataStore; final class ProjectMetadataStoreTest extends TestCase @@ -36,6 +38,26 @@ public function testWriteReadAndFindProjectDirectory(): void self::assertSame($projectDir, $store->findProjectDir($nestedDir)); } + public function testChangedSourceRefInvalidatesPreviousImmutableIdentity(): void + { + $template = (new DefaultTemplateCatalog())->first(); + $profile = (new DefaultProfileCatalog())->default(); + $sha = str_repeat('a', 40); + $metadata = ProjectMetadata::fromArray([ + 'templateId' => $template->id, 'templatePackageName' => $template->packageName, + 'templateRepositoryUrl' => $template->repositoryUrl, 'templateVersion' => $template->defaultVersion, + 'templateRevision' => $sha, + ]); + self::assertSame($sha, $metadata->withUpdate($profile, $template)->templateRevision); + self::assertNull($metadata->withUpdate($profile, $template, templateVersion: '2.0.0')->templateRevision); + self::assertSame(str_repeat('b', 40), $metadata->withUpdate( + $profile, + $template, + templateVersion: '2.0.0', + templateRevision: str_repeat('b', 40), + )->templateRevision); + } + private function temporaryDirectory(): string { $directory = sys_get_temp_dir() . '/sympress-cli-' . bin2hex(random_bytes(6)); diff --git a/tests/Repository/TemplateRevisionTest.php b/tests/Repository/TemplateRevisionTest.php new file mode 100644 index 0000000..1803dfc --- /dev/null +++ b/tests/Repository/TemplateRevisionTest.php @@ -0,0 +1,141 @@ +setEnv(['GIT_DIR' => false, 'GIT_WORK_TREE' => false, 'GIT_INDEX_FILE' => false]); + $process->mustRun(); + return trim($process->getOutput()); + }; + try { + $git(['init', '-b', 'main']); + $git(['config', 'user.name', 'Review fixture']); + $git(['config', 'user.email', 'fixture@example.test']); + file_put_contents($repository . '/composer.json', '{"name":"fixture/template","require":{"php":"^8.5"}}'); + file_put_contents($repository . '/release.txt', 'release snapshot'); + file_put_contents($repository . '/.sympress/cli.json', '{"schemaVersion":1,"profiles":[]}'); + $git(['add', '.']); + $git(['commit', '-m', 'Release']); + $sha = $git(['rev-parse', 'HEAD']); + $git(['tag', 'v1.2.3']); + file_put_contents($repository . '/release.txt', 'mutable main snapshot'); + file_put_contents($repository . '/.sympress/cli.json', '{"schemaVersion":999}'); + $git(['add', '.']); + $git(['commit', '-m', 'Different main']); + self::assertSame($sha, (new TemplateRevision())->resolve($repository, '1.2.3')); + self::assertNotNull((new RepositoryManifestLoader())->loadFromRepository($repository, $sha)); + $configuration = new ProjectConfiguration( + template: new TemplateDefinition( + 'fixture', + 'Fixture', + 'fixture/template', + $repository, + 'Fixture', + '1.2.3', + ), + profile: (new DefaultProfileCatalog())->default(), + directory: $directory . '/project', + projectName: 'Test', + projectSlug: 'test', + composerPackageName: 'acme/test', + ddevTld: 'ddev.site', + wpAdminUsername: 'admin', + wpAdminPassword: 'uniquePasswordSentinel', + templateRevision: $sha, + ); + $output = new BufferedOutput(); + $style = new SymfonyStyle(new ArrayInput([]), $output); + self::assertSame(0, (new ProjectGenerator())->generate($configuration, $style)); + self::assertSame('release snapshot', file_get_contents($directory . '/project/release.txt')); + $metadata = (string) file_get_contents($directory . '/project/.sympress/project.json'); + self::assertStringContainsString($sha, $metadata); + self::assertStringNotContainsString('uniquePasswordSentinel', $output->fetch()); + self::assertDirectoryDoesNotExist($directory . '/project/.git'); + } finally { + (new Filesystem())->remove($directory); + } + } + + public function testSnapshotManifestCannotRedirectSelectedRepository(): void + { + $root = sys_get_temp_dir() . '/cli-redirect-' . bin2hex(random_bytes(5)); + $repository = $root . '/source'; + mkdir($repository . '/.sympress', 0700, true); + $git = static function (array $arguments) use ($repository): string { + $process = new Process(['git', '-C', $repository, ...$arguments]); + $process->setEnv(['GIT_DIR' => false, 'GIT_WORK_TREE' => false, 'GIT_INDEX_FILE' => false]); + $process->mustRun(); + return trim($process->getOutput()); + }; + try { + $git(['init', '-b', 'main']); + $git(['config', 'user.name', 'Review fixture']); + $git(['config', 'user.email', 'fixture@example.test']); + file_put_contents($repository . '/composer.json', '{"name":"fixture/template"}'); + $manifest = json_encode(['schemaVersion' => 1, 'templates' => [[ + 'id' => 'fixture', 'label' => 'Fixture', 'packageName' => 'fixture/template', + 'repositoryUrl' => 'https://example.test/redirect', + 'description' => 'Redirect', 'defaultVersion' => 'main', + ]]], JSON_THROW_ON_ERROR); + file_put_contents($repository . '/.sympress/cli.json', $manifest); + $git(['add', '.']); + $git(['commit', '-m', 'Fixture']); + $configuration = new ProjectConfiguration( + template: new TemplateDefinition( + 'fixture', + 'Fixture', + 'fixture/template', + $repository, + 'Fixture', + 'main', + ), + profile: (new DefaultProfileCatalog())->default(), + directory: $root . '/project', + projectName: 'Test', + projectSlug: 'test', + composerPackageName: 'acme/test', + ddevTld: 'ddev.site', + wpAdminUsername: 'admin', + wpAdminPassword: 'secret', + templateRevision: $git(['rev-parse', 'HEAD']), + ); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('cannot redirect'); + (new ProjectGenerator())->generate( + $configuration, + new SymfonyStyle(new ArrayInput([]), new BufferedOutput()), + ); + } finally { + (new Filesystem())->remove($root); + } + } + + public function testRemoteManifestHasNoMutableMainFallback(): void + { + $this->expectException(\RuntimeException::class); + (new RepositoryManifestLoader())->loadFromRepository('https://github.com/example/template'); + } +} From edd07762d424321493cdd61ee22028243f81e0a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Fri, 2 Oct 2026 00:26:18 +0200 Subject: [PATCH 3/4] Harden review fixes and prepare stable dependency train --- .gitattributes | 21 +++++++++++++++++++++ .github/workflows/dependency-canary.yml | 18 ++++++++++++++++++ .github/workflows/qa.yml | 2 +- composer.json | 3 ++- docs/releases/0.1.0.md | 9 +++++++++ 5 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/dependency-canary.yml create mode 100644 docs/releases/0.1.0.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c27096e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,21 @@ +* text=auto eol=lf + +/.github export-ignore +/tests export-ignore +/docs export-ignore +/AGENTS.md export-ignore +/CONTRIBUTING.md export-ignore +/qa-adoption.json export-ignore +/phpcs.xml export-ignore +/phpcs.xml.dist export-ignore +/phpstan.neon export-ignore +/phpstan.neon.dist export-ignore +/phpunit*.xml* export-ignore +/.php-cs-fixer* export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore +/.env* export-ignore +/auth.json export-ignore +/.npmrc export-ignore +/composer.review*.json export-ignore +/composer.review*.lock export-ignore diff --git a/.github/workflows/dependency-canary.yml b/.github/workflows/dependency-canary.yml new file mode 100644 index 0000000..e032403 --- /dev/null +++ b/.github/workflows/dependency-canary.yml @@ -0,0 +1,18 @@ +name: Dependency compatibility + +on: + schedule: + - cron: '29 4 * * 3' + workflow_dispatch: + +permissions: + contents: read + +jobs: + compatibility: + permissions: + contents: read + issues: write + uses: sympress/workflows/.github/workflows/dependency-canary.yml@177fa0d727b278d2103052ec77c102b4a4c492a0 + with: + php_version: '8.5' diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 08084f2..d209cdc 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -12,6 +12,6 @@ permissions: jobs: qa: - uses: sympress/workflows/.github/workflows/sympress-qa.yml@v1 + uses: sympress/workflows/.github/workflows/sympress-qa.yml@177fa0d727b278d2103052ec77c102b4a4c492a0 with: php_version: '8.5' diff --git a/composer.json b/composer.json index cd88ee7..aa78704 100644 --- a/composer.json +++ b/composer.json @@ -66,5 +66,6 @@ "optimize-autoloader": true, "sort-packages": true }, - "prefer-stable": true + "prefer-stable": true, + "minimum-stability": "stable" } diff --git a/docs/releases/0.1.0.md b/docs/releases/0.1.0.md new file mode 100644 index 0000000..6d07db5 --- /dev/null +++ b/docs/releases/0.1.0.md @@ -0,0 +1,9 @@ +# 0.1.0 release candidate + +This candidate contains the 2026-10-01 review repairs and requires the stable +SymPress dependency train. It has not been published yet. CI consumers use the +reviewed workflows commit 177fa0d727b278d2103052ec77c102b4a4c492a0. + +Local exact-commit Composer archives verify dependency resolution and distribution +contents before publication. They do not imply that prospective tags exist on +GitHub or Packagist. Historical tags remain unchanged. From e08fa3c215a588b33e01bf331519528ec70a1701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Fri, 2 Oct 2026 00:30:31 +0200 Subject: [PATCH 4/4] Preserve selected URL layouts and fail closed on invalid inline input --- src/Generator/EnvFileEditor.php | 9 +++++-- tests/Generator/EnvFileEditorTest.php | 35 +++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/Generator/EnvFileEditor.php b/src/Generator/EnvFileEditor.php index 473be43..18a11b0 100644 --- a/src/Generator/EnvFileEditor.php +++ b/src/Generator/EnvFileEditor.php @@ -38,7 +38,7 @@ public function apply(string $projectDir, ProjectConfiguration $configuration): 'DDEV_PROJECT_NAME' => $configuration->projectSlug, 'DDEV_PROJECT_TLD' => $configuration->ddevTld, 'WP_HOME' => $configuration->wpHome(), - 'WP_SITEURL' => '${WP_HOME}/wp', + 'WP_SITEURL' => '${WP_HOME}', 'WP_ADMIN_USERNAME' => $configuration->wpAdminUsername, 'WP_ADMIN_PASSWORD' => $configuration->wpAdminPassword, ]); @@ -59,7 +59,12 @@ private function update(string $envFile, array $values): void foreach ($lines as $index => $line) { foreach ($values as $key => $value) { - if (str_starts_with((string) $line, $key . '=')) { + if (preg_match('/^(?:export\s+)?' . preg_quote($key, '/') . '\s*=/', trim((string) $line)) === 1) { + if ($key === 'WP_SITEURL') { + // The selected template owns its WordPress root/subdirectory layout. + $seen[$key] = true; + continue; + } $lines[$index] = $key . '=' . ($key === 'WP_SITEURL' ? $value : $this->quote($value)); $seen[$key] = true; } diff --git a/tests/Generator/EnvFileEditorTest.php b/tests/Generator/EnvFileEditorTest.php index 197f5d1..1fc43ad 100644 --- a/tests/Generator/EnvFileEditorTest.php +++ b/tests/Generator/EnvFileEditorTest.php @@ -34,7 +34,7 @@ public function testApplyCreatesEnvFromExampleAndUpdatesProjectValues(): void $env = (string) file_get_contents($projectDir . '/.env'); self::assertStringContainsString('WP_HOME="https://acme-website.ddev.site"', $env); - self::assertStringContainsString('WP_SITEURL=${WP_HOME}/wp', $env); + self::assertStringContainsString('WP_SITEURL=${WP_HOME}' . "\n", $env); self::assertStringContainsString('WP_ADMIN_PASSWORD="secret-secret"', $env); } @@ -57,10 +57,41 @@ public function testValuesRoundTripThroughRealDotenvParserAndFileIsPrivate(): vo $values = (new \Symfony\Component\Dotenv\Dotenv())->parse((string) file_get_contents($directory . '/.env')); self::assertSame($secret, $values['WP_ADMIN_PASSWORD']); self::assertSame($secret, $values['WP_ADMIN_USERNAME']); - self::assertSame('https://test.ddev.site/wp', $values['WP_SITEURL']); + self::assertSame('https://test.ddev.site', $values['WP_SITEURL']); self::assertSame(0600, fileperms($directory . '/.env') & 0777); } + public function testSelectedTemplateRootAndSubdirectorySiteUrlExpressionsArePreserved(): void + { + $expressions = [ + 'WP_SITEURL=${WP_HOME}', + 'WP_SITEURL=${WP_HOME}/wp', + 'export WP_SITEURL = "${WP_HOME}/core"', + ]; + foreach ($expressions as $expression) { + $directory = $this->temporaryDirectory(); + file_put_contents( + $directory . '/.env.example', + 'WP_HOME=https://example.test' . "\n" . $expression . "\n", + ); + $configuration = new ProjectConfiguration( + template: (new DefaultTemplateCatalog())->first(), + profile: (new DefaultProfileCatalog())->default(), + directory: $directory, + projectName: 'Test', + projectSlug: 'test', + composerPackageName: 'acme/test', + ddevTld: 'ddev.site', + wpAdminUsername: 'test', + wpAdminPassword: 'private-test', + ); + (new EnvFileEditor())->apply($directory, $configuration); + $contents = (string) file_get_contents($directory . '/.env'); + self::assertStringContainsString($expression . "\n", $contents); + self::assertSame(1, substr_count($contents, 'WP_SITEURL')); + } + } + private function temporaryDirectory(): string { $directory = sys_get_temp_dir() . '/sympress-cli-' . bin2hex(random_bytes(6));