From 13b61ce3a8b7829d6e9ca2f73418b29675c8f49c Mon Sep 17 00:00:00 2001 From: Christopher Vagnetoft Date: Sun, 28 Dec 2025 15:26:33 +0100 Subject: [PATCH] Initial commit --- README.md | 16 ++- src/Command/PackageBuildCommand.php | 50 ++++++++- src/Command/PackageLoginCommand.php | 22 +++- src/Command/PackagePublishCommand.php | 36 ++++++- src/Command/PackageUnpublishCommand.php | 44 +++++++- src/CommandProvider.php | 2 +- src/Package/PackageBuilder.php | 109 +++++++++++++++++++ src/Package/PackagePublisher.php | 35 ++++++ src/Project/ProjectInfo.php | 53 +++++++++ src/Registry/Credentials/InsecureStore.php | 77 +++++++++++++ src/Registry/Credentials/StoreInterface.php | 8 ++ src/Registry/Gitea/GiteaProvider.php | 21 ++++ src/Registry/Gitea/GiteaRegistry.php | 113 ++++++++++++++++++++ src/Registry/ProviderTrait.php | 15 +++ src/Registry/RegistryFactory.php | 34 ++++++ src/Registry/RegistryInterface.php | 13 +++ src/Registry/RegistryProviderInterface.php | 8 ++ src/Registry/RegistryTrait.php | 15 +++ 18 files changed, 652 insertions(+), 19 deletions(-) create mode 100644 src/Package/PackageBuilder.php create mode 100644 src/Package/PackagePublisher.php create mode 100644 src/Project/ProjectInfo.php create mode 100644 src/Registry/Credentials/InsecureStore.php create mode 100644 src/Registry/Credentials/StoreInterface.php create mode 100644 src/Registry/Gitea/GiteaProvider.php create mode 100644 src/Registry/Gitea/GiteaRegistry.php create mode 100644 src/Registry/ProviderTrait.php create mode 100644 src/Registry/RegistryFactory.php create mode 100644 src/Registry/RegistryInterface.php create mode 100644 src/Registry/RegistryProviderInterface.php create mode 100644 src/Registry/RegistryTrait.php diff --git a/README.md b/README.md index 3147ddb..0d27271 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Composer Package Tools -This is a composer plugin to create zipballs, and publish to Gitea. +This is a composer plugin to create zipballs, and publish them to Gitea. ## Usage @@ -11,21 +11,19 @@ $ composer require --dev noccylabs/composer-package $ composer require --global noccylabs/composer-package # Authenticate to remotes -$ composer package:login gitea:myserver.tld -Username: myusername -Token: +$ composer package:login myserver.tld # Create package by cloning into temporary directory $ composer package # Create package from working directory -$ composer package --unclean +$ composer package --dirty # Create and publish without saving zipball $ composer package --publish gitea:myserver.tld/myowner # Publish latest zipball $ composer package:publish gitea:myserver.tld/myowner - -# Unpublish a version -$ composer package:unpublish gitea:myserver.tld/myowner myvendor/mypackage@1.2.3 - ``` + +## Notes + +- All builds are dirty right now. Cloning logic is to be implemented. \ No newline at end of file diff --git a/src/Command/PackageBuildCommand.php b/src/Command/PackageBuildCommand.php index 302c61a..3242186 100644 --- a/src/Command/PackageBuildCommand.php +++ b/src/Command/PackageBuildCommand.php @@ -5,6 +5,13 @@ namespace NoccyLabs\Composer\PackagePlugin\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Composer\Command\BaseCommand; +use NoccyLabs\Composer\PackagePlugin\Package\PackageBuilder; +use NoccyLabs\Composer\PackagePlugin\Package\PackagePublisher; +use NoccyLabs\Composer\PackagePlugin\Project\ProjectInfo; +use NoccyLabs\Composer\PackagePlugin\Registry\Credentials\InsecureStore; +use NoccyLabs\Composer\PackagePlugin\Registry\Gitea\GiteaProvider; +use NoccyLabs\Composer\PackagePlugin\Registry\RegistryFactory; +use Symfony\Component\Console\Input\InputOption; class PackageBuildCommand extends BaseCommand { @@ -13,12 +20,51 @@ class PackageBuildCommand extends BaseCommand $this ->setName('package:build') ->setAliases([ "package" ]) - ->setDescription("Package the library into a zipball, or publish directly"); + ->setDescription("Package the library into a zipball, or publish directly") + ->addOption("publish", null, InputOption::VALUE_REQUIRED, "Publish to registry immediately after building") + ->addOption("dirty", null, InputOption::VALUE_NONE, "Build directly from source without cloning") + ->addOption("force", null, InputOption::VALUE_NONE, "Build even if the output file already exists") + ; } protected function execute(InputInterface $input, OutputInterface $output): int { - $output->writeln('Executing'); + $registry = $input->getOption("publish"); + + $builder = new PackageBuilder($output); + + $project = ProjectInfo::read(); + + if ($registry && file_exists($project->filename)) { + $output->writeln("Package file already exists. Pass --force to rebuild it."); + } else { + $builder->build($project, $input->getOption("force")); + } + + if ($registry) { + $credentials = new InsecureStore(); + + $providers = [ + 'gitea' => new GiteaProvider($credentials) + ]; + + $factory = new RegistryFactory($providers); + $publisher = new PackagePublisher($factory, $output); + + if (!$registry) { + $output->writeln([ + "Missing registry to publish to. Please specify the registry like this:", + "", + " gitea: - to publish to server as the default user", + " gitea:/ - to publish to server as owner", + "" + ]); + return self::INVALID; + } + + $publisher->publish($project, $registry); + + } return 0; } diff --git a/src/Command/PackageLoginCommand.php b/src/Command/PackageLoginCommand.php index 817cd48..b5a465e 100644 --- a/src/Command/PackageLoginCommand.php +++ b/src/Command/PackageLoginCommand.php @@ -5,6 +5,13 @@ namespace NoccyLabs\Composer\PackagePlugin\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Composer\Command\BaseCommand; +use NoccyLabs\Composer\PackagePlugin\Registry\Credentials\InsecureStore; +use NoccyLabs\Composer\PackagePlugin\Registry\Gitea\GiteaProvider; +use NoccyLabs\Composer\PackagePlugin\Registry\RegistryFactory; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Style\SymfonyStyle; class PackageLoginCommand extends BaseCommand { @@ -12,12 +19,23 @@ class PackageLoginCommand extends BaseCommand { $this ->setName('package:login') - ->setDescription("Login to a composer package registry"); + ->setDescription("Login to a composer package registry") + ->addOption("forget", null, InputOption::VALUE_NONE, "Forget the login for the registry") + ->addArgument("server", InputArgument::OPTIONAL, "Registry specification") + ; } protected function execute(InputInterface $input, OutputInterface $output): int { - $output->writeln('Executing'); + $credentials = new InsecureStore(); + + $io = new SymfonyStyle($input, $output); + + $server = $input->getArgument("server"); + $user = $io->ask("Username"); + $token = $io->ask("Token"); + + $credentials->storeToken($server, $user, $token); return 0; } diff --git a/src/Command/PackagePublishCommand.php b/src/Command/PackagePublishCommand.php index 9c503ac..9a51e1d 100644 --- a/src/Command/PackagePublishCommand.php +++ b/src/Command/PackagePublishCommand.php @@ -5,6 +5,12 @@ namespace NoccyLabs\Composer\PackagePlugin\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Composer\Command\BaseCommand; +use NoccyLabs\Composer\PackagePlugin\Package\PackagePublisher; +use NoccyLabs\Composer\PackagePlugin\Project\ProjectInfo; +use NoccyLabs\Composer\PackagePlugin\Registry\Credentials\InsecureStore; +use NoccyLabs\Composer\PackagePlugin\Registry\Gitea\GiteaProvider; +use NoccyLabs\Composer\PackagePlugin\Registry\RegistryFactory; +use Symfony\Component\Console\Input\InputArgument; class PackagePublishCommand extends BaseCommand { @@ -12,12 +18,38 @@ class PackagePublishCommand extends BaseCommand { $this ->setName('package:publish') - ->setDescription("Publish a package to a composer repository"); + ->setDescription("Publish a package to a composer package registry") + ->addArgument("registry", InputArgument::OPTIONAL, "The registry to publish to") + ; } protected function execute(InputInterface $input, OutputInterface $output): int { - $output->writeln('Executing'); + $credentials = new InsecureStore(); + + $providers = [ + 'gitea' => new GiteaProvider($credentials) + ]; + + $registry = new RegistryFactory($providers); + $publisher = new PackagePublisher($registry, $output); + + $project = ProjectInfo::read(); + + $registry = $input->getArgument("registry"); + if (!$registry) { + $output->writeln([ + "Missing registry to publish to. Please specify the registry like this:", + "", + " gitea: - to publish to server as the default user", + " gitea:/ - to publish to server as owner", + "" + ]); + return self::INVALID; + } + + $publisher->publish($project, $registry); + return 0; } diff --git a/src/Command/PackageUnpublishCommand.php b/src/Command/PackageUnpublishCommand.php index 324cf94..6821a6e 100644 --- a/src/Command/PackageUnpublishCommand.php +++ b/src/Command/PackageUnpublishCommand.php @@ -5,6 +5,13 @@ namespace NoccyLabs\Composer\PackagePlugin\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Composer\Command\BaseCommand; +use NoccyLabs\Composer\PackagePlugin\Package\PackagePublisher; +use NoccyLabs\Composer\PackagePlugin\Project\ProjectInfo; +use NoccyLabs\Composer\PackagePlugin\Registry\Credentials\InsecureStore; +use NoccyLabs\Composer\PackagePlugin\Registry\Gitea\GiteaProvider; +use NoccyLabs\Composer\PackagePlugin\Registry\RegistryFactory; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; class PackageUnpublishCommand extends BaseCommand { @@ -12,13 +19,44 @@ class PackageUnpublishCommand extends BaseCommand { $this ->setName('package:unpublish') - ->setDescription("Unpublish a version of a package or a package from a composer repository"); + ->setDescription("Unpublish a version of a package or a package from a composer package registry") + ->addOption("all", null, InputOption::VALUE_NONE, "Allow unpublish all versions of a package by passing all as the version") + ->addArgument("registry", InputArgument::REQUIRED, "The registry to unpublish from") + ->addArgument("version", InputArgument::REQUIRED, "The version to unpublish (or with --all, 'all' for all)") + ; } protected function execute(InputInterface $input, OutputInterface $output): int { - $output->writeln('Executing'); + $credentials = new InsecureStore(); - return 0; + $providers = [ + 'gitea' => new GiteaProvider($credentials) + ]; + + $factory = new RegistryFactory($providers); + $publisher = new PackagePublisher($factory, $output); + + $project = ProjectInfo::read(); + + $registry = $input->getArgument("registry"); + if (!$registry) { + $output->writeln([ + "Missing registry to unpublish from. Please specify the registry like this:", + "", + " gitea: - to unpublish from server for the default user", + " gitea:/ - to unpublish from server for owner", + "" + ]); + return self::INVALID; + } + + if ($pickVersion = $input->getArgument("version")) { + $project->version = $pickVersion; + } + + $publisher->unpublish($project, $registry); + + return self::SUCCESS; } } diff --git a/src/CommandProvider.php b/src/CommandProvider.php index 941acc4..a1e2fbc 100644 --- a/src/CommandProvider.php +++ b/src/CommandProvider.php @@ -12,7 +12,7 @@ class CommandProvider implements CommandProviderCapability new Command\PackageBuildCommand(), new Command\PackageLoginCommand(), new Command\PackagePublishCommand(), - new Command\PackageUnpublishCommand(), + // new Command\PackageUnpublishCommand(), ]; } } diff --git a/src/Package/PackageBuilder.php b/src/Package/PackageBuilder.php new file mode 100644 index 0000000..e56b8f9 --- /dev/null +++ b/src/Package/PackageBuilder.php @@ -0,0 +1,109 @@ +name; + $packageVersion = $project->version; + if (!$packageVersion) { + exec("git describe --tags 2>/dev/null", $tags, $ret); + if ($ret != 0) { + throw new \Exception("Non-zero exitcode from git describe --tags, have you created a tag?"); + } + $packageVersion = trim(reset($tags)); + } + $this->output->writeln("Creating {$project->filename} from {$packageName}@{$packageVersion}..."); + $filename = getcwd() . "/" . $project->filename; + + if (file_exists($filename) && !$overwrite) { + throw new \Exception("The package file already exists, pass --force to overwrite it."); + } + + // TODO clone repo before build unless dirty + + $files = $this->listFiles(); + $total = array_sum(array_map(filesize(...), $files)); + $unit = "B"; $units = [ "KiB", "MiB" ]; + while ($total > 1024 && count($units)) { + $total /= 1024; + $unit = array_shift($units); + } + $size = sprintf("%.1f%s", $total, $unit); + + $this->output->writeln("Adding ".count($files)." files ({$size})"); + + if (extension_loaded("zip")) { + $progress = new ProgressBar($this->output, count($files)); + $this->packWithExtension($filename, $files, $packageVersion, $progress); + $this->output->writeln(""); + } else { + $this->packWithCommand($filename, $files); + } + } + + private function listFiles(): array + { + exec("git ls-files", $out, $ret); + if ($ret != 0) { + throw new \Exception("Non-zero exitcode from git ls-files"); + } + return $out; + } + + private function packWithExtension(string $zipFile, array $files, string $version, ProgressBar $progress): void + { + $zip = new ZipArchive(); + $zip->open($zipFile, ZipArchive::CREATE); + + foreach ($files as $file) { + if ($file === 'composer.json') { + $json = json_decode(file_get_contents($file)); + $json->version = $version; + $json = json_encode($json, JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT); + $zip->addFromString($file, $json); + } else { + $zip->addFile($file); + } + $progress->advance(); + } + $zip->close(); + $progress->finish(); + } + + private function packWithCommand(string $zipFile, array $files): void + { + if ($zipCommand = exec("which zip")) { + // TODO + $cmd = $zipCommand; + $args = [ "a", $zipFile, ...$files ]; + } elseif ($szCommand = exec("which 7z")) { + // TODO + $cmd = $szCommand; + $args = [ "a", "-tzip", $zipFile, ...$files ]; + } else { + throw new \Exception("Unable to find zip or 7z tool, and the zip extension is not loaded"); + } + $cmdl = sprintf("%s %s", escapeshellcmd($cmd), join(" ", array_map(escapeshellarg(...), $args))); + exec($cmdl, $out, $ret); + if ($ret !== 0) { + $timmed = (mb_strlen($cmdl) > 32) ? (mb_substr($cmdl, 0, 32) . "...") : $cmdl; + throw new \Exception("Non-zero exit code from command: {$timmed}"); + } + } + +} \ No newline at end of file diff --git a/src/Package/PackagePublisher.php b/src/Package/PackagePublisher.php new file mode 100644 index 0000000..bb489ad --- /dev/null +++ b/src/Package/PackagePublisher.php @@ -0,0 +1,35 @@ +output->writeln("Resolving registry for {$registryUri}..."); + $registry = $this->registryFactory->createRegistryFromUri($registryUri); + + $this->output->writeln("Publishing package file {$project->filename} to {$registry->server} as {$registry->owner}..."); + $registry->publishPackageVersion($project); + } + + public function unpublish(ProjectInfo $project, string $registryUri): void + { + // $this->output->writeln("Resolving registry for {$registryUri}..."); + $registry = $this->registryFactory->createRegistryFromUri($registryUri); + + $this->output->writeln("Unpublishing package {$project->name}@{$project->version} from {$registry->server} for {$registry->owner}..."); + $registry->unpublishPackageVersion($project); + } +} \ No newline at end of file diff --git a/src/Project/ProjectInfo.php b/src/Project/ProjectInfo.php new file mode 100644 index 0000000..09ae094 --- /dev/null +++ b/src/Project/ProjectInfo.php @@ -0,0 +1,53 @@ +/dev/null")) ?: null; + } + if (!$version) { + $version = $manifest->version ?? "1.0.0"; + } + + $packageName = $manifest->name; + + $filename = sprintf("%s--%s.zip", strtr($packageName, [ "/" => "-" ]), $version); + + + return new ProjectInfo( + path: $path, + name: $packageName, + version: $version, + filename: $filename, + manifest: $manifest, + ); + } + + public function __construct( + public readonly string $path, + public readonly string $name, + public ?string $version, + public readonly string $filename, + public readonly object $manifest, + ) + { + } +} diff --git a/src/Registry/Credentials/InsecureStore.php b/src/Registry/Credentials/InsecureStore.php new file mode 100644 index 0000000..828ccc8 --- /dev/null +++ b/src/Registry/Credentials/InsecureStore.php @@ -0,0 +1,77 @@ +filename = getenv("HOME")."/.config/composer/package-plugin.json"; + } + + private function readConfig(): ?object + { + if (!file_exists($this->filename)) return null; + $json = file_get_contents($this->filename); + return json_decode($json); + } + + private function writeConfig(object $config): void + { + $json = json_encode($config, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + file_put_contents($this->filename, $json); + } + + public function getToken(string $server, ?string $owner): string + { + $conf = $this->readConfig(); + if (!$conf) { + throw new \Exception("No credentials found for {$server}"); + } + $credentials = (array)$conf->credentials; + if (array_key_exists($server, $credentials)) { + $serverCredentials = (array)$credentials[$server]; + if ($owner && array_key_exists($owner, $serverCredentials)) { + $token = $serverCredentials[$owner]->token; + $user = $owner; + } else { + $keys = array_keys($serverCredentials); + $user = reset($keys); + $first = reset($serverCredentials); + $token = $first->token; + } + return sprintf("%s:%s", $user, $token); + } + + throw new \Exception("No credentials found for {$server}"); + } + + public function storeToken(string $server, string $user, string $token): void + { + $conf = $this->readConfig(); + if (!$conf) { + $conf = (object)[]; + } + if (!isset($conf->credentials)) { + $conf->credentials = (object)[]; + } + if (!isset($conf->credentials->{$server})) { + $conf->credentials->{$server} = (object)[ + $user => [ + 'user' => $user, + 'token' => $token + ] + ]; + } else { + $conf->credentials->{$server}->{$user} = (object)[ + 'user' => $user, + 'token' => $token + ]; + } + $this->writeConfig($conf); + } +} \ No newline at end of file diff --git a/src/Registry/Credentials/StoreInterface.php b/src/Registry/Credentials/StoreInterface.php new file mode 100644 index 0000000..17ead7f --- /dev/null +++ b/src/Registry/Credentials/StoreInterface.php @@ -0,0 +1,8 @@ +credentials->getToken($server, $owner); + [$user, $_] = explode(":", $token, 2); + return new GiteaRegistry($server, $owner??$user, $token); + } +} \ No newline at end of file diff --git a/src/Registry/Gitea/GiteaRegistry.php b/src/Registry/Gitea/GiteaRegistry.php new file mode 100644 index 0000000..0c70aab --- /dev/null +++ b/src/Registry/Gitea/GiteaRegistry.php @@ -0,0 +1,113 @@ +server, $this->owner, $project->version); + $request = [ + 'method' => 'PUT', + 'url' => $url, + 'auth' => $this->token, + 'filename' => $project->filename, + ]; + $this->invoke($request); + } + + public function unpublishPackageVersion(ProjectInfo $project): void + { + $url = sprintf("https://%s/api/packages/%s/composer?version=%s", $this->server, $this->owner, $project->version); + $request = [ + 'method' => 'PUT', + 'url' => $url, + 'auth' => $this->token, + 'filename' => $project->filename, + ]; + $this->invoke($request); + + } + + public function unpublishPackage(ProjectInfo $project): void + { + + } + + private function invoke(array $request): mixed + { + if (extension_loaded("curl")) { + return $this->invokeExt($request); + } + return $this->invokeCurl($request); + } + + /** + * Invoke using the curl php extension + * + * @return void + */ + private function invokeExt(array $request): mixed + { + $fd = fopen($request['filename'], "rb"); + $fdlen = filesize($request['filename']); + + $curl = curl_init(); + curl_setopt($curl, CURLOPT_URL, $request['url']); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + + if ($request['method'] === 'PUT') { + curl_setopt($curl, CURLOPT_PUT, 1); + curl_setopt($curl, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); + curl_setopt($curl, CURLOPT_USERPWD, $this->token); + curl_setopt($curl, CURLOPT_INFILE, $fd); + curl_setopt($curl, CURLOPT_INFILESIZE, $fdlen); + } + $response = curl_exec($curl); + $code = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + fclose($fd); + + switch ($code) { + case 201: + return true; + case 400: + throw new \Exception("Bad request"); + case 409: + throw new \Exception("Package with this version already exists"); + default: + if ($code < 200 && $code >= 300) + throw new \Exception("Request failed: {$code}"); + return true; + } + } + + /** + * Invoke using the curl cli utility + * + * @return void + */ + private function invokeCurl(array $request): mixed + { + + $cmd = trim(exec("which curl")); + $args = [ "--user", $this->token ]; + + if ($request['method'] === 'PUT') { + $args[] = '--upload-file'; + $args[] = $request['filename']; + } + + $args[] = $request['url']; + + $cmdl = escapeshellcmd($cmd)." ".join(" ", array_map(escapeshellarg(...), $args)); + passthru($cmdl); + + return true; + } +} \ No newline at end of file diff --git a/src/Registry/ProviderTrait.php b/src/Registry/ProviderTrait.php new file mode 100644 index 0000000..a786f9e --- /dev/null +++ b/src/Registry/ProviderTrait.php @@ -0,0 +1,15 @@ +$provider) { + $this->registerProvider($prefix, $provider); + } + } + + public function registerProvider(string $prefix, RegistryProviderInterface $provider): void + { + $this->providers[$prefix] = $provider; + } + + public function createRegistryFromUri(string $uri): RegistryInterface + { + $params = explode(":", $uri); + $type = array_shift($params); + if (!isset($this->providers[$type])) { + throw new \Exception("Invalid registry provider type '{$type}'. Supported are ".join(", ",array_keys($this->providers))); + } + $registry = $this->providers[$type]->createRegistry($params); + return $registry; + } +} \ No newline at end of file diff --git a/src/Registry/RegistryInterface.php b/src/Registry/RegistryInterface.php new file mode 100644 index 0000000..c82d254 --- /dev/null +++ b/src/Registry/RegistryInterface.php @@ -0,0 +1,13 @@ +