From 470ae76bef5229f07880fa54916b4d52f34c7347 Mon Sep 17 00:00:00 2001 From: Guillermo Cava Date: Wed, 2 Apr 2025 09:46:28 -0500 Subject: [PATCH 01/10] kickstarts pull cmd --- app/Commands/PullCommand.php | 80 ++++++++++++++++++++++++++++++++++++ composer.json | 3 +- config/filesystems.php | 4 ++ stubs/recipe.stub | 23 +++++++++++ stubs/step.stub | 18 ++++++++ 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 app/Commands/PullCommand.php create mode 100644 stubs/recipe.stub create mode 100644 stubs/step.stub diff --git a/app/Commands/PullCommand.php b/app/Commands/PullCommand.php new file mode 100644 index 0000000..190fe13 --- /dev/null +++ b/app/Commands/PullCommand.php @@ -0,0 +1,80 @@ +option('no-process')) { + info('Dry run enabled'); + Process::fake(); + } + // @todo: Pull more than one recipe at a time + $recipe = $this->pullRecipe(); + $this->intallRecipe($recipe); + + // @todo: Should we prompt the user to continue + // if (! confirm('Do you want to run the recipe now?', true)) { + // info('Recipe pulled, but not run.'); + // } + } + + public function intallRecipe(string $recipe): void + { + info('Installing recipe: ' . $recipe); + + $this->pullFromRecipeFromApi($recipe); + } + + protected function pullRecipe(): string + { + $slug = $this->argument('recipe'); + + if (empty($slug)) { + return select( + label: 'Which recipe(s) should I pull?', + options: $this->pullFromListFromApi(), + ); + } + + return $slug; + } + + private function pullFromListFromApi() + { + // @todo: cleanup api config + $data = Http::get('http://mise-app.test/api/recipes') + ->throw() + ->json('data'); + + return collect($data)->pluck('name', 'slug')->all(); + } + + private function pullFromRecipeFromApi($string) + { + $data = Http::get('http://mise-app.test/api/recipes/' . $string) + ->throw() + ->json('data'); + + Storage::drive('local-recipes')->put($data['class'] . '.php', $data['file']); + + collect($data['steps'])->each(function ($step) { + Storage::drive('local-recipes')->put($step['class'] . '.php', $step['file']); + }); + } +} diff --git a/composer.json b/composer.json index a74c90b..81a2ad3 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,8 @@ } ], "require": { - "php": "^8.2.0" + "php": "^8.2.0", + "illuminate/http": "^11.5" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.75", diff --git a/config/filesystems.php b/config/filesystems.php index d9e375a..132e408 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -11,5 +11,9 @@ 'driver' => 'local', 'root' => dirname(__FILE__) . '/..', ], + 'local-recipes' => [ + 'driver' => 'local', + 'root' => $_SERVER['HOME'] . '/.mise/Recipes', + ], ], ]; diff --git a/stubs/recipe.stub b/stubs/recipe.stub new file mode 100644 index 0000000..daad129 --- /dev/null +++ b/stubs/recipe.stub @@ -0,0 +1,23 @@ + Date: Thu, 3 Apr 2025 21:03:26 -0500 Subject: [PATCH 02/10] fix composer config --- composer.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 81a2ad3..88c5a3c 100644 --- a/composer.json +++ b/composer.json @@ -21,10 +21,11 @@ } ], "require": { - "php": "^8.2.0", - "illuminate/http": "^11.5" + "php": "^8.2.0" }, "require-dev": { + "illuminate/http": "^11.5", + "laravel-zero/framework": "^11.36.1", "friendsofphp/php-cs-fixer": "^3.75", "illuminate/log": "^11.5", "illuminate/queue": "^v11.44.2", From a45338dad53654a9c08ff0e32462d903107e3ba9 Mon Sep 17 00:00:00 2001 From: Guillermo Cava Date: Fri, 4 Apr 2025 09:28:27 -0500 Subject: [PATCH 03/10] tweaks pull cmd --- app/Commands/PullCommand.php | 66 ++++++++++++------------ app/Recipes.php | 3 +- app/Services/MiseService.php | 44 ++++++++++++++++ config/app.php | 1 + tests/Feature/MiseServiceTest.php | 83 +++++++++++++++++++++++++++++++ 5 files changed, 162 insertions(+), 35 deletions(-) create mode 100644 app/Services/MiseService.php create mode 100644 tests/Feature/MiseServiceTest.php diff --git a/app/Commands/PullCommand.php b/app/Commands/PullCommand.php index 190fe13..fb1724e 100644 --- a/app/Commands/PullCommand.php +++ b/app/Commands/PullCommand.php @@ -2,13 +2,16 @@ namespace App\Commands; -use Illuminate\Support\Facades\Http; +use App\Services\MiseService; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Storage; use LaravelZero\Framework\Commands\Command; +use function Laravel\Prompts\confirm; +use function Laravel\Prompts\error; use function Laravel\Prompts\info; -use function Laravel\Prompts\select; +use function Laravel\Prompts\multiselect; +use function Laravel\Prompts\note; class PullCommand extends Command { @@ -24,52 +27,47 @@ public function handle(): void info('Dry run enabled'); Process::fake(); } - // @todo: Pull more than one recipe at a time - $recipe = $this->pullRecipe(); - $this->intallRecipe($recipe); - - // @todo: Should we prompt the user to continue - // if (! confirm('Do you want to run the recipe now?', true)) { - // info('Recipe pulled, but not run.'); - // } - } - public function intallRecipe(string $recipe): void - { - info('Installing recipe: ' . $recipe); + $remoteRecipes = $this->selectRemoteRecipes(); - $this->pullFromRecipeFromApi($recipe); + foreach ($remoteRecipes as $recipe) { + $this->install($recipe); + } + + if (confirm('Do you want to run the recipe now?')) { + $this->call('apply', [ + 'recipe' => $remoteRecipes, + '--no-process' => $this->option('no-process'), + ]); + } } - protected function pullRecipe(): string + protected function selectRemoteRecipes(): array { - $slug = $this->argument('recipe'); + $selectedRemoteRecipes = $this->argument('recipe'); - if (empty($slug)) { - return select( + if (empty($selectedRemoteRecipes)) { + return multiselect( label: 'Which recipe(s) should I pull?', - options: $this->pullFromListFromApi(), + options: app(MiseService::class)->allForSelect(), ); } - return $slug; - } - - private function pullFromListFromApi() - { - // @todo: cleanup api config - $data = Http::get('http://mise-app.test/api/recipes') - ->throw() - ->json('data'); + if (count($missingRecipes = array_diff($selectedRemoteRecipes, app(MiseService::class)->keys())) > 0) { + error('The following keys were not found and will be skipped'); + note(collect($missingRecipes)->map(fn ($key) => " {$key}")->implode("\n")); + } - return collect($data)->pluck('name', 'slug')->all(); + return app(MiseService::class)->keys()->filter( + fn (string $key) => in_array($key, $selectedRemoteRecipes) + )->toArray(); } - private function pullFromRecipeFromApi($string) + private function install($key) { - $data = Http::get('http://mise-app.test/api/recipes/' . $string) - ->throw() - ->json('data'); + info('Installing recipe: ' . $key); + + $data = app(MiseService::class)->findByKey($key); Storage::drive('local-recipes')->put($data['class'] . '.php', $data['file']); diff --git a/app/Recipes.php b/app/Recipes.php index e957905..fe4b1af 100644 --- a/app/Recipes.php +++ b/app/Recipes.php @@ -5,12 +5,13 @@ use App\Recipes\Recipe; use Illuminate\Support\Collection; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Storage; class Recipes { public function all(): Collection { - $customRecipesDir = $_SERVER['HOME'] . '/.mise/Recipes'; + $customRecipesDir = Storage::disk('local-recipes')->path(''); if (is_dir($customRecipesDir)) { $this->loadFilesInPath($customRecipesDir); diff --git a/app/Services/MiseService.php b/app/Services/MiseService.php new file mode 100644 index 0000000..e7720db --- /dev/null +++ b/app/Services/MiseService.php @@ -0,0 +1,44 @@ +url = config('app.mise-url'); + } + + public function all(): Collection + { + $data = Http::get($this->url . '/api/recipes') + ->throw() + ->json('data'); + + return collect($data); + } + + public function findByKey(string $recipe): Collection + { + $data = Http::get($this->url . '/api/recipes/' . $recipe) + ->throw() + ->json('data'); + + return collect($data); + } + + public function allForSelect(): Collection + { + return $this->all()->pluck('name', 'key'); + } + + public function keys(): Collection + { + return $this->all()->pluck('key'); + } +} diff --git a/config/app.php b/config/app.php index 4de77d4..45ceaee 100644 --- a/config/app.php +++ b/config/app.php @@ -57,4 +57,5 @@ App\Providers\AppServiceProvider::class, ], + 'mise-url' => env('MISE_URL', 'https://mise.dev'), ]; diff --git a/tests/Feature/MiseServiceTest.php b/tests/Feature/MiseServiceTest.php new file mode 100644 index 0000000..ab18991 --- /dev/null +++ b/tests/Feature/MiseServiceTest.php @@ -0,0 +1,83 @@ + [ + 'data' => [ + ['id' => 1, 'name' => $name = 'Tighten SaaS'], + ['id' => 2, 'name' => 'Nightwatch Play'], + ], + ], + ]); + + $recipes = (new MiseService)->all(); + + expect($recipes)->toBeInstanceOf(Collection::class) + ->and($recipes)->toHaveCount(2) + ->and($recipes[0]['name'])->toBe($name); +}); + +it('pull alls remote keys', function () { + Http::fake([ + 'https://mise.example.com/api/recipes' => [ + 'data' => [ + ['id' => 1, 'name' => 'Tighten SaaS', 'key' => $keyOne = 'tighten-saas'], + ['id' => 2, 'name' => 'Nightwatch Play', 'key' => $keyTwo = 'nightwatch-play'], + ], + ], + ]); + + $keys = (new MiseService)->keys(); + + expect($keys)->toBeInstanceOf(Collection::class) + ->toHaveCount(2) + ->and($keys[0])->toBe($keyOne) + ->and($keys[1])->toBe($keyTwo); +}); + +it('list alls remote recipes', function () { + Http::fake([ + 'https://mise.example.com/api/recipes' => [ + 'data' => [ + ['id' => 1, 'name' => $nameOne = 'Tighten SaaS', 'key' => $keyOne = 'tighten-saas'], + ['id' => 2, 'name' => $nameTwo = 'Nightwatch Play', 'key' => $keyTwo = 'nightwatch-play'], + ], + ], + ]); + + $options = (new MiseService)->allForSelect(); + + expect($options)->toBeInstanceOf(Collection::class) + ->toHaveCount(2) + ->and($options[$keyOne])->toBe($nameOne) + ->and($options[$keyTwo])->toBe($nameTwo); +}); + +it('can fetch a specific recipe', function () { + $key = 'tighten-saas'; + + Http::fake([ + "https://mise.example.com/api/recipes/{$key}" => Http::response([ + 'data' => [ + 'id' => 1, + 'name' => $name = 'Tighten SaaS', + ], + ]), + ]); + + $recipe = (new MiseService)->findByKey($key); + + expect($recipe)->toBeInstanceOf(Collection::class) + ->and($recipe['name'])->toBe($name); +}); From c1fbd88f0cb30cfd986298c886cfd868648217ef Mon Sep 17 00:00:00 2001 From: Guillermo Cava Date: Tue, 15 Apr 2025 20:48:18 -0500 Subject: [PATCH 04/10] remove recipe stubs --- composer.lock | 710 +++++++++++++++++++++++++++++++++++++++++++--- stubs/recipe.stub | 23 -- stubs/step.stub | 18 -- 3 files changed, 674 insertions(+), 77 deletions(-) delete mode 100644 stubs/recipe.stub delete mode 100644 stubs/step.stub diff --git a/composer.lock b/composer.lock index e018a28..52454f1 100644 --- a/composer.lock +++ b/composer.lock @@ -521,26 +521,29 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.4", + "version": "1.1.5", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9" + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=13" + }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12", - "phpstan/phpstan": "1.4.10 || 2.0.3", + "doctrine/coding-standard": "^9 || ^12 || ^13", + "phpstan/phpstan": "1.4.10 || 2.1.11", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -560,9 +563,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.4" + "source": "https://github.com/doctrine/deprecations/tree/1.1.5" }, - "time": "2024-12-07T21:18:45+00:00" + "time": "2025-04-07T20:06:18+00:00" }, { "name": "doctrine/inflector", @@ -1003,6 +1006,77 @@ ], "time": "2025-03-31T18:40:42+00:00" }, + { + "name": "fruitcake/php-cors", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/3d158f36e7875e2f040f37bc0573956240a5a38b", + "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "symfony/http-foundation": "^4.4|^5.4|^6|^7" + }, + "require-dev": { + "phpstan/phpstan": "^1.4", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2023-10-12T05:21:21+00:00" + }, { "name": "graham-campbell/result-type", "version": "v1.1.3", @@ -1390,6 +1464,92 @@ ], "time": "2025-03-27T12:30:47+00:00" }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.4", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "30e286560c137526eccd4ce21b2de477ab0676d2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/30e286560c137526eccd4ce21b2de477ab0676d2", + "reference": "30e286560c137526eccd4ce21b2de477ab0676d2", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.36 || ^9.6.15", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2025-02-03T10:55:03+00:00" + }, { "name": "hamcrest/hamcrest-php", "version": "v2.0.1", @@ -2062,6 +2222,67 @@ }, "time": "2025-02-19T20:17:00+00:00" }, + { + "name": "illuminate/http", + "version": "v11.44.2", + "source": { + "type": "git", + "url": "https://github.com/illuminate/http.git", + "reference": "8fe991160856deebae6e7f45719140228635c99c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/http/zipball/8fe991160856deebae6e7f45719140228635c99c", + "reference": "8fe991160856deebae6e7f45719140228635c99c", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "illuminate/collections": "^11.0", + "illuminate/macroable": "^11.0", + "illuminate/session": "^11.0", + "illuminate/support": "^11.0", + "php": "^8.2", + "symfony/http-foundation": "^7.0.3", + "symfony/http-kernel": "^7.0.3", + "symfony/mime": "^7.0.3", + "symfony/polyfill-php83": "^1.31" + }, + "suggest": { + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image()." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Http\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Http package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-01-23T14:04:49+00:00" + }, { "name": "illuminate/log", "version": "v11.44.2", @@ -2327,6 +2548,63 @@ }, "time": "2025-02-10T11:41:56+00:00" }, + { + "name": "illuminate/session", + "version": "v11.44.2", + "source": { + "type": "git", + "url": "https://github.com/illuminate/session.git", + "reference": "2ad71663c1cca955f483a5227247f13eba3b495c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/session/zipball/2ad71663c1cca955f483a5227247f13eba3b495c", + "reference": "2ad71663c1cca955f483a5227247f13eba3b495c", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-session": "*", + "illuminate/collections": "^11.0", + "illuminate/contracts": "^11.0", + "illuminate/filesystem": "^11.0", + "illuminate/support": "^11.0", + "php": "^8.2", + "symfony/finder": "^7.0.3", + "symfony/http-foundation": "^7.0.3" + }, + "suggest": { + "illuminate/console": "Required to use the session:table command (^11.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Session\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Session package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-01-28T15:20:31+00:00" + }, { "name": "illuminate/support", "version": "v11.44.2", @@ -3588,38 +3866,39 @@ }, { "name": "nunomaduro/collision", - "version": "v8.7.0", + "version": "v8.8.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "586cb8181a257a2152b6a855ca8d9598878a1a26" + "reference": "4cf9f3b47afff38b139fb79ce54fc71799022ce8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/586cb8181a257a2152b6a855ca8d9598878a1a26", - "reference": "586cb8181a257a2152b6a855ca8d9598878a1a26", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/4cf9f3b47afff38b139fb79ce54fc71799022ce8", + "reference": "4cf9f3b47afff38b139fb79ce54fc71799022ce8", "shasum": "" }, "require": { - "filp/whoops": "^2.17.0", + "filp/whoops": "^2.18.0", "nunomaduro/termwind": "^2.3.0", "php": "^8.2.0", - "symfony/console": "^7.2.1" + "symfony/console": "^7.2.5" }, "conflict": { - "laravel/framework": "<11.39.1 || >=13.0.0", - "phpunit/phpunit": "<11.5.3 || >=12.0.0" + "laravel/framework": "<11.44.2 || >=13.0.0", + "phpunit/phpunit": "<11.5.15 || >=13.0.0" }, "require-dev": { - "larastan/larastan": "^2.10.0", - "laravel/framework": "^11.44.2", + "brianium/paratest": "^7.8.3", + "larastan/larastan": "^3.2", + "laravel/framework": "^11.44.2 || ^12.6", "laravel/pint": "^1.21.2", "laravel/sail": "^1.41.0", "laravel/sanctum": "^4.0.8", "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0", - "pestphp/pest": "^3.7.4", - "sebastian/environment": "^6.1.0 || ^7.2.0" + "orchestra/testbench-core": "^9.12.0 || ^10.1", + "pestphp/pest": "^3.8.0", + "sebastian/environment": "^7.2.0 || ^8.0" }, "type": "library", "extra": { @@ -3682,7 +3961,7 @@ "type": "patreon" } ], - "time": "2025-03-14T22:37:40+00:00" + "time": "2025-04-03T14:33:09+00:00" }, { "name": "nunomaduro/laravel-console-summary", @@ -3967,21 +4246,21 @@ }, { "name": "pestphp/pest", - "version": "v3.8.0", + "version": "v3.8.1", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "42e1b9f17fc2b2036701f4b968158264bde542d4" + "reference": "6080f51a0b0830715c48ba0e7458b06907febfe5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/42e1b9f17fc2b2036701f4b968158264bde542d4", - "reference": "42e1b9f17fc2b2036701f4b968158264bde542d4", + "url": "https://api.github.com/repos/pestphp/pest/zipball/6080f51a0b0830715c48ba0e7458b06907febfe5", + "reference": "6080f51a0b0830715c48ba0e7458b06907febfe5", "shasum": "" }, "require": { "brianium/paratest": "^7.8.3", - "nunomaduro/collision": "^8.7.0", + "nunomaduro/collision": "^8.8.0", "nunomaduro/termwind": "^2.3.0", "pestphp/pest-plugin": "^3.0.0", "pestphp/pest-plugin-arch": "^3.1.0", @@ -4063,7 +4342,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v3.8.0" + "source": "https://github.com/pestphp/pest/tree/v3.8.1" }, "funding": [ { @@ -4075,7 +4354,7 @@ "type": "github" } ], - "time": "2025-03-30T17:49:10+00:00" + "time": "2025-04-03T16:35:58+00:00" }, { "name": "pestphp/pest-plugin", @@ -4462,16 +4741,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.1", + "version": "5.6.2", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8" + "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", - "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/92dde6a5919e34835c506ac8c523ef095a95ed62", + "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62", "shasum": "" }, "require": { @@ -4520,9 +4799,9 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.1" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.2" }, - "time": "2024-12-07T09:39:29+00:00" + "time": "2025-04-13T19:20:35+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -7851,6 +8130,282 @@ ], "time": "2024-12-30T19:00:17+00:00" }, + { + "name": "symfony/http-foundation", + "version": "v7.2.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "371272aeb6286f8135e028ca535f8e4d6f114126" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/371272aeb6286f8135e028ca535f8e4d6f114126", + "reference": "371272aeb6286f8135e028ca535f8e4d6f114126", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-mbstring": "~1.1", + "symfony/polyfill-php83": "^1.27" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.2.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-03-25T15:54:33+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.2.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "b1fe91bc1fa454a806d3f98db4ba826eb9941a54" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/b1fe91bc1fa454a806d3f98db4ba826eb9941a54", + "reference": "b1fe91bc1fa454a806d3f98db4ba826eb9941a54", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/css-selector": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/dom-crawler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^7.1", + "symfony/routing": "^6.4|^7.0", + "symfony/serializer": "^7.1", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", + "symfony/var-exporter": "^6.4|^7.0", + "twig/twig": "^3.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.2.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-03-28T13:32:50+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.2.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "87ca22046b78c3feaff04b337f33b38510fd686b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/87ca22046b78c3feaff04b337f33b38510fd686b", + "reference": "87ca22046b78c3feaff04b337f33b38510fd686b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/serializer": "^6.4.3|^7.0.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.2.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-02-19T08:51:20+00:00" + }, { "name": "symfony/options-resolver", "version": "v7.2.0", @@ -8075,6 +8630,89 @@ ], "time": "2024-09-09T11:45:10+00:00" }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/c36586dcf89a12315939e00ec9b4474adcb1d773", + "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, { "name": "symfony/polyfill-intl-normalizer", "version": "v1.31.0", diff --git a/stubs/recipe.stub b/stubs/recipe.stub deleted file mode 100644 index daad129..0000000 --- a/stubs/recipe.stub +++ /dev/null @@ -1,23 +0,0 @@ - Date: Sat, 5 Jul 2025 15:03:26 -0500 Subject: [PATCH 05/10] recurse over local recipes --- app/Recipes.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/app/Recipes.php b/app/Recipes.php index fe4b1af..3b10716 100644 --- a/app/Recipes.php +++ b/app/Recipes.php @@ -6,6 +6,8 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Storage; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; class Recipes { @@ -49,7 +51,9 @@ public function keys(): Collection protected function allInPath(string $path): Collection { return collect(File::allFiles($path)) - ->map(fn ($file) => 'App\\Recipes\\' . str_replace('/', '\\', + ->map(fn ($file) => 'App\\Recipes\\' . str_replace( + '/', + '\\', trim(str_replace([$path, '.php'], '', $file->getPathname()), '/') )) ->filter(fn ($class) => class_exists($class) && is_subclass_of($class, Recipe::class)) @@ -58,8 +62,14 @@ protected function allInPath(string $path): Collection protected function loadFilesInPath(string $path): void { - foreach (glob($path . '/*.php') as $file) { - require_once $file; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + require_once $file->getPathname(); + } } } } From e4bfc664ba1fc812f490498475c6e26196b2d51a Mon Sep 17 00:00:00 2001 From: Guillermo Cava Date: Sat, 5 Jul 2025 15:04:21 -0500 Subject: [PATCH 06/10] add checks to the pull command here we're validating agaisnt the mise lock file to verify if changes to versions or if the recipes already live locally --- app/Commands/PullCommand.php | 117 +++++++++++++++++++++++++++++++---- 1 file changed, 104 insertions(+), 13 deletions(-) diff --git a/app/Commands/PullCommand.php b/app/Commands/PullCommand.php index fb1724e..a3b4c2a 100644 --- a/app/Commands/PullCommand.php +++ b/app/Commands/PullCommand.php @@ -2,16 +2,19 @@ namespace App\Commands; +use App\Services\LocalRecipesService; use App\Services\MiseService; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Process; -use Illuminate\Support\Facades\Storage; use LaravelZero\Framework\Commands\Command; use function Laravel\Prompts\confirm; use function Laravel\Prompts\error; use function Laravel\Prompts\info; -use function Laravel\Prompts\multiselect; +use function Laravel\Prompts\multisearch; use function Laravel\Prompts\note; +use function Laravel\Prompts\select; +use function Laravel\Prompts\table; class PullCommand extends Command { @@ -30,26 +33,41 @@ public function handle(): void $remoteRecipes = $this->selectRemoteRecipes(); - foreach ($remoteRecipes as $recipe) { + $recipeDiff = $this->diffRecipes($remoteRecipes); + + $recipesToInstall = $this->handleConflicts($recipeDiff); + + if (empty($recipesToInstall)) { + info('No recipes to install.'); + + return; + } + + // Install recipes + foreach ($recipesToInstall as $recipe) { $this->install($recipe); } if (confirm('Do you want to run the recipe now?')) { $this->call('apply', [ - 'recipe' => $remoteRecipes, + 'recipe' => $recipesToInstall, '--no-process' => $this->option('no-process'), ]); } } - protected function selectRemoteRecipes(): array + private function selectRemoteRecipes(): array { $selectedRemoteRecipes = $this->argument('recipe'); if (empty($selectedRemoteRecipes)) { - return multiselect( - label: 'Which recipe(s) should I pull?', - options: app(MiseService::class)->allForSelect(), + $options = app(MiseService::class)->allForSelect(); + + return multisearch( + 'Which recipe(s) should I pull?', + fn (string $value) => strlen($value) > 0 + ? $options->filter(fn ($option) => str_contains($option, $value))->all() + : $options->all(), ); } @@ -63,16 +81,89 @@ protected function selectRemoteRecipes(): array )->toArray(); } + private function diffRecipes(array $recipeKeys): Collection + { + $miseService = app(MiseService::class)->all(); + + return collect($recipeKeys)->map(function ($key) use ($miseService) { + $remoteRecipe = $miseService->first(fn ($recipe) => $recipe['key'] === $key); + + $status = 'new'; + + if (app(LocalRecipesService::class)->exists($key)) { + $localRecipe = app(LocalRecipesService::class)->findByKey($key); + + $status = $localRecipe['integrity'] === $remoteRecipe['integrity'] ? 'unchanged' : 'updated'; + } + + return [ + 'key' => $key, + 'status' => $status, + ]; + }); + } + + private function handleConflicts(Collection $recipeAnalysis): array + { + $this->showConflictSummary($recipeAnalysis); + + $existingRecipes = $recipeAnalysis->whereIn('status', ['unchanged', 'updated']); + + if ($existingRecipes->isEmpty()) { + return $recipeAnalysis->pluck('key')->toArray(); + } + + return $this->resolveConflictsInteractively($recipeAnalysis); + } + + private function showConflictSummary(Collection $recipeAnalysis): void + { + if ($recipeAnalysis->isEmpty()) { + return; + } + + info('Checking for conflicts...'); + + $tableData = $recipeAnalysis->map(function ($recipe) { + $actionText = match ($recipe['status']) { + 'new' => 'Will install', + 'unchanged' => 'No changes', + 'updated' => 'Updated available', + }; + + return [ + $recipe['key'], + ucfirst($recipe['status']), + $actionText, + ]; + })->toArray(); + + table(['Recipe', 'Status', 'Action'], $tableData); + } + + private function resolveConflictsInteractively(Collection $recipeAnalysis): array + { + $choice = select( + label: 'How should I handle existing recipes?', + options: [ + 'skip-unchanged' => 'Skip unchanged recipes (recommended)', + 'overwrite-all' => 'Overwrite all existing recipes', + ], + default: 'skip-unchanged' + ); + + return match ($choice) { + 'skip-unchanged' => $recipeAnalysis->whereIn('status', ['new', 'updated'])->pluck('key')->toArray(), + 'overwrite-all' => $recipeAnalysis->pluck('key')->toArray(), + }; + } + private function install($key) { info('Installing recipe: ' . $key); $data = app(MiseService::class)->findByKey($key); - Storage::drive('local-recipes')->put($data['class'] . '.php', $data['file']); - - collect($data['steps'])->each(function ($step) { - Storage::drive('local-recipes')->put($step['class'] . '.php', $step['file']); - }); + app(LocalRecipesService::class)->install($data->get('download_url')); } } From ccc092f8c0eccd740a5096989de1f85a5fa7543c Mon Sep 17 00:00:00 2001 From: Guillermo Cava Date: Sat, 5 Jul 2025 15:04:46 -0500 Subject: [PATCH 07/10] adds the baseline LocalRecipeService --- app/Services/LocalRecipesService.php | 77 ++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 app/Services/LocalRecipesService.php diff --git a/app/Services/LocalRecipesService.php b/app/Services/LocalRecipesService.php new file mode 100644 index 0000000..14bbba4 --- /dev/null +++ b/app/Services/LocalRecipesService.php @@ -0,0 +1,77 @@ +put($data['class'] . '.php', $data['file']); + + // // Save metadata for the main recipe + // $metadata->setRecipeMetadata($key, [ + // 'recipe_class' => $data['class'], + // 'local_hash' => $data['file_hash'], + // 'remote_hash' => $data['file_hash'], + // 'version' => $data['version'] ?? null, + // ]); + + // collect($data['steps'])->each(function ($step) { + // Storage::drive('local-recipes')->put($step['class'] . '.php', $step['file']); + // }); + } + + public function all(): Collection + { + // Get lock file + // Iterate over each recipe + // Return array of recipes + + if (! $this->lockExists()) { + return collect(); + } + + $content = Storage::disk(self::Disk)->get(self::Lock); + + return collect(json_decode($content, true)['recipes'] ?? []); + } + + public function findByKey(string $key): ?Collection + { + return $this->all()->first(fn ($recipe) => $recipe['key'] === $key); + } + + public function exists(string $key): bool + { + return $this->all()->contains('key', $key); + } + + private function lockExists(): bool + { + return Storage::disk(self::Disk)->exists(self::Lock); + } + + private function validateIntegrity(string $content): string + { + return hash('sha256', $content); + } + + private function updateLock(string $key, array $data) {} +} From 49a69b7ab98e5ee800726918193a165c3a553d30 Mon Sep 17 00:00:00 2001 From: Guillermo Cava Date: Sun, 6 Jul 2025 13:09:00 -0500 Subject: [PATCH 08/10] working state --- app/Commands/PullCommand.php | 16 ++++-- app/Services/LocalRecipesService.php | 85 +++++++++++++++++++--------- 2 files changed, 69 insertions(+), 32 deletions(-) diff --git a/app/Commands/PullCommand.php b/app/Commands/PullCommand.php index a3b4c2a..481b0f3 100644 --- a/app/Commands/PullCommand.php +++ b/app/Commands/PullCommand.php @@ -43,7 +43,6 @@ public function handle(): void return; } - // Install recipes foreach ($recipesToInstall as $recipe) { $this->install($recipe); } @@ -71,7 +70,7 @@ private function selectRemoteRecipes(): array ); } - if (count($missingRecipes = array_diff($selectedRemoteRecipes, app(MiseService::class)->keys())) > 0) { + if (count($missingRecipes = array_diff($selectedRemoteRecipes, app(MiseService::class)->keys()->toArray())) > 0) { error('The following keys were not found and will be skipped'); note(collect($missingRecipes)->map(fn ($key) => " {$key}")->implode("\n")); } @@ -93,7 +92,9 @@ private function diffRecipes(array $recipeKeys): Collection if (app(LocalRecipesService::class)->exists($key)) { $localRecipe = app(LocalRecipesService::class)->findByKey($key); - $status = $localRecipe['integrity'] === $remoteRecipe['integrity'] ? 'unchanged' : 'updated'; + $status = $localRecipe['integrity'] === $remoteRecipe['integrity'] && $localRecipe['version'] === $remoteRecipe['version'] + ? 'unchanged' + : 'updated'; } return [ @@ -164,6 +165,13 @@ private function install($key) $data = app(MiseService::class)->findByKey($key); - app(LocalRecipesService::class)->install($data->get('download_url')); + app(LocalRecipesService::class)->install([ + 'key' => $key, + 'name' => $data->get('name'), + 'namespace' => $data->get('namespace'), + 'version' => $data->get('version'), + 'url' => $data->get('download_url'), + 'integrity' => $data->get('integrity'), + ]); } } diff --git a/app/Services/LocalRecipesService.php b/app/Services/LocalRecipesService.php index 14bbba4..459c0da 100644 --- a/app/Services/LocalRecipesService.php +++ b/app/Services/LocalRecipesService.php @@ -2,12 +2,15 @@ namespace App\Services; +use Exception; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; +use ZipArchive; /** * { - * recipes: { key: string, "name": "name", version: string, hash: string, source: { url: string } }[] + * recipes: { key: string, "name": "name", version: string, integrity: string, source: { url: string } }[] * } */ class LocalRecipesService @@ -15,35 +18,48 @@ class LocalRecipesService const Lock = 'mise-lock.json'; const Disk = 'local-recipes'; - public function install($url): void + public function install($package): void { - // Pull Zip file from remote Mise service - // Integrity check - // Extract to local recipes folder - // Update lock file - // - - // Storage::drive('local-recipes')->put($data['class'] . '.php', $data['file']); - - // // Save metadata for the main recipe - // $metadata->setRecipeMetadata($key, [ - // 'recipe_class' => $data['class'], - // 'local_hash' => $data['file_hash'], - // 'remote_hash' => $data['file_hash'], - // 'version' => $data['version'] ?? null, - // ]); - - // collect($data['steps'])->each(function ($step) { - // Storage::drive('local-recipes')->put($step['class'] . '.php', $step['file']); - // }); + $response = Http::get($package['url'])->throw(); + $zipContent = $response->body(); + + if ($this->validateIntegrity($zipContent) !== $package['integrity']) { + throw new Exception('Integrity verification failed. Downloaded file integrity check failed.'); + } + + $tempZipName = 'temp_recipe_' . time() . '_' . random_int(1000, 9999) . '.zip'; + $tempDisk = Storage::disk('local'); + $tempDisk->put($tempZipName, $zipContent); + $tempZipPath = $tempDisk->path($tempZipName); + + $zip = new ZipArchive; + if ($zip->open($tempZipPath) !== true) { + $tempDisk->delete($tempZipName); + throw new Exception('Failed to open zip file.'); + } + + $disk = Storage::disk(self::Disk); + $extractPath = $disk->path($package['namespace']); + + if (! $zip->extractTo($extractPath)) { + $zip->close(); + $tempDisk->delete($tempZipName); + throw new Exception('Failed to extract zip file.'); + } + + $zip->close(); + $tempDisk->delete($tempZipName); + + $this->updateLock($package['key'], [ + 'name' => $package['name'], + 'version' => $package['version'], + 'integrity' => $package['integrity'], + 'source' => ['url' => $package['url']], + ]); } public function all(): Collection { - // Get lock file - // Iterate over each recipe - // Return array of recipes - if (! $this->lockExists()) { return collect(); } @@ -53,7 +69,7 @@ public function all(): Collection return collect(json_decode($content, true)['recipes'] ?? []); } - public function findByKey(string $key): ?Collection + public function findByKey(string $key): ?array { return $this->all()->first(fn ($recipe) => $recipe['key'] === $key); } @@ -70,8 +86,21 @@ private function lockExists(): bool private function validateIntegrity(string $content): string { - return hash('sha256', $content); + return hash('sha512', $content); } - private function updateLock(string $key, array $data) {} + private function updateLock(string $key, array $data): void + { + $lockData = $this->lockExists() ? json_decode(Storage::disk(self::Disk)->get(self::Lock), true) : ['recipes' => []]; + + $existingIndex = collect($lockData['recipes'])->search(fn ($recipe) => $recipe['key'] === $key); + + if (is_int($existingIndex)) { + $lockData['recipes'][$existingIndex] = array_merge(['key' => $key], $data); + } else { + $lockData['recipes'][] = array_merge(['key' => $key], $data); + } + + Storage::disk(self::Disk)->put(self::Lock, json_encode($lockData, JSON_PRETTY_PRINT)); + } } From 39d1f67e54cb0f72339654714a9ce897fe061579 Mon Sep 17 00:00:00 2001 From: Guillermo Cava Date: Mon, 7 Jul 2025 22:34:26 -0500 Subject: [PATCH 09/10] adds local service --- app/Services/LocalRecipesService.php | 54 +++---- tests/Feature/LocalRecipesServiceTest.php | 167 ++++++++++++++++++++++ 2 files changed, 194 insertions(+), 27 deletions(-) create mode 100644 tests/Feature/LocalRecipesServiceTest.php diff --git a/app/Services/LocalRecipesService.php b/app/Services/LocalRecipesService.php index 459c0da..5ad1319 100644 --- a/app/Services/LocalRecipesService.php +++ b/app/Services/LocalRecipesService.php @@ -8,11 +8,6 @@ use Illuminate\Support\Facades\Storage; use ZipArchive; -/** - * { - * recipes: { key: string, "name": "name", version: string, integrity: string, source: { url: string } }[] - * } - */ class LocalRecipesService { const Lock = 'mise-lock.json'; @@ -27,28 +22,7 @@ public function install($package): void throw new Exception('Integrity verification failed. Downloaded file integrity check failed.'); } - $tempZipName = 'temp_recipe_' . time() . '_' . random_int(1000, 9999) . '.zip'; - $tempDisk = Storage::disk('local'); - $tempDisk->put($tempZipName, $zipContent); - $tempZipPath = $tempDisk->path($tempZipName); - - $zip = new ZipArchive; - if ($zip->open($tempZipPath) !== true) { - $tempDisk->delete($tempZipName); - throw new Exception('Failed to open zip file.'); - } - - $disk = Storage::disk(self::Disk); - $extractPath = $disk->path($package['namespace']); - - if (! $zip->extractTo($extractPath)) { - $zip->close(); - $tempDisk->delete($tempZipName); - throw new Exception('Failed to extract zip file.'); - } - - $zip->close(); - $tempDisk->delete($tempZipName); + $this->extractRecipe($zipContent, $package['namespace']); $this->updateLock($package['key'], [ 'name' => $package['name'], @@ -103,4 +77,30 @@ private function updateLock(string $key, array $data): void Storage::disk(self::Disk)->put(self::Lock, json_encode($lockData, JSON_PRETTY_PRINT)); } + + private function extractRecipe($content, $namespace): void + { + $tempZipName = 'temp_recipe_' . uniqid() . '.zip'; + $tempDisk = Storage::disk('local'); + $tempDisk->put($tempZipName, $content); + $tempZipPath = $tempDisk->path($tempZipName); + + $zip = new ZipArchive; + if ($zip->open($tempZipPath) !== true) { + $tempDisk->delete($tempZipName); + throw new Exception('Failed to open zip file.'); + } + + $disk = Storage::disk(self::Disk); + $extractPath = $disk->path($namespace); + + if (! $zip->extractTo($extractPath)) { + $zip->close(); + $tempDisk->delete($tempZipName); + throw new Exception('Failed to extract zip file.'); + } + + $zip->close(); + $tempDisk->delete($tempZipName); + } } diff --git a/tests/Feature/LocalRecipesServiceTest.php b/tests/Feature/LocalRecipesServiceTest.php new file mode 100644 index 0000000..b1b2320 --- /dev/null +++ b/tests/Feature/LocalRecipesServiceTest.php @@ -0,0 +1,167 @@ +all(); + + expect($recipes) + ->toBeInstanceOf(Collection::class) + ->toHaveCount(0); +}); + +it('returns recipes from lock file', function () { + $package = [ + 'key' => 'test-recipe', + 'name' => 'Test Recipe', + 'version' => '1.0.0', + 'integrity' => 'test-hash', + 'source' => ['url' => 'https://example.com/recipe.zip'], + ]; + + Storage::disk('local-recipes')->put('mise-lock.json', json_encode([ + 'recipes' => [ + $package, + ], + ])); + + $recipes = (new LocalRecipesService)->all(); + + expect($recipes) + ->toBeInstanceOf(Collection::class) + ->toHaveCount(1) + ->and($recipes->first())->toBe($package); +}); + +it('can find recipe by key', function () { + Storage::disk('local-recipes')->put('mise-lock.json', json_encode([ + 'recipes' => [ + [ + 'key' => 'recipe-one', + 'name' => 'Recipe One', + 'version' => '1.0.0', + 'integrity' => 'hash-one', + 'source' => ['url' => 'https://example.com/one.zip'], + ], + [ + 'key' => 'recipe-two', + 'name' => 'Recipe Two', + 'version' => '2.0.0', + 'integrity' => 'hash-two', + 'source' => ['url' => 'https://example.com/two.zip'], + ], + ], + ])); + + $recipe = (new LocalRecipesService)->findByKey('recipe-two'); + + expect($recipe)->not->toBeNull() + ->and($recipe['key'])->toBe('recipe-two') + ->and($recipe['name'])->toBe('Recipe Two'); +}); + +it('returns null when recipe key not found', function () { + $recipe = (new LocalRecipesService)->findByKey('non-existent'); + + expect($recipe)->toBeNull(); +}); + +it('checks if recipe exists by key', function () { + Storage::disk('local-recipes')->put('mise-lock.json', json_encode([ + 'recipes' => [ + [ + 'key' => 'existing-recipe', + 'name' => 'Existing Recipe', + 'version' => '1.0.0', + 'integrity' => 'test-hash', + 'source' => ['url' => 'https://example.com/recipe.zip'], + ], + ], + ])); + + $service = new LocalRecipesService; + + expect($service->exists('existing-recipe'))->toBeTrue() + ->and($service->exists('non-existent'))->toBeFalse(); +}); + +it('downloads and validates integrity during install', function () { + $zip = new ZipArchive; + $zip->open($tempZipPath = tempnam(sys_get_temp_dir(), 'test_recipe') . '.zip', ZipArchive::CREATE); + $zip->addFromString('Recipe.php', ' "Test Recipe"];'); + $zip->close(); + + $zipContent = file_get_contents($tempZipPath); + unlink($tempZipPath); + + $package = [ + 'key' => 'test-recipe', + 'name' => 'Test Recipe', + 'version' => '1.0.0', + 'integrity' => $expectedIntegrity = hash('sha512', $zipContent), + 'url' => $url = 'https://mise.tighten.com/recipe.zip', + 'namespace' => 'TestRecipe', + ]; + + Http::fake([ + $url => Http::response($zipContent, 200), + ]); + + (new LocalRecipesService)->install($package); + + Http::assertSent(fn ($request) => $request->url() === $url); + + expect(Storage::disk('local-recipes')->exists('mise-lock.json'))->toBeTrue(); + expect(Storage::disk('local-recipes')->exists('TestRecipe/Recipe.php'))->toBeTrue(); + + $lockContent = json_decode(Storage::disk('local-recipes')->get('mise-lock.json'), true); + expect($lockContent['recipes']) + ->toHaveCount(1) + ->toBe([ + [ + 'key' => 'test-recipe', + 'name' => 'Test Recipe', + 'version' => '1.0.0', + 'integrity' => $expectedIntegrity, + 'source' => ['url' => $url], + ], + ]); +}); + +it('throws exception when integrity verification fails', function () { + $zip = new ZipArchive; + $zip->open($tempZipPath = tempnam(sys_get_temp_dir(), 'test_recipe') . '.zip', ZipArchive::CREATE); + $zip->addFromString('Recipe.php', ' "Test Recipe"];'); + $zip->close(); + + $zipContent = file_get_contents($tempZipPath); + unlink($tempZipPath); + + $package = [ + 'key' => 'test-recipe', + 'name' => 'Test Recipe', + 'version' => '1.0.0', + 'integrity' => 'invalid-hash-that-wont-match', + 'url' => $url = 'https://mise.tighten.com/recipe.zip', + 'namespace' => 'TestRecipe', + ]; + + Http::fake([ + $url => Http::response($zipContent, 200), + ]); + + expect(fn () => (new LocalRecipesService)->install($package)) + ->toThrow(Exception::class, 'Integrity verification failed. Downloaded file integrity check failed.'); +}); From cc41e2f1a2f6f69b513f73d3462b9d3527f771fc Mon Sep 17 00:00:00 2001 From: Guillermo Cava Date: Mon, 7 Jul 2025 23:11:09 -0500 Subject: [PATCH 10/10] adds names to the confirm --- app/Commands/PullCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Commands/PullCommand.php b/app/Commands/PullCommand.php index 481b0f3..202c64b 100644 --- a/app/Commands/PullCommand.php +++ b/app/Commands/PullCommand.php @@ -47,7 +47,7 @@ public function handle(): void $this->install($recipe); } - if (confirm('Do you want to run the recipe now?')) { + if (confirm(sprintf('Should I apply %s?', implode(', ', $recipesToInstall)))) { $this->call('apply', [ 'recipe' => $recipesToInstall, '--no-process' => $this->option('no-process'),