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