add p:egg:normalize command (#2379)

This commit is contained in:
Harry Youd
2026-06-18 17:22:13 +01:00
committed by GitHub
parent 0e75027c12
commit d5df688638
6 changed files with 430 additions and 2 deletions

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Console\Commands\Egg;
use App\Enums\EggFormat;
use App\Exceptions\Service\InvalidFileUploadException;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use App\Services\Eggs\Sharing\EggImporterService;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Symfony\Component\Yaml\Yaml;
class NormalizeEggCommand extends Command
{
protected $description = 'Upgrades egg json/yaml to latest format as yaml';
protected $signature = 'p:egg:normalize {file} {--delete-original}';
public function __construct(
private EggExporterService $exporter,
) {
parent::__construct();
}
public function handle(EggImporterService $importer): int
{
$inputFile = $this->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<string, mixed> $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<string, mixed> $eggArray
*/
private function hasFileChanged(string $unparsed, array $eggArray): bool
{
$yaml = $this->eggToYaml($eggArray);
return $unparsed !== $yaml;
}
/**
* @param array<string, mixed> $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);
}
}

View File

@@ -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();

View File

@@ -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) {

View File

@@ -0,0 +1,168 @@
<?php
namespace App\Tests\Unit\Console\Commands\Egg {
use App\Tests\TestCase;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Testing\PendingCommand;
use Symfony\Component\Uid\Uuid;
use function PHPUnit\Framework\assertFileDoesNotExist;
use function PHPUnit\Framework\assertFileEquals;
use function PHPUnit\Framework\assertFileExists;
class NormalizeEggCommandTest extends TestCase
{
private ?string $tmpdir = null;
private ?CarbonInterface $origTime = null;
public function test_upgrade_ptdlv2_to_plcnv3(): void
{
$orig = $this->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);
}
}

View File

@@ -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'

View File

@@ -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"
}
]
}