diff --git a/app/Console/Commands/Egg/NormalizeEggCommand.php b/app/Console/Commands/Egg/NormalizeEggCommand.php new file mode 100644 index 000000000..cbe60ca3d --- /dev/null +++ b/app/Console/Commands/Egg/NormalizeEggCommand.php @@ -0,0 +1,152 @@ +argument('file'); + $unparsed = file_get_contents($inputFile); + if ($unparsed === false) { + $this->error("Failed to read file: {$inputFile}"); + + return Command::FAILURE; + } + $extension = strtolower(pathinfo($inputFile, PATHINFO_EXTENSION)); + + $this->info("Importing {$inputFile}"); + + $format = match ($extension) { + 'yaml', 'yml' => EggFormat::YAML, + 'json' => EggFormat::JSON, + default => null, + }; + if (is_null($format)) { + $this->error(" -> unsupported extension {$extension} for {$inputFile}"); + + return Command::FAILURE; + } + + try { + $eggArray = $importer->parse($unparsed, $format); + } catch (InvalidFileUploadException) { + $this->error(" -> unsupported file version, is it actually an egg? ({$inputFile})"); + + return Command::FAILURE; + } + + if ( + !array_key_exists('meta', $eggArray) || + !is_array($eggArray['meta']) + ) { + $this->error(" -> does not contain existing meta or meta is not array, is it actually an egg? ({$inputFile})"); + + return Command::FAILURE; + } + if (!array_key_exists('exported_at', $eggArray)) { + $this->error(" -> does not contain existing exported_at, is it actually an egg? ({$inputFile})"); + + return Command::FAILURE; + } + if ( + !array_key_exists('update_url', $eggArray['meta']) || + !is_string($eggArray['meta']['update_url']) + ) { + $this->error(" -> does not contain existing meta.update_url or is not a string, is it actually an egg? ({$inputFile})"); + + return Command::FAILURE; + } + + // We upgraded our in-memory array when we imported, so set to latest version before export + $eggArray['meta']['version'] = Egg::EXPORT_VERSION; + $eggArray['meta']['update_url'] = self::replaceExtension($eggArray['meta']['update_url']); + self::fixVariableRules($eggArray); + + $outputFile = self::replaceExtension($inputFile); + + if ($outputFile === $inputFile && !$this->hasFileChanged($unparsed, $eggArray)) { + $this->info(' -> no changes required'); + + return Command::SUCCESS; + } else { + $eggArray['exported_at'] = Carbon::now()->toAtomString(); + } + + $this->info(" -> exporting to {$outputFile}"); + $yaml = $this->eggToYaml($eggArray); + if (file_put_contents($outputFile, $yaml) === false) { + $this->error(" -> failed to write output file: {$outputFile}"); + + return Command::FAILURE; + } + + if ($this->option('delete-original') && $outputFile !== $inputFile) { + $this->info(' -> deleting input file as requested'); + if (!unlink($inputFile)) { + $this->warn(" -> failed to delete original file: {$inputFile}"); + } + } + + return Command::SUCCESS; + } + + private static function replaceExtension(string $path): string + { + return preg_replace('/^(.*\.)(?:yml|json|yaml)$/', '$1yaml', $path); + } + + /** + * @param array $eggArray + */ + private static function fixVariableRules(array &$eggArray): void + { + if (!array_key_exists('variables', $eggArray)) { + return; + } + foreach ($eggArray['variables'] as &$var) { + unset($var['field_type']); + if (!array_key_exists('rules', $var)) { + continue; + } + $var['rules'] = is_array($var['rules']) ? $var['rules'] : explode('|', $var['rules']); + } + } + + /** + * @param array $eggArray + */ + private function hasFileChanged(string $unparsed, array $eggArray): bool + { + $yaml = $this->eggToYaml($eggArray); + + return $unparsed !== $yaml; + } + + /** + * @param array $eggArray + */ + private function eggToYaml(array $eggArray): string + { + return Yaml::dump($this->exporter->yamlExport($eggArray), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK | Yaml::DUMP_OBJECT_AS_MAP); + } +} diff --git a/app/Services/Eggs/Sharing/EggExporterService.php b/app/Services/Eggs/Sharing/EggExporterService.php index 579bb0dff..4f702ca82 100644 --- a/app/Services/Eggs/Sharing/EggExporterService.php +++ b/app/Services/Eggs/Sharing/EggExporterService.php @@ -78,7 +78,7 @@ class EggExporterService return null; } - protected function yamlExport(mixed $data): mixed + public function yamlExport(mixed $data): mixed { if ($data instanceof Collection) { $data = $data->all(); diff --git a/app/Services/Eggs/Sharing/EggImporterService.php b/app/Services/Eggs/Sharing/EggImporterService.php index 511100590..30da81522 100644 --- a/app/Services/Eggs/Sharing/EggImporterService.php +++ b/app/Services/Eggs/Sharing/EggImporterService.php @@ -146,7 +146,7 @@ class EggImporterService * * @throws InvalidFileUploadException|JsonException */ - protected function parse(string $content, EggFormat $format): array + public function parse(string $content, EggFormat $format): array { try { $parsed = match ($format) { diff --git a/tests/Unit/Console/Commands/Egg/NormalizerEggCommandTest.php b/tests/Unit/Console/Commands/Egg/NormalizerEggCommandTest.php new file mode 100644 index 000000000..40c1a7b93 --- /dev/null +++ b/tests/Unit/Console/Commands/Egg/NormalizerEggCommandTest.php @@ -0,0 +1,168 @@ +tmpdir . '/egg-ptdlv2.json'; + $this->copyExampleFile('egg-ptdlv2.json'); + $this->runCommand($orig) + ->expectsOutputToContain(' -> exporting to ') + ->run(); + + assertFileEquals( + dirname(__FILE__) . '/egg-plcnv3-example.yaml', + substr($orig, 0, -4) . 'yaml', + ); + } + + /** + * @depends test_upgrade_ptdlv2_to_plcnv3 + */ + public function test_idempotency(): void + { + $orig = $this->tmpdir . '/egg-ptdlv2.json'; + $this->copyExampleFile('egg-ptdlv2.json'); + $this->runCommand($orig) + ->expectsOutputToContain(' -> exporting to ') + ->run(); + + $newFile = substr($orig, 0, -4) . 'yaml'; + assertFileExists($newFile); + + copy($newFile, $this->tmpdir . '/copy.yaml'); + + // turn off datetime mocking (i.e. get a new datetime) + // so we can ensure a new datetime is only written to the file + // if the content has changed + Carbon::setTestNow(null); + + $this->runCommand($newFile) + ->expectsOutputToContain(' -> no changes required') + ->doesntExpectOutputToContain(' -> exporting to ') + ->run(); + + assertFileEquals( + $newFile, + $this->tmpdir . '/copy.yaml', + ); + } + + public function test_delete_original(): void + { + $orig = $this->tmpdir . '/egg-ptdlv2.json'; + $this->copyExampleFile('egg-ptdlv2.json'); + + $this->runCommand(' --delete-original ' . $orig) + ->expectsOutputToContain(' -> exporting to ') + ->expectsOutputToContain(' -> deleting input file as requested') + ->run(); + + $newFile = substr($orig, 0, -4) . 'yaml'; + + assertFileExists($newFile); + assertFileDoesNotExist($orig); + } + + public function test_dont_delete_if_error(): void + { + $orig = $this->tmpdir . '/egg-ptdlv2.json'; + $this->copyExampleFile('egg-ptdlv2.json'); + + $GLOBALS['SHOULD_MOCK_FILE_PUT'] = true; + + $this->artisan('p:egg:normalize --delete-original ' . $orig) + ->expectsOutputToContain('Importing ') + ->expectsOutputToContain(' -> failed to write output file') + ->assertFailed() + ->run(); + + unset($GLOBALS['SHOULD_MOCK_FILE_PUT']); + + $newFile = substr($orig, 0, -4) . 'yaml'; + + assertFileDoesNotExist($newFile); + assertFileExists($orig); + } + + private function runCommand(string $arguments): PendingCommand + { + return $this->artisan('p:egg:normalize ' . $arguments) + ->expectsOutputToContain('Importing ') + ->assertSuccessful(); + } + + private function copyExampleFile(string $filename): void + { + if (!copy( + dirname(__FILE__) . '/' . $filename, + $this->tmpdir . '/' . $filename) + ) { + throw new \Exception('unable to copy json file to tmpdir'); + } + } + + protected function setUp(): void + { + parent::setUp(); + + register_shutdown_function([$this, 'shutdownHandler']); + + $this->tmpdir = sys_get_temp_dir() . '/' . Uuid::v4()->toRfc4122(); + if (!mkdir($this->tmpdir)) { + throw new \Exception('unable to create tmp dir'); + } + + // save the datetime set by the test framework to restore it after out tests + $this->origTime = Carbon::getTestNow(); + Carbon::setTestNow(Carbon::createFromDate(2026, 06, 01)->setTime(01, 02, 03)); + } + + protected function tearDown(): void + { + parent::tearDown(); + + Carbon::setTestNow($this->origTime); + + if (!is_null($this->tmpdir)) { + foreach (glob("{$this->tmpdir}/*") as $filename) { + unlink($filename); + } + rmdir($this->tmpdir); + } + } + + public function shutdownHandler(): void + { + // try and delete our tmp files to leave everything clean + error_get_last() && $this->tearDown(); + } + } +} + +namespace App\Console\Commands\Egg { + /** @phpstan-ignore missingType.parameter */ + function file_put_contents(string $filename, mixed $data, int $flags = 0, $context = null): int|false + { + if (($GLOBALS['SHOULD_MOCK_FILE_PUT'] ?? false) === true) { + return false; + } + + return \file_put_contents($filename, $data, $flags, $context); + } +} diff --git a/tests/Unit/Console/Commands/Egg/egg-plcnv3-example.yaml b/tests/Unit/Console/Commands/Egg/egg-plcnv3-example.yaml new file mode 100644 index 000000000..827e6e283 --- /dev/null +++ b/tests/Unit/Console/Commands/Egg/egg-plcnv3-example.yaml @@ -0,0 +1,54 @@ +_comment: 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL' +meta: + version: PLCN_v3 + update_url: 'https://raw.githubusercontent.com/pelican-dev/panel/refs/heads/main/tests/Unit/Console/Command/Egg/egg-ptdlv2.yaml' +exported_at: '2026-06-01T01:02:03+00:00' +name: 'Egg Example' +author: example@example.com +uuid: 21f1c702-953a-45ca-ae58-c205e8a4ea7e +description: 'Egg for test purposes' +tags: + - test +features: + - eula + - java_version + - pid_limit +docker_images: + 'ghcr.io/pelican-eggs/yolks:java_8': 'ghcr.io/pelican-eggs/yolks:java_8' + 'ghcr.io/pelican-eggs/yolks:java_11': 'ghcr.io/pelican-eggs/yolks:java_11' + 'ghcr.io/pelican-eggs/yolks:java_16': 'ghcr.io/pelican-eggs/yolks:java_16' + 'ghcr.io/pelican-eggs/yolks:java_17': 'ghcr.io/pelican-eggs/yolks:java_17' +file_denylist: { } +config: + files: + server.properties: + parser: properties + find: + server-ip: 0.0.0.0 + server-port: '{{server.allocations.default.port}}' + startup: + done: ')! For help, type ' + logs: { } + stop: stop +scripts: + installation: + script: |- + #!/bin/ash + echo test some escaped backslashes (/), then proper backslashes (/) + container: 'ghcr.io/pelican-eggs/installers:alpine' + entrypoint: ash +variables: + - + name: 'Modpack Version' + description: '' + env_variable: MODPACK_VERSION + default_value: 1.0.0 + user_viewable: true + user_editable: true + rules: + - required + - string + - 'max:20' + sort: null +startup_commands: + Default: 'java -Xms128M -Xmx{{SERVER_MEMORY}}M -jar minecraft.jar' diff --git a/tests/Unit/Console/Commands/Egg/egg-ptdlv2.json b/tests/Unit/Console/Commands/Egg/egg-ptdlv2.json new file mode 100644 index 000000000..012103f63 --- /dev/null +++ b/tests/Unit/Console/Commands/Egg/egg-ptdlv2.json @@ -0,0 +1,54 @@ +{ + "_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL", + "meta": { + "version": "PTDL_v2", + "update_url": "https://raw.githubusercontent.com/pelican-dev/panel/refs/heads/main/tests/Unit/Console/Command/Egg/egg-ptdlv2.json" + }, + "exported_at": "2026-06-01T01:02:03+00:00", + "name": "Egg Example", + "author": "example@example.com", + "uuid": "21f1c702-953a-45ca-ae58-c205e8a4ea7e", + "description": "Egg for test purposes", + "tags": [ + "test" + ], + "features": [ + "eula", + "java_version", + "pid_limit" + ], + "docker_images": { + "ghcr.io\/pelican-eggs\/yolks:java_8": "ghcr.io\/pelican-eggs\/yolks:java_8", + "ghcr.io\/pelican-eggs\/yolks:java_11": "ghcr.io\/pelican-eggs\/yolks:java_11", + "ghcr.io\/pelican-eggs\/yolks:java_16": "ghcr.io\/pelican-eggs\/yolks:java_16", + "ghcr.io\/pelican-eggs\/yolks:java_17": "ghcr.io\/pelican-eggs\/yolks:java_17" + }, + "file_denylist": [], + "startup": "java -Xms128M -Xmx{{SERVER_MEMORY}}M -jar minecraft.jar", + "config": { + "files": "{\r\n \"server.properties\": {\r\n \"parser\": \"properties\",\r\n \"find\": {\r\n \"server-ip\": \"0.0.0.0\",\r\n \"server-port\": \"{{server.build.default.port}}\"\r\n }\r\n }\r\n}", + "startup": "{\r\n \"done\": \")! For help, type \"\r\n}", + "logs": "{}", + "stop": "stop" + }, + "scripts": { + "installation": { + "script": "#!\/bin\/ash\r\necho test some escaped backslashes (\/), then proper backslashes (/)", + "container": "ghcr.io\/pelican-eggs\/installers:alpine", + "entrypoint": "ash" + } + }, + "variables": [ + { + "name": "Modpack Version", + "description": "", + "env_variable": "MODPACK_VERSION", + "default_value": "1.0.0", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:20", + "sort": null, + "field_type": "text" + } + ] +} \ No newline at end of file