From e398ee075dc7c6c7a0fb627e5de26ac84c2b68f7 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Wed, 26 Aug 2026 00:39:36 +0200 Subject: [PATCH] Fix unrelated Composer project detection --- app/Launcher/Launcher.php | 4 +- app/Launcher/ProjectDetector.php | 46 +++++++++++++++++++-- tests/Unit/Launcher/LauncherTest.php | 42 +++++++++++++++++++ tests/Unit/Launcher/ProjectDetectorTest.php | 34 +++++++++++++++ 4 files changed, 121 insertions(+), 5 deletions(-) diff --git a/app/Launcher/Launcher.php b/app/Launcher/Launcher.php index df8e8dfc..b434d282 100644 --- a/app/Launcher/Launcher.php +++ b/app/Launcher/Launcher.php @@ -66,7 +66,9 @@ public function __construct( */ public function run(array $argv): ?int { - $project = $this->detect(); + $project = $this->isLauncherCommand($argv) + ? (self::$project ??= $this->detector->detectForLauncherCommand($this->workingDirectory())) + : $this->detect(); // The CLI's own source checkout is itself a Hyde Composer project. When the file // we are running *is* the project's entry point, dispatching would relaunch diff --git a/app/Launcher/ProjectDetector.php b/app/Launcher/ProjectDetector.php index efa86b90..ef9da9a9 100644 --- a/app/Launcher/ProjectDetector.php +++ b/app/Launcher/ProjectDetector.php @@ -39,21 +39,45 @@ final class ProjectDetector * * 1. A composer.json that actually declares Hyde makes it a Composer project root. * 2. Otherwise, portable markers make it a Portable project root, and stop the search. - * 3. Otherwise, we continue with the parent directory. + * 3. Otherwise, a composer.json is a boundary: it is not a Hyde project, so the + * search fails rather than walking into an enclosing project. + * 4. Otherwise, we continue with the parent directory. * * When nothing matches all the way up, the starting directory is a Portable project. - * Portable is always the fallback, and never a fallback from a *broken* Composer project. + * Portable is the fallback only when no Composer manifest was encountered. * - * @throws \App\Launcher\LauncherException If a composer.json is present but unparseable. + * @throws \App\Launcher\LauncherException If a composer.json is present but does not + * declare Hyde, or cannot be parsed. */ public function detect(string $directory): Project + { + return $this->detectProject($directory); + } + + /** + * Detect a project for a command owned by the executable itself. + * + * Launcher-owned commands have always been usable from an unrelated Composer + * project. They boot the embedded application in isolation, so that project is + * represented as the same Portable context used by the previous fallback. A + * malformed manifest still fails: it is not safe to guess about one. + */ + public function detectForLauncherCommand(string $directory): Project + { + return $this->detectProject($directory, allowUnrelatedComposer: true); + } + + private function detectProject(string $directory, bool $allowUnrelatedComposer = false): Project { $start = Project::canonicalize($directory); $current = $start; for ($depth = 0; $depth < self::MAX_DEPTH; $depth++) { - if ($this->declaresHyde($current)) { + $manifest = $current.'/composer.json'; + $hasManifest = is_file($manifest); + + if ($hasManifest && $this->declaresHyde($current)) { return Project::composer($current, $start); } @@ -61,6 +85,20 @@ public function detect(string $directory): Project return new Project(ProjectType::Portable, $current, $start); } + if ($hasManifest) { + if ($allowUnrelatedComposer) { + return Project::portable($start); + } + + throw new LauncherException(<<and($dispatcher->called)->toBeFalse(); }); +it('refuses project commands inside an unrelated composer project', function () { + $path = TemporaryProject::directory(); + + TemporaryProject::write($path, [ + 'composer.json' => '{"name":"laravel/laravel","require":{"laravel/framework":"^13.0"}}', + ]); + + putenv("HYDE_WORKING_DIR=$path"); + + expect(fn () => (new Launcher())->run(['hyde', 'build'])) + ->toThrow( + LauncherException::class, + 'This is a Composer project, but it does not declare Hyde.' + ); +}); + +it('keeps launcher-owned commands available inside an unrelated composer project', function () { + $path = TemporaryProject::directory(); + + TemporaryProject::write($path, [ + 'composer.json' => '{"name":"laravel/laravel","require":{"laravel/framework":"^13.0"}}', + ]); + + $dispatcher = new class() extends ProjectDispatcher + { + public bool $called = false; + + public function dispatch(Project $project, array $arguments = []): int + { + $this->called = true; + + return 0; + } + }; + + putenv("HYDE_WORKING_DIR=$path"); + + expect((new Launcher(new ProjectDetector(), $dispatcher))->run(['hyde', 'info']))->toBeNull() + ->and($dispatcher->called)->toBeFalse(); +}); + it('does not dispatch a CLI-owned command inside a composer project', function () { $path = TemporaryProject::composer(['hyde' => "detect($path)->type)->toBe(ProjectType::Composer); }); +it('refuses a composer project that does not declare Hyde', function () { + $path = TemporaryProject::directory(); + + TemporaryProject::write($path, [ + 'composer.json' => json_encode([ + 'name' => 'laravel/laravel', + 'require' => [ + 'laravel/framework' => '^13.0', + ], + ]), + ]); + + expect(fn () => (new ProjectDetector())->detect($path)) + ->toThrow( + LauncherException::class, + 'This is a Composer project, but it does not declare Hyde.' + ); +}); + it('treats a require-dev only requirement as a composer project', function () { $path = TemporaryProject::composer(manifest: '{"name": "acme/site", "require-dev": {"hyde/framework": "^2.0"}}'); @@ -158,6 +177,21 @@ ->and($project->workingDirectory)->toBe($path.'/docs/guides'); }); +it('does not walk past an unrelated composer project', function () { + $path = TemporaryProject::directory(); + + TemporaryProject::write($path, [ + 'composer.json' => '{"name":"laravel/laravel","require":{"laravel/framework":"^13.0"}}', + 'app/.gitkeep' => '', + ]); + + expect(fn () => (new ProjectDetector())->detect($path.'/app')) + ->toThrow( + LauncherException::class, + 'This is a Composer project, but it does not declare Hyde.' + ); +}); + it('does not attribute a nested portable site to an enclosing composer project', function () { $path = TemporaryProject::composer();