diff --git a/app/Commands/PullCommand.php b/app/Commands/PullCommand.php new file mode 100644 index 0000000..202c64b --- /dev/null +++ b/app/Commands/PullCommand.php @@ -0,0 +1,177 @@ +option('no-process')) { + info('Dry run enabled'); + Process::fake(); + } + + $remoteRecipes = $this->selectRemoteRecipes(); + + $recipeDiff = $this->diffRecipes($remoteRecipes); + + $recipesToInstall = $this->handleConflicts($recipeDiff); + + if (empty($recipesToInstall)) { + info('No recipes to install.'); + + return; + } + + foreach ($recipesToInstall as $recipe) { + $this->install($recipe); + } + + if (confirm(sprintf('Should I apply %s?', implode(', ', $recipesToInstall)))) { + $this->call('apply', [ + 'recipe' => $recipesToInstall, + '--no-process' => $this->option('no-process'), + ]); + } + } + + private function selectRemoteRecipes(): array + { + $selectedRemoteRecipes = $this->argument('recipe'); + + if (empty($selectedRemoteRecipes)) { + $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(), + ); + } + + 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")); + } + + return app(MiseService::class)->keys()->filter( + fn (string $key) => in_array($key, $selectedRemoteRecipes) + )->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'] && $localRecipe['version'] === $remoteRecipe['version'] + ? '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); + + 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/Recipes.php b/app/Recipes.php index e957905..3b10716 100644 --- a/app/Recipes.php +++ b/app/Recipes.php @@ -5,12 +5,15 @@ use App\Recipes\Recipe; use Illuminate\Support\Collection; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Storage; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; class Recipes { public function all(): Collection { - $customRecipesDir = $_SERVER['HOME'] . '/.mise/Recipes'; + $customRecipesDir = Storage::disk('local-recipes')->path(''); if (is_dir($customRecipesDir)) { $this->loadFilesInPath($customRecipesDir); @@ -48,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)) @@ -57,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(); + } } } } diff --git a/app/Services/LocalRecipesService.php b/app/Services/LocalRecipesService.php new file mode 100644 index 0000000..5ad1319 --- /dev/null +++ b/app/Services/LocalRecipesService.php @@ -0,0 +1,106 @@ +throw(); + $zipContent = $response->body(); + + if ($this->validateIntegrity($zipContent) !== $package['integrity']) { + throw new Exception('Integrity verification failed. Downloaded file integrity check failed.'); + } + + $this->extractRecipe($zipContent, $package['namespace']); + + $this->updateLock($package['key'], [ + 'name' => $package['name'], + 'version' => $package['version'], + 'integrity' => $package['integrity'], + 'source' => ['url' => $package['url']], + ]); + } + + public function all(): Collection + { + 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): ?array + { + 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('sha512', $content); + } + + 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)); + } + + 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/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/composer.json b/composer.json index a74c90b..88c5a3c 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,8 @@ "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", 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/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/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/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.'); +}); 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); +});