diff --git a/src/CLI/CLI.php b/src/CLI/CLI.php index b213bd1..8e5e1f5 100644 --- a/src/CLI/CLI.php +++ b/src/CLI/CLI.php @@ -364,7 +364,7 @@ public function isDefined(string $arg): bool /** * Creates pretty json */ - public function json(array $data = []): string + public function json(array|object $data = []): string { return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } diff --git a/src/CLI/Commands/Migrate/To/PublicFolder.php b/src/CLI/Commands/Migrate/To/PublicFolder.php index db26848..241cb2d 100644 --- a/src/CLI/Commands/Migrate/To/PublicFolder.php +++ b/src/CLI/Commands/Migrate/To/PublicFolder.php @@ -8,6 +8,7 @@ use Kirby\CLI\Command; use Kirby\Filesystem\Dir; use Kirby\Filesystem\F; +use stdClass; class PublicFolder extends Command { @@ -37,6 +38,7 @@ public static function command(CLI $cli): void static::makeIndexPHP($cli, $publicDir); static::removeOldIndexPHP($cli); + static::updateComposerConfig($cli); $cli->br(); $cli->success('Migrated to a public folder setup'); @@ -49,6 +51,15 @@ protected static function confirmMigration(CLI $cli): void $cli->br(); } + /** + * The document root that the built-in server has to + * serve after the migration. Null if there is none. + */ + protected static function documentRoot(CLI $cli): string|null + { + return basename(static::publicDir($cli->dir())); + } + protected static function makeIndexPHP(CLI $cli, string $publicDir) { $template = $cli->root('commands.core') . '/migrate/to/_templates/index.public.simple.php'; @@ -171,4 +182,130 @@ protected static function removeOldIndexPHP(CLI $cli): void } } + /** + * Points the start script in the composer.json + * at the document root of the new setup + */ + protected static function updateComposerConfig(CLI $cli): void + { + $file = $cli->dir() . '/composer.json'; + + if (is_file($file) === false) { + return; + } + + $contents = F::read($file); + + if (is_string($contents) === false) { + $cli->out('🚨 The composer.json could not be read'); + return; + } + + // decoding into objects keeps empty objects, like an + // `"extra": {}`, from turning into arrays when writing back + $composer = json_decode($contents); + + if ($composer instanceof stdClass === false) { + $cli->out('🚨 The composer.json could not be parsed'); + return; + } + + $start = $composer->scripts->start ?? null; + + if ($start === null) { + return; + } + + // the start script can be a single command or a list of commands + $commands = is_string($start) === true ? [$start] : $start; + + if (is_array($commands) === false) { + return; + } + + $root = static::documentRoot($cli); + $server = false; + $updated = false; + + foreach ($commands as $key => $command) { + if (is_string($command) === false) { + continue; + } + + $new = static::updateStartCommand($command, $root); + + // the command does not start a server + if ($new === null) { + continue; + } + + $server = true; + + if ($new !== $command) { + $commands[$key] = $new; + $updated = true; + } + } + + if ($server === false) { + $cli->out('🚨 The start script in the composer.json could not be updated. Please set the document root manually.'); + return; + } + + // the start script already serves the right document root + if ($updated === false) { + return; + } + + $composer->scripts->start = is_string($start) === true ? $commands[0] : $commands; + + $json = $cli->json($composer); + + // keep the trailing newline of the original file + if (str_ends_with($contents, "\n") === true) { + $json .= "\n"; + } + + if (F::write($file, $json) === true) { + $cli->out('✅ The composer.json has been updated'); + } else { + $cli->out('🚨 The composer.json could not be updated'); + } + } + + /** + * Sets the document root of a command that starts the + * built-in server, or removes it if there is no document + * root. Returns null if the command starts no server. + */ + protected static function updateStartCommand( + string $command, + string|null $root + ): string|null { + $parts = preg_split('!\s+!', trim($command)) ?: []; + + // only the built-in server takes a document root + if (in_array('-S', $parts, true) === false) { + return null; + } + + // remove the current document root + $target = array_search('-t', $parts, true); + + if ($target !== false) { + array_splice($parts, $target, 2); + } + + // add the new document root right after the host and port + if ($root !== null) { + $server = array_search('-S', $parts, true); + + if ($server !== false) { + array_splice($parts, $server + 2, 0, ['-t', $root]); + } + } + + return implode(' ', $parts); + } + } diff --git a/src/CLI/Commands/Migrate/To/RootFolder.php b/src/CLI/Commands/Migrate/To/RootFolder.php index 2be5e55..6370b86 100644 --- a/src/CLI/Commands/Migrate/To/RootFolder.php +++ b/src/CLI/Commands/Migrate/To/RootFolder.php @@ -42,11 +42,21 @@ public static function command(CLI $cli): void static::makeIndexPHP($cli, $dir); static::removePublicDir($cli, $publicDir); + static::updateComposerConfig($cli); $cli->br(); $cli->success('Migrated to a root folder setup'); } + /** + * A root folder setup is served from the project + * root, so the start script needs no document root + */ + protected static function documentRoot(CLI $cli): string|null + { + return null; + } + protected static function makeIndexPHP(CLI $cli, string $dir) { $template = $cli->root('commands.core') . '/migrate/to/_templates/index.root.simple.php'; diff --git a/tests/CLI/Commands/Migrate/To/PublicFolderProxy.php b/tests/CLI/Commands/Migrate/To/PublicFolderProxy.php new file mode 100644 index 0000000..4bbc075 --- /dev/null +++ b/tests/CLI/Commands/Migrate/To/PublicFolderProxy.php @@ -0,0 +1,30 @@ +assertSame([], PublicFolder::args()); @@ -19,4 +58,173 @@ public function testDescription(): void { $this->assertSame('Switch to a public folder setup', PublicFolder::description()); } + + public function testDocumentRoot(): void + { + $cli = $this->createCLI(); + $this->assertSame('public', PublicFolderProxy::documentRoot($cli)); + } + + #[DataProvider('startCommandProvider')] + public function testUpdateStartCommand(string $command, string|null $expected): void + { + $this->assertSame($expected, PublicFolderProxy::updateStartCommand($command, 'public')); + } + + public function testUpdateComposerConfig(): void + { + $cli = $this->createCLIWithKirby(); + $this->setupOutputCapture(); + + chdir($root = $this->kirbyRoot()); + + $before = implode("\n", [ + '{', + ' "extra": {},', + ' "scripts": {', + ' "start": [', + ' "Composer\\\\Config::disableProcessTimeout",', + ' "@php -S localhost:8000 kirby/router.php"', + ' ]', + ' }', + '}', + '' + ]); + + file_put_contents($root . '/composer.json', $before); + + PublicFolderProxy::updateComposerConfig($cli); + + $after = file_get_contents($root . '/composer.json'); + + $this->assertStringContainsString('-S localhost:8000 -t public', $after); + + // empty objects survive the round trip + $this->assertStringContainsString('"extra": {}', $after); + + // so does the trailing newline + $this->assertStringEndsWith("\n", $after); + + $this->assertOutputContains('The composer.json has been updated'); + } + + public function testUpdateComposerConfigWithStringScript(): void + { + $cli = $this->createCLIWithKirby(); + $this->setupOutputCapture(); + + chdir($root = $this->kirbyRoot()); + + $before = implode("\n", [ + '{', + ' "scripts": {', + ' "start": "@php -S localhost:8000 kirby/router.php"', + ' }', + '}', + '' + ]); + + file_put_contents($root . '/composer.json', $before); + + PublicFolderProxy::updateComposerConfig($cli); + + $composer = json_decode(file_get_contents($root . '/composer.json'), true); + + $this->assertSame( + '@php -S localhost:8000 -t public kirby/router.php', + $composer['scripts']['start'] + ); + } + + public function testUpdateComposerConfigWithoutChanges(): void + { + $cli = $this->createCLIWithKirby(); + $this->setupOutputCapture(); + + chdir($root = $this->kirbyRoot()); + + $before = implode("\n", [ + '{', + ' "extra": {},', + ' "scripts": {', + ' "start": "@php -S localhost:8000 -t public kirby/router.php"', + ' }', + '}', + '' + ]); + + file_put_contents($root . '/composer.json', $before); + + PublicFolderProxy::updateComposerConfig($cli); + + // the file keeps its own formatting because it is never written + $this->assertSame($before, file_get_contents($root . '/composer.json')); + $this->assertOutputNotContains('The composer.json has been updated'); + } + + public function testUpdateComposerConfigWithoutServerCommand(): void + { + $cli = $this->createCLIWithKirby(); + $this->setupOutputCapture(); + + chdir($root = $this->kirbyRoot()); + + $before = implode("\n", [ + '{', + ' "scripts": {', + ' "start": "vite dev"', + ' }', + '}', + '' + ]); + + file_put_contents($root . '/composer.json', $before); + + PublicFolderProxy::updateComposerConfig($cli); + + $this->assertSame($before, file_get_contents($root . '/composer.json')); + $this->assertOutputContains('Please set the document root manually'); + } + + public function testUpdateComposerConfigWithInvalidJson(): void + { + $cli = $this->createCLIWithKirby(); + $this->setupOutputCapture(); + + chdir($root = $this->kirbyRoot()); + + file_put_contents($root . '/composer.json', '{'); + + PublicFolderProxy::updateComposerConfig($cli); + + $this->assertOutputContains('The composer.json could not be parsed'); + } + + public function testUpdateComposerConfigWithoutFile(): void + { + $cli = $this->createCLI(); + $this->setupOutputCapture(); + + // there is no composer.json in the test directory + chdir(__DIR__); + + PublicFolderProxy::updateComposerConfig($cli); + + $this->assertSame('', $this->getOutput()); + } + + protected function setUp(): void + { + $this->cwd = getcwd(); + } + + protected function tearDown(): void + { + if ($this->cwd !== null) { + chdir($this->cwd); + $this->cwd = null; + } + + parent::tearDown(); + } } diff --git a/tests/CLI/Commands/Migrate/To/RootFolderProxy.php b/tests/CLI/Commands/Migrate/To/RootFolderProxy.php new file mode 100644 index 0000000..503bd18 --- /dev/null +++ b/tests/CLI/Commands/Migrate/To/RootFolderProxy.php @@ -0,0 +1,30 @@ +assertSame([], RootFolder::args()); @@ -19,4 +53,68 @@ public function testDescription(): void { $this->assertSame('Switch to a root folder setup', RootFolder::description()); } + + public function testDocumentRoot(): void + { + $cli = $this->createCLI(); + $this->assertNull(RootFolderProxy::documentRoot($cli)); + } + + #[DataProvider('startCommandProvider')] + public function testUpdateStartCommand(string $command, string|null $expected): void + { + $this->assertSame($expected, RootFolderProxy::updateStartCommand($command, null)); + } + + public function testUpdateComposerConfig(): void + { + $cli = $this->createCLIWithKirby(); + $this->setupOutputCapture(); + + chdir($root = $this->kirbyRoot()); + + $before = implode("\n", [ + '{', + ' "extra": {},', + ' "scripts": {', + ' "start": [', + ' "Composer\\\\Config::disableProcessTimeout",', + ' "@php -S localhost:8000 -t public_html kirby/router.php"', + ' ]', + ' }', + '}', + '' + ]); + + file_put_contents($root . '/composer.json', $before); + + RootFolderProxy::updateComposerConfig($cli); + + $composer = json_decode($after = file_get_contents($root . '/composer.json'), true); + + $this->assertSame( + '@php -S localhost:8000 kirby/router.php', + $composer['scripts']['start'][1] + ); + + // empty objects survive the round trip + $this->assertStringContainsString('"extra": {}', $after); + + $this->assertOutputContains('The composer.json has been updated'); + } + + protected function setUp(): void + { + $this->cwd = getcwd(); + } + + protected function tearDown(): void + { + if ($this->cwd !== null) { + chdir($this->cwd); + $this->cwd = null; + } + + parent::tearDown(); + } }