diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffc4472 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/vendor +/recipes +/.idea +composer.lock +phpunit.xml +.phpunit.result.cache +.DS_Store diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..76733d0 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,19 @@ +filter: + excluded_paths: [tests/*] + +checks: + php: + remove_extra_empty_lines: true + remove_php_closing_tag: true + remove_trailing_whitespace: true + fix_use_statements: + remove_unused: true + preserve_multiple: false + preserve_blanklines: true + order_alphabetically: true + fix_php_opening_tag: true + fix_linefeed: true + fix_line_ending: true + fix_identation_4spaces: true + fix_doc_comments: true + diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..2604a5e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +language: php + +php: + - 7.2 + - 7.3 + - 7.4 + +env: + matrix: + - COMPOSER_FLAGS="" + +before_script: + - travis_retry composer self-update + - travis_retry composer update ${COMPOSER_FLAGS} --no-interaction --prefer-source + +script: + - composer test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b4ae1c4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing + +Contributions are **welcome** and will be fully **credited**. + +Please read and understand the contribution guide before creating an issue or pull request. + +## Etiquette + +This project is open source, and as such, the maintainers give their free time to build and maintain the source code +held within. They make the code freely available in the hope that it will be of use to other developers. It would be +extremely unfair for them to suffer abuse or anger for their hard work. + +Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the +world that developers are civilized and selfless people. + +It's the duty of the maintainer to ensure that all submissions to the project are of sufficient +quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. + +## Viability + +When requesting or submitting new features, first consider whether it might be useful to others. Open +source projects are used by many developers, who may have entirely different needs to your own. Think about +whether or not your feature is likely to be used by other users of the project. + +## Procedure + +Before filing an issue: + +- Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. +- Check to make sure your feature suggestion isn't already present within the project. +- Check the pull requests tab to ensure that the bug doesn't have a fix in progress. +- Check the pull requests tab to ensure that the feature isn't already in progress. + +Before submitting a pull request: + +- Check the codebase to ensure that your feature doesn't already exist. +- Check the pull requests to ensure that another person hasn't already submitted the feature or fix. + +## Requirements + +If the project maintainer has any additional requirements, you will find them listed here. + +- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer). + +- **Add tests!** - Your patch won't be accepted if it doesn't have tests. + +- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. + +- **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. + +- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. + +- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. + +**Happy coding**! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ad1e1b7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 synga-nl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee4821a --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Advanced laravel installer +Installs Laravel with additional packages specified in recipes. + +## Composer + +Run `composer global require synga/advanced-laravel-installer` + +## Commands + +When the composer bin directory is in your PATH you can call the following command everywhere: + +`advanced-laravel new` You can use this command to create a new laravel project in the current directory +`advanced-laravel recipe` Create/Edit a recipe. You can add (development) packages and commands. +`advanced-laravel recipe:list` List all the available recipes and their filenames +`advanced-laravel recipe:open` Opens a recipe or the recipe directory in the file manager. + +## Improvements +- [ ] Better console menu handling (Menustack), maybe +- [ ] Composer command is used to search for packages. This needs to be improved because it causes bugs. +- [ ] Save recipes in the cloud so you can use them anywhere, only access to your account is needed. +- [ ] Include other recipes so you can composer recipes from other recipes. + +## Testing +You can run the tests with: + +`composer test` + +## Contributing +Since I'm getting some questions about this I want these things to be perfectly clear: + +This is a safe haven for contributions, every (positive) contributon matters! +You are free (and encouraged) to use anything of this package for your own ideas. +You can always ask for help or email me directly for any questions. + +## Security +If you discover any security related issues, please email info@synga.nl instead of using the issue tracker. + +## License +The MIT License (MIT). Please see License File for more information. \ No newline at end of file diff --git a/bin/advanced-laravel b/bin/advanced-laravel new file mode 100644 index 0000000..6304bd0 --- /dev/null +++ b/bin/advanced-laravel @@ -0,0 +1,54 @@ +#!/usr/bin/env php +add(new NewCommand()); +$app->add(new RecipeCommand()); +$app->add(new OpenRecipeInFileManagerCommand()); +$app->add(new ListRecipesCommand()); + +$app->run(); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..943f8f8 --- /dev/null +++ b/composer.json @@ -0,0 +1,52 @@ +{ + "name": "synga/advanced-laravel-installer", + "description": "Can install a laravel application with preconfigured packages via recipes", + "type": "library", + "require": { + "php": "^7.2.9", + "ext-zip": "*", + "ext-json": "*", + "composer/composer": "^1.10", + "guzzlehttp/guzzle": "^6.0", + "illuminate/console": "^7.1", + "symfony/filesystem": "^4.0|^5.0", + "symfony/process": "^4.2|^5.0", + "geekdevs/cli-highlighter": "^1.0", + "tivie/php-os-detector": "^1.1" + }, + "bin": [ + "bin/advanced-laravel" + ], + "require-dev": { + "phpunit/phpunit": "^8.0|^9.0" + }, + "autoload": { + "psr-4": { + "Synga\\Installer\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Laravel\\Installer\\Tests\\": "tests/" + } + }, + "license": "MIT", + "authors": [ + { + "name": "Roy Pouls", + "email": "info@synga.nl" + } + ], + "scripts": { + "test-coverage": [ + "vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover" + ], + "test": [ + "vendor/bin/phpunit" + ] + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "stable" +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..a49bb10 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + ./tests + + + + + ./src + + + diff --git a/src/AdvancedInstaller.php b/src/AdvancedInstaller.php new file mode 100644 index 0000000..988c75c --- /dev/null +++ b/src/AdvancedInstaller.php @@ -0,0 +1,66 @@ +addOption('recipe-path', null, InputOption::VALUE_REQUIRED, 'Path to recipe json.') + ->addOption('recipe-name', null, InputOption::VALUE_REQUIRED, 'Name of recipe.') + ->addOption('recipe', '-r', InputOption::VALUE_NONE, 'Choose recipe interactively.'); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return Recipe + */ + public static function getRecipe(InputInterface $input, OutputInterface $output): ?Recipe + { + if (empty(array_diff( + ['recipe-path', 'recipe-name', 'recipe'], + array_keys(array_filter($input->getOptions())) + ))) { + return null; + } + + $recipeOption = $input->getOption('recipe-path'); + + if (!empty($recipeOption)) { + $recipe = Recipe::creatByFilePath($recipeOption); + + if ($recipe instanceof Recipe) { + return $recipe; + } + } + + $recipeCollection = RecipeCollection::collect(__DIR__ . '/../recipes'); + + $recipeOption = $input->getOption('recipe-name'); + + if (!empty($recipeOption)) { + $recipe = $recipeCollection->search($recipeOption); + + if (empty($recipe)) { + $recipe = $recipeCollection->consoleSelect($input, $output); + } + + return $recipe; + } + + return null; + } +} \ No newline at end of file diff --git a/src/Console/Command.php b/src/Console/Command.php new file mode 100644 index 0000000..638d9e9 --- /dev/null +++ b/src/Console/Command.php @@ -0,0 +1,42 @@ +output; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + * @throws \Exception + */ + public function run(InputInterface $input, OutputInterface $output): int + { + $this->input = $input; + $this->output = new OutputStyle($input, $output); + + return parent::run($input, $output); // TODO: Change the autogenerated stub + } +} diff --git a/src/Console/Command/ListRecipesCommand.php b/src/Console/Command/ListRecipesCommand.php new file mode 100644 index 0000000..77d47e1 --- /dev/null +++ b/src/Console/Command/ListRecipesCommand.php @@ -0,0 +1,50 @@ +setName('recipe:list') + ->setAliases(['rl']) + ->setDescription('Lists all recipes.'); + } + + /** + * Execute the command. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $recipes = RecipeCollection::collect($this->path); + + $this->info("Available recipes: \n"); + + foreach ($recipes->getRecipes() as $recipe) { + $this->info(" [{$recipe->getFilename()}] {$recipe->getName()}"); + } + + return 0; + } +} \ No newline at end of file diff --git a/src/Console/Command/NewCommand.php b/src/Console/Command/NewCommand.php new file mode 100644 index 0000000..3f23368 --- /dev/null +++ b/src/Console/Command/NewCommand.php @@ -0,0 +1,262 @@ +setName('new') + ->setDescription('Create a new Laravel application') + ->addArgument('name', InputArgument::OPTIONAL) + ->addOption('dev', null, InputOption::VALUE_NONE, 'Installs the latest "development" release') + ->addOption('auth', null, InputOption::VALUE_NONE, 'Installs the Laravel authentication scaffolding') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Forces install even if the directory already exists'); + + AdvancedInstaller::configure($this); + } + + /** + * Execute the command. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $recipe = AdvancedInstaller::getRecipe($input, $output); + + if (!extension_loaded('zip')) { + throw new RuntimeException('The Zip PHP extension is not installed. Please install it and try again.'); + } + + $name = $input->getArgument('name'); + + $directory = $name && $name !== '.' ? getcwd() . '/' . $name : getcwd(); + + if (!$input->getOption('force')) { + $this->verifyApplicationDoesntExist($directory); + } + + $output->writeln('Crafting application...'); + + $this->download($zipFile = $this->makeFilename(), $this->getVersion($input)) + ->extract($zipFile, $directory) + ->prepareWritableDirectories($directory, $output) + ->cleanUp($zipFile); + + $composer = $this->findComposer(); + + $commands = array_merge( + [ + $composer . ' install --no-scripts', + ], + optional($recipe)->composerCommands($composer) ?? [], + [ + $composer . ' run-script post-root-package-install', + $composer . ' run-script post-create-project-cmd', + $composer . ' run-script post-autoload-dump', + ]); + + if ($input->getOption('no-ansi')) { + $commands = array_map(function ($value) { + return $value . ' --no-ansi'; + }, $commands); + } + + if ($input->getOption('quiet')) { + $commands = array_map(function ($value) { + return $value . ' --quiet'; + }, $commands); + } + + $process = Process::fromShellCommandline(implode(' && ', $commands), $directory, null, null, null); + + if ('\\' !== DIRECTORY_SEPARATOR && file_exists('/dev/tty') && is_readable('/dev/tty')) { + try { + $process->setTty(true); + } catch (RuntimeException $e) { + $output->writeln('Warning: ' . $e->getMessage()); + } + } + + $process->run(function ($type, $line) use ($output) { + $output->write($line); + }); + + if ($process->isSuccessful()) { + $output->writeln('Application ready! Build something amazing.'); + } + + return 0; + } + + /** + * Verify that the application does not already exist. + * + * @param string $directory + * @return void + */ + protected function verifyApplicationDoesntExist($directory) + { + if ((is_dir($directory) || is_file($directory)) && $directory != getcwd()) { + throw new RuntimeException('Application already exists!'); + } + } + + /** + * Generate a random temporary filename. + * + * @return string + */ + protected function makeFilename() + { + return getcwd() . '/laravel_' . md5(time() . uniqid()) . '.zip'; + } + + /** + * Download the temporary Zip to the given file. + * + * @param string $zipFile + * @param string $version + * @return $this + */ + protected function download($zipFile, $version = 'master') + { + switch ($version) { + case 'develop': + $filename = 'latest-develop.zip'; + break; + case 'auth': + $filename = 'latest-auth.zip'; + break; + case 'master': + $filename = 'latest.zip'; + break; + } + + if (!empty($filename)) { + $response = (new Client)->get('http://cabinet.laravel.com/' . $filename); + + file_put_contents($zipFile, $response->getBody()); + } + + return $this; + } + + /** + * Extract the Zip file into the given directory. + * + * @param string $zipFile + * @param string $directory + * @return $this + */ + protected function extract($zipFile, $directory) + { + $archive = new ZipArchive; + + $response = $archive->open($zipFile, ZipArchive::CHECKCONS); + + if ($response === ZipArchive::ER_NOZIP) { + throw new RuntimeException('The zip file could not download. Verify that you are able to access: http://cabinet.laravel.com/latest.zip'); + } + + $archive->extractTo($directory); + + $archive->close(); + + return $this; + } + + /** + * Clean-up the Zip file. + * + * @param string $zipFile + * @return $this + */ + protected function cleanUp($zipFile) + { + @chmod($zipFile, 0777); + + @unlink($zipFile); + + return $this; + } + + /** + * Make sure the storage and bootstrap cache directories are writable. + * + * @param string $appDirectory + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return $this + */ + protected function prepareWritableDirectories($appDirectory, OutputInterface $output) + { + $filesystem = new Filesystem; + + try { + $filesystem->chmod($appDirectory . DIRECTORY_SEPARATOR . 'bootstrap/cache', 0755, 0000, true); + $filesystem->chmod($appDirectory . DIRECTORY_SEPARATOR . 'storage', 0755, 0000, true); + } catch (IOExceptionInterface $e) { + $output->writeln('You should verify that the "storage" and "bootstrap/cache" directories are writable.'); + } + + return $this; + } + + /** + * Get the version that should be downloaded. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @return string + */ + protected function getVersion(InputInterface $input) + { + if ($input->getOption('dev')) { + return 'develop'; + } + + if ($input->getOption('auth')) { + return 'auth'; + } + + return 'master'; + } + + /** + * Get the composer command for the environment. + * + * @return string + */ + protected function findComposer() + { + $composerPath = getcwd() . '/composer.phar'; + + if (file_exists($composerPath)) { + return '"' . PHP_BINARY . '" ' . $composerPath; + } + + return 'composer'; + } +} \ No newline at end of file diff --git a/src/Console/Command/OpenRecipeInFileManagerCommand.php b/src/Console/Command/OpenRecipeInFileManagerCommand.php new file mode 100644 index 0000000..4736ce7 --- /dev/null +++ b/src/Console/Command/OpenRecipeInFileManagerCommand.php @@ -0,0 +1,106 @@ +setName('recipe:open') + ->setAliases(['ro']) + ->addArgument('recipe-name', InputArgument::OPTIONAL) + ->addOption('list', InputOption::VALUE_NONE) + ->setDescription('Open the recipe directory for current platform.'); + } + + /** + * Execute the command. + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * + * @return int + * @todo support linux (most common versions) + * @todo if this is working well, let's extract opening in file manager to a package. + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $path = $this->path; + + $recipeCollection = RecipeCollection::collect($this->path); + + if ($this->option('list')) { + $recipe = $recipeCollection->consoleSelect($input, $output); + + $path .= "/{$recipe->getFilename()}"; + } + + if ($this->argument('recipe-name')) { + $recipe = $recipeCollection->search($this->argument('recipe-name')); + + if ($recipe) { + $path .= "/{$recipe->getFilename()}"; + } else { + $this->line("Could not find the recipe, opening the directory instead.\n"); + try { + (new ListRecipesCommand())->run(new StringInput(''), $output); + } catch (\Exception $e) { + $this->line("Something went wrong while listing the recipes.\n"); + } + } + } + + $path = escapeshellarg($path); + + $os = new Os\Detector(); + switch ($os->getType()) { + case OS\WINDOWS: + case OS\CYGWIN: + exec("start {$path}"); + break; + case OS\MACOSX: + exec("open -R {$path}"); + break; + // Not yet supported. Possible commands: + // xdg-open, gnome-open and nautilus + case OS\GEN_UNIX: + case OS\LINUX: + case OS\MSYS: + case OS\SUN_OS: + case OS\NONSTOP: + case OS\QNX: + case OS\BSD: + case OS\BE_OS: + case OS\HP_UX: + case OS\ZOS: + case OS\AIX: + default: + $this->line("You can use to following command to go to the recipes directory: \n"); + $this->line("cd {$path}\n"); + + $this->line('Your platform not yet supported. Please create a pull request to support this for your platform.'); + } + + return 0; + } +} \ No newline at end of file diff --git a/src/Console/Command/RecipeCommand.php b/src/Console/Command/RecipeCommand.php new file mode 100644 index 0000000..3fd5596 --- /dev/null +++ b/src/Console/Command/RecipeCommand.php @@ -0,0 +1,292 @@ +The number %s could not be found\n"; + const TRANSLATION_PACKAGE_LISTING = '[%d] %s:%s'; + const TRANSLATION_PACKAGE_DELETE = 'Which package do you want to delete?'; + const TRANSLATION_PACKAGE_NOT_FOUND = 'Could not find the package, please try again'; + const TRANSLATION_PACKAGE_NOT_REGISTERED = "No %s packages are registered yet. \n"; + const TRANSLATION_PACKAGE_REGISTERED = "The following %S packages are registered: \n"; + const TRANSLATION_PACKAGE_LINE = ' %s:%s'; + const TRANSLATION_BACK_OPTION = '[%d] Back'; + const TRANSLATION_RECIPE_CURRENT = "%s recipe with name: %s\n"; + const TRANSLATION_RECIPE_SAVED_PROCEED = 'The recipe has been saved, do you wish to proceed?'; + const TRANSLATION_RECIPE_REQUIREMENTS_VIOLATED = 'The recipe does not meet all the requirements.'; + const TRANSLATION_COMING_SOON = 'Coming soon.'; + const TRANSLATION_CONFIRM_PROCEED = 'Press enter to proceed'; + const TRANSLATION_REFLECTION_EXCEPTION = 'Reflection exception, did you properly install PHP and the composer packages?'; + const TRANSLATION_EXIT = 'We wish you all the best of luck, fortune and prosperity.'; + + /** @var SearchCommand */ + protected $search; + + /** @var JsonHighlighter */ + protected $highlighter; + + /** @var string */ + protected $path = __DIR__ . '/../../../recipes'; + + /** + * RecipeCommand constructor. + * @param string|null $name + */ + public function __construct(string $name = null) + { + parent::__construct($name); + + $this->highlighter = new JsonHighlighter([ + 'keys' => 'magenta', + 'values' => 'green', + 'braces' => 'light_white', + ]); + } + + /** + * Configure the command options. + * + * @return void + */ + protected function configure(): void + { + $this + ->setName('recipe') + ->setDescription('Create a recipe.') + ->addArgument('name', InputArgument::REQUIRED); + } + + /** + * Execute the command. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $name = $this->argument('name'); + + $recipe = MutableRecipe::createByName($this->path, $name); + $creating = is_null($recipe); + + while (true) { + $this->line( + sprintf(self::TRANSLATION_RECIPE_CURRENT, + ($creating) ? 'cyan' : 'red', + ($creating) ? 'Creating' : 'Updateing', + is_array($name) ? implode(' ', $name) : $name + ) + ); + + $this->line(' [1] Add production packages'); + $this->line(' [2] Add development packages'); + $this->line(' [3] Add commands'); + $this->line(' [4] Delete production packages'); + $this->line(' [5] Delete development packages'); + $this->line(' [6] Delete commands'); + $this->line(str_repeat('-', 80)); + $this->line(' [7] View current state'); + $this->line(' [8] Save'); + $this->line(' [9] Exit - Working copy will be deleted'); + + $menuItem = $this->ask(self::TRANSLATION_PROVIDE_MENU_ITEM); + + switch ($menuItem) { + case 1: + $recipe->setProductionPackages($this->addPackages($input, $output, $recipe->getProductionPackages(), 'production')); + break; + case 2: + $recipe->setDevelopmentPackages($this->addPackages($input, $output, $recipe->getDevelopmentPackages(), 'development')); + break; + case 3: + case 6: + $this->line(self::TRANSLATION_COMING_SOON); + $this->ask(self::TRANSLATION_CONFIRM_PROCEED); + break; + case 4: + while (true) { + if (is_null($this->deletePackageFromCollection($recipe->getProductionPackages()))) { + break; + } + } + break; + case 5: + while (true) { + if (is_null($this->deletePackageFromCollection($recipe->getDevelopmentPackages()))) { + break; + } + } + break; + case 7: + echo $this->highlighter->highlight($recipe->toJson()) . "\n\n"; + + $this->ask(self::TRANSLATION_CONFIRM_PROCEED); + break; + case 8: + if ($recipe->save($this->path)) { + if (!$this->confirm(self::TRANSLATION_RECIPE_SAVED_PROCEED)) { + break 2; + } + } else { + $this->line(self::TRANSLATION_RECIPE_REQUIREMENTS_VIOLATED); + + $this->ask(self::TRANSLATION_CONFIRM_PROCEED); + } + break; + case 9: + break 2; + default: + $this->line(sprintf(self::TRANSLATION_MENU_ITEM_NOT_FOUND, $menuItem)); + } + } + + $this->info(self::TRANSLATION_EXIT); + + return 0; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return Collection + */ + protected function search(InputInterface $input, OutputInterface $output): Collection + { + $result = new Collection(); + + try { + $initCommandReflection = new \ReflectionClass(InitCommand::class); + $method = $initCommandReflection->getMethod('determineRequirements'); + $method->setAccessible(true); + + // @todo with some modifications in composer, this can work even better. + // @todo Need to find a way to not let the application crash when you type in a package that does not exist + $initCommand = new InitCommand(); + $initCommand->setIO(new ConsoleIO($input, $output, new HelperSet([ + 'question' => new QuestionHelper(), + ]))); + + $packages = []; + + try { + $packages = $method->invoke($initCommand, $input, $output, $packages); + } catch (\RuntimeException $e) { + $this->line(self::TRANSLATION_PACKAGE_NOT_FOUND); + } + + foreach ($packages as $item) { + $items = explode(' ', $item); + + if (count($items) === 2) { + $result[$items[0]] = $items[1]; + } + }; + + } catch (\ReflectionException $e) { + $this->line(self::TRANSLATION_REFLECTION_EXCEPTION); + } + + return $result; + + } + + /** + * @param Collection $packages + * + * @return bool + */ + protected function deletePackageFromCollection(Collection $packages): ?Collection + { + $this->line(self::TRANSLATION_PACKAGE_DELETE . "\n"); + + $choice = $this->choicePackageList($packages); + + if (is_null($choice)) { + return null; + } + + $this->info('UNSET'); + + unset($packages[$choice]); + + return $packages; + } + + /** + * @param Collection $packages + * + * @return string|null + */ + protected function choicePackageList(Collection $packages): ?string + { + $loop = 1; + + $packages->map(function ($version, $packageName) use (&$loop) { + $this->line(' ' . sprintf(self::TRANSLATION_PACKAGE_LISTING, $loop++, $packageName, $version)); + }); + + $this->line(sprintf("\n " . self::TRANSLATION_BACK_OPTION, $loop++)); + + $choice = (int)$this->ask(self::TRANSLATION_PROVIDE_MENU_ITEM); + + if ($packages->count() === $choice - 1) { + return null; + } + + $loop = 1; + $result = ''; + + $packages->map(function ($version, $packageName) use ($choice, &$loop, &$result) { + if ($choice === $loop) { + $result = $packageName; + } + + $loop++; + }); + + return $result; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @param Collection $packages + * @param string $packageType + * + * @return Collection + */ + protected function addPackages(InputInterface $input, OutputInterface $output, Collection $packages, string $packageType): Collection + { + if ($packages->isEmpty()) { + $this->info(sprintf(self::TRANSLATION_PACKAGE_NOT_REGISTERED, $packageType)); + } else { + $this->info(sprintf(self::TRANSLATION_PACKAGE_REGISTERED, $packageType)); + + $packages->map(function ($version, $packageName) { + $this->line(sprintf(self::TRANSLATION_PACKAGE_LINE, $packageName, $version)); + }); + + $this->line(''); + } + + return $packages->merge($this->search($input, $output)); + } +} \ No newline at end of file diff --git a/src/Recipes/MutableRecipe.php b/src/Recipes/MutableRecipe.php new file mode 100644 index 0000000..e62dac3 --- /dev/null +++ b/src/Recipes/MutableRecipe.php @@ -0,0 +1,24 @@ +productionPackages = $productionPackages; + + return $this; + } + + public function setDevelopmentPackages(Collection $developmentPackages): self + { + $this->developmentPackages = $developmentPackages; + + return $this; + } +} \ No newline at end of file diff --git a/src/Recipes/Recipe.php b/src/Recipes/Recipe.php new file mode 100644 index 0000000..162400a --- /dev/null +++ b/src/Recipes/Recipe.php @@ -0,0 +1,247 @@ +name = Arr::get($recipe, 'name'); + + if (empty($this->name)) { + throw new \InvalidArgumentException('No name provided for class: ' . static::class); + } + + $this->productionPackages = collect(Arr::get($recipe, 'packages.production', [])); + $this->developmentPackages = collect(Arr::get($recipe, 'packages.development', [])); + $this->commands = collect(Arr::get($recipe, 'commands', [])); + $this->filename = Arr::get($recipe, 'filename', ''); + } + + /** + * @param string $filePath + * + * @return static + */ + public static function creatByFilePath(string $filePath): self + { + if (!file_exists($filePath)) { + throw new \InvalidArgumentException("The path {$filePath} does not exist."); + } + + try { + $recipe = json_decode(file_get_contents($filePath), true); + + $recipe = static::createByArray($recipe); + + $recipe->filename = basename($filePath); + + return $recipe; + } catch (\Throwable $throwable) { + throw new \LogicException("Could not retrieve data from {$filePath}"); + } + } + + /** + * @param array $recipe + * + * @return static + */ + public static function createByArray(array $recipe): self + { + if (!static::createValidate($recipe)) { + throw new \InvalidArgumentException('The data is not correct.'); + } + + return new static($recipe); + } + + public static function createByName(string $path, string $name): ?self + { + $filePath = $path . '/' . md5($name) . '.json'; + + if (file_exists($filePath)) { + return static::creatByFilePath($filePath); + } + + return null; + } + + /** + * @param array $recipe + * + * @return bool + * @todo Check also for empty + */ + protected static function createValidate(array $recipe): bool + { + return Arr::has($recipe, ['name', 'packages']) && Arr::hasAny($recipe, ['packages.production', 'packages.development']); + } + + /** + * @return bool + */ + protected function validate(): bool + { + if (empty($this->name) || ($this->productionPackages->isEmpty() && $this->developmentPackages->isEmpty())) { + return false; + } + + return true; + } + + /** + * @param string $path + * + * @return bool + */ + public function save(string $path): bool + { + if (!$this->validate()) { + return false; + } + + $filename = md5($this->name) . '.json'; + + file_put_contents("{$path}/$filename", $this->toJson()); + + return true; + } + + /** + * @return Collection + */ + public function getProductionPackages(): Collection + { + return $this->productionPackages; + } + + /** + * @return Collection + */ + public function getDevelopmentPackages(): Collection + { + return $this->developmentPackages; + } + + /** + * @return Collection + */ + public function getAllPackageNames(): Collection + { + return $this->productionPackages->merge($this->developmentPackages); + } + + /** + * @param string $composer + * @return string|null + */ + public function productionCommand(string $composer): ?string + { + return $this->createCommand($composer, $this->productionPackages); + } + + /** + * @param string $composer + * @return string|null + */ + public function developmentCommand(string $composer): ?string + { + return $this->createCommand($composer, $this->developmentPackages); + } + + /** + * @param string $composer + * @return array + */ + public function composerCommands(string $composer): array + { + return array_filter([$this->productionCommand("{$composer} require"), $this->developmentCommand("{$composer} require --dev")]); + } + + /** + * @param string $artisanPath + * @return Collection + */ + public function commands(string $artisanPath): Collection + { + return $this->commands->map(function ($item) use ($artisanPath) { + $escapedCommand = []; + foreach (explode(' ', $item) as $argument) { + $escapedCommand[] = escapeshellarg($argument); + } + + $escapedCommand = implode(' ', $escapedCommand); + + return "{$artisanPath} {$escapedCommand}"; + }); + } + + /** + * @param string $composer + * @param Collection $packages + * @return string|null + */ + protected function createCommand(string $composer, Collection $packages): ?string + { + if ($packages->isEmpty()) { + return null; + } + + foreach ($packages as $package => $version) { + $composer .= ' ' . escapeshellarg("{$package}:{$version}"); + } + + return $composer; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + public function getFilename(): string + { + return $this->filename; + } + + /** + * @return string + */ + public function toJson(): string + { + return json_encode([ + 'name' => $this->name, + 'packages' => [ + 'production' => $this->productionPackages->toArray(), + 'development' => $this->developmentPackages->toArray(), + ], + 'commands' => $this->commands->toArray(), + ], JSON_PRETTY_PRINT); + } +} diff --git a/src/Recipes/RecipeCollection.php b/src/Recipes/RecipeCollection.php new file mode 100644 index 0000000..ba4aa0b --- /dev/null +++ b/src/Recipes/RecipeCollection.php @@ -0,0 +1,87 @@ +push(Recipe::creatByFilePath("{$directory}/{$file}")); + } + } + + return $collection; + } + + /** + * @param Recipe $recipe + */ + public function push(Recipe $recipe) + { + $this->items[$recipe->getName()] = $recipe; + } + + /** + * @todo move this outside the collection. For example the AdvancedInstaller class. + * @todo move question helper outside since we can't test this + * + * @param InputInterface $input + * @param OutputInterface $ouput + * @return Recipe|null + */ + public function consoleSelect(InputInterface $input, OutputInterface $ouput): ?Recipe + { + if (empty($this->items) || !$input->isInteractive()) { + return null; + } + + $question = new ChoiceQuestion('Please choose the recipe', array_keys($this->items)); + $question->setMaxAttempts(3); + + $helper = new QuestionHelper(); + $answer = $helper->ask($input, $ouput, $question); + + return $this->items[$answer]; + } + + /** + * @param string $name + * @return Recipe|null + */ + public function search(string $name): ?Recipe + { + return $this->items[$name] ?? null; + } + + /** + * @return Recipe[] + */ + public function getRecipes(): array + { + return $this->items; + } + + /** + * @return int + */ + public function count(): int + { + return count($this->items); + } +} diff --git a/tests/NewCommandTest.php b/tests/NewCommandTest.php new file mode 100644 index 0000000..67994bf --- /dev/null +++ b/tests/NewCommandTest.php @@ -0,0 +1,95 @@ +scaffoldDirectory = __DIR__ . '/../' . $this->scaffoldDirectoryName; + } + + /** + * @return Application + */ + protected function setUpApplication(): Application + { + if (file_exists($this->scaffoldDirectory)) { + (new Filesystem)->remove($this->scaffoldDirectory); + } + + return new Application('Laravel Installer'); + } + + /** + * This method is called after each test. + */ + protected function tearDown(): void + { + if (file_exists($this->scaffoldDirectory)) { + (new Filesystem)->remove($this->scaffoldDirectory); + } + } + + public function test_it_can_scaffold_a_new_laravel_app() + { + $app = $this->setUpApplication(); + + $app->add(new NewCommand()); + + $tester = new CommandTester($app->find('new')); + + $statusCode = $tester->execute(['name' => $this->scaffoldDirectoryName, '--auth' => null]); + + $this->assertEquals($statusCode, 0); + $this->assertDirectoryExists($this->scaffoldDirectory . '/vendor'); + $this->assertFileExists($this->scaffoldDirectory . '/.env'); + $this->assertFileExists($this->scaffoldDirectory . '/resources/views/auth/login.blade.php'); + } + + public function test_it_can_scaffold_a_new_laravel_app_from_recipe() + { + $app = $this->setUpApplication(); + + $app->add(new NewCommand()); + + $tester = new CommandTester($app->find('new')); + + $statusCode = $tester->execute(['name' => $this->scaffoldDirectoryName, '--auth' => null, '--recipe-path' => realpath($this->recipe)]); + + $this->assertEquals($statusCode, 0); + $this->assertDirectoryExists($this->scaffoldDirectory . '/vendor'); + $this->assertFileExists($this->scaffoldDirectory . '/.env'); + $this->assertFileExists($this->scaffoldDirectory . '/resources/views/auth/login.blade.php'); + + $composerJsonPathName = $this->scaffoldDirectory . '/composer.json'; + $this->assertFileExists($composerJsonPathName); + $composerJson = file_get_contents($composerJsonPathName); + + $this->assertStringContainsString('spatie/laravel-query-builder', $composerJson); + $this->assertStringContainsString('mtolhuys/laravel-schematics', $composerJson); + } +} diff --git a/tests/Recipes/RecipeCollectionTest.php b/tests/Recipes/RecipeCollectionTest.php new file mode 100644 index 0000000..eba1450 --- /dev/null +++ b/tests/Recipes/RecipeCollectionTest.php @@ -0,0 +1,48 @@ +directory)); + + $this->assertSame(1, $recipeCollection->count()); + foreach ($recipeCollection->getRecipes() as $recipe) { + $this->assertInstanceOf(Recipe::class, $recipe); + } + } + + public function test_can_search_recipe() + { + $recipeCollection = RecipeCollection::collect(realpath($this->directory)); + $recipe = $recipeCollection->search('test'); + + $this->assertInstanceOf(Recipe::class, $recipe); + } + + public function test_returns_null_on_search_non_existing_key() + { + $recipeCollection = RecipeCollection::collect(realpath($this->directory)); + + $null = $recipeCollection->search('does_not_exist'); + $this->assertNull($null); + } + + public function test_can_push_recipe() + { + $recipeCollection = RecipeCollection::collect(realpath($this->directory)); + + $recipeMock = $this->createMock(Recipe::class); + $recipeCollection->push($recipeMock); + + $this->assertSame(2, $recipeCollection->count()); + } +} \ No newline at end of file diff --git a/tests/Recipes/RecipeTest.php b/tests/Recipes/RecipeTest.php new file mode 100644 index 0000000..69eb220 --- /dev/null +++ b/tests/Recipes/RecipeTest.php @@ -0,0 +1,98 @@ +pathName); + + $this->assertInstanceOf(Recipe::class, $recipe); + $this->assertStringContainsString($recipe->getFilename(), $this->pathName); + } + + public function test_create_by_name() + { + $recipe = Recipe::createByName($this->path, 'test'); + + $this->assertInstanceOf(Recipe::class, $recipe); + $this->assertStringContainsString($recipe->getFilename(), $this->pathName); + } + + public function test_create_by_array() + { + $recipeArray = json_decode(file_get_contents($this->pathName), true); + + $recipe = Recipe::createByArray($recipeArray); + + $this->assertInstanceOf(Recipe::class, $recipe); + $this->assertEmpty($recipe->getFilename()); + } + + public function test_can_save_recipe() + { + $name = 'test_name'; + $recipeArray = json_decode(file_get_contents($this->pathName), true); + $recipeArray['name'] = $name; + + $recipe = Recipe::createByArray($recipeArray); + $recipe->save($this->path); + + $pathName = "{$this->path}/" . md5($name) . '.json'; + + $this->assertFileExists($pathName); + @unlink($pathName); + } + + public function test_can_get_composer_commands() + { + $recipe = Recipe::creatByFilePath($this->pathName); + + $composerCommands = $recipe->composerCommands('composer'); + + $this->assertIsArray($composerCommands); + $this->assertCount(2, $composerCommands); + $this->assertStringContainsString('--dev', $composerCommands[1]); + } + + public function test_can_get_artisan_commands() + { + $recipe = Recipe::creatByFilePath($this->pathName); + + $commands = $recipe->commands('artisan'); + + $this->assertCount(1, $commands); + $this->assertStringContainsString('--help', $commands[0]); + $this->assertStringContainsString('artisan', $commands[0]); + } + + public function test_can_get_all_packages() + { + $recipe = Recipe::creatByFilePath($this->pathName); + + $packages = $recipe->getAllPackageNames(); + + $this->assertArrayHasKey('mtolhuys/laravel-schematics', $packages); + $this->assertArrayHasKey('spatie/laravel-query-builder', $packages); + } + + public function test_can_return_valid_data() + { + $recipe = Recipe::creatByFilePath($this->pathName); + + $this->assertIsString($recipe->getName()); + $this->assertInstanceOf(Collection::class, $recipe->getProductionPackages()); + $this->assertSame(1, $recipe->getProductionPackages()->count()); + $this->assertInstanceOf(Collection::class, $recipe->getDevelopmentPackages()); + $this->assertSame(1, $recipe->getDevelopmentPackages()->count()); + } +} \ No newline at end of file diff --git a/tests/Resources/Recipes/098f6bcd4621d373cade4e832627b4f6.json b/tests/Resources/Recipes/098f6bcd4621d373cade4e832627b4f6.json new file mode 100644 index 0000000..a722447 --- /dev/null +++ b/tests/Resources/Recipes/098f6bcd4621d373cade4e832627b4f6.json @@ -0,0 +1,14 @@ +{ + "name": "test", + "packages": { + "production": { + "mtolhuys\/laravel-schematics": "^0.10.3" + }, + "development": { + "spatie\/laravel-query-builder": "^2.8" + } + }, + "commands": [ + "--help" + ] +} \ No newline at end of file