diff --git a/CHANGELOG.md b/CHANGELOG.md index c62ff21..dc68061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to `laranail/toolkit` are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Removed + +- **Breaking: the `laranail::toolkit.tidy` command** (`Simtabi\Laranail\Toolkit\Commands\Tidy`) moved to `laranail/artisan-ui` as `laranail::artisan-ui.tidy`, with the same actions, options and safety rules. Install that package and call the new name; there is no forwarder here. +- **`routes/web.php` and `routes/api.php`.** No provider ever loaded them. `web.php` held seven unauthenticated `GET` closures that rebuilt and cleared caches (`/tidy/*`), and `api.php` was empty. Their actions are in `laranail/artisan-ui`'s authenticated Caches and Tidy quick-action groups. + +### Changed + +- The branch alias is `0.3.x-dev`. Consumers on `^0.2` stay on 0.2.x and keep `laranail::toolkit.tidy`; require `^0.3` to move. + ## [0.2.3] - 2026-10-05 ### Added diff --git a/composer.json b/composer.json index 0e63050..bcf4f54 100644 --- a/composer.json +++ b/composer.json @@ -112,7 +112,7 @@ } }, "branch-alias": { - "dev-main": "0.2.x-dev" + "dev-main": "0.3.x-dev" } }, "suggest": { diff --git a/docs/architecture.md b/docs/architecture.md index 448136b..0471b75 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,17 +43,13 @@ src/ > The command base (`Command` + `SupportsNamespacedNames`) is **not** local — it > comes from [`laranail/console`](https://opensource.simtabi.com/console/) `^1.0`, -> the org-canonical command base. All three toolkit commands — `MakeCrud`, -> `IdeHelperMacros`, `Tidy` — extend it and use its **full +> the org-canonical command base. Both toolkit commands — `MakeCrud` and +> `IdeHelperMacros` — extend it and use its **full > feature set**: the fluent `$this->consoleWriter()` (context statuses > success/error/warning/info/note, styling, emoji) and the `$this->services` > lifecycle (`performance`, `signals`, `interaction`, `logger`, `error`, -> `metadata`, `display`). The heavy `Tidy` command makes its destructive sweep -> **signal-safe** (`signals()->shouldKeepRunning()`), confirms through -> `interaction()->confirmAction()` (safe default in non-interactive mode), and -> captures failures via the **auto-redacting** `error()->logError()` — so -> credentials never reach a log channel. The existing security hardening -> (FilePathGuard storage confinement, `db` action gating) is untouched. +> `metadata`, `display`). The maintenance command `Tidy` moved to `laranail/artisan-ui` +> (`laranail::artisan-ui.tidy`) in 0.3.0. ## Adding a feature / tool / module diff --git a/docs/commands.md b/docs/commands.md index 1fd6dc7..9987776 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -10,7 +10,6 @@ alias), and injects its collaborators — no facades in the core logic. |---|---|---|---| | CRUD generator | `laranail::toolkit.make-crud` | `laranail::toolkit.make-crud` | [make-crud](make-crud.md) | | IDE-helper macros | `laranail::toolkit.ide-helper-macros` | `laranail::toolkit.ide-helper-macros` | [macros](macros.md) | -| Tidy | `laranail::toolkit.tidy` | `tidy` | below | --- @@ -62,90 +61,21 @@ non-zero. The non-interactive flag is read from the input, so `--no-interaction` the stub size via `display()->formatBytes(strlen(...))`. Accepts `--path=` to override the output location (default `ide-helper/_ide_helper_macros.php` under the base path). -- **`tidy`** (heavy) — `consoleWriter()` throughout; the file sweep is - **signal-safe** (`shouldKeepRunning()` is polled per root and per file); - destructive prompts route through `interaction()->confirmAction()` (the existing - `db` gating is preserved); each run is timed with `performance()` and logged via - `logger()->logCompletion()` with files-processed + space-freed `metadata()`. --- -## `laranail::toolkit.tidy` — maintenance / cleanup +## Tidy moved to `laranail/artisan-ui` -Unified, path-confined cleanup: `cache`, `logs`, `temp`, `storage`, `db`, `all`. +The maintenance command `laranail::toolkit.tidy` was removed in 0.3.0. It now lives in +[`laranail/artisan-ui`](https://opensource.simtabi.com/documentation/laranail/artisan-ui/) as +`laranail::artisan-ui.tidy`, with the same actions, options and safety rules (storage-confined +deletion, scoped `storage` sweeps, `db` gated and kept out of `all`). Install that package and call +the new name: ```bash -php artisan laranail::toolkit.tidy [action] [options] +php artisan laranail::artisan-ui.tidy logs --days=30 --force ``` -| Option | Description | -|---|---| -| `action` (argument) | `cache`, `logs`, `temp`, `storage`, `db`, or `all` (default). | -| `--days=` | Only delete files older than this many days. | -| `--size=` | Only delete files larger than this many MB. | -| `--seed` | (db) also run `db:seed` after `migrate:fresh`. | -| `--optimize` | (cache) also run `optimize:clear`. | -| `--dry-run` | Show what would be removed without deleting anything. | -| `--unfiltered` | (`storage`) sweep user files with no age/size filter. Refused in production. | -| `--force` | Skip confirmation prompts (required for the `db` action). | - -### Actions - -- **`cache`** — flushes the application cache; with `--optimize` also runs - `optimize:clear`. `--dry-run` previews; the freed space is reported. -- **`logs` / `temp` / `storage`** — delete files under the relevant - `storage_path()` roots, filtered by `--days` (age) and/or `--size`. - `.gitignore` files are always preserved. -- **`db`** — runs `migrate:fresh` (optionally `--seed`). **Excluded from - `all`.** Requires `--force` AND passes the production-safety - `confirmToProceed()` guard; it is a no-op under `--dry-run`. -- **`all`** — tidies cache + `logs` / `temp`, and `storage` **only when scoped** - (see below). It **never** runs the destructive `db` action. - -### The `storage` action needs a scope - -`storage` sweeps `storage/app/public`, `storage/app/uploads` and -`storage/app/exports`. The first is the disk behind `storage:link` — user -uploads. So unlike `logs` and `temp`, which hold data the application -regenerates, an unfiltered sweep here is not housekeeping. - -| Invocation | Result | -|---|---| -| `tidy storage --days=30 --force` | Deletes uploads older than 30 days. | -| `tidy storage --size=100 --force` | Deletes uploads over 100 MB. | -| `tidy storage --force` | **Refused.** Every file would match. | -| `tidy storage --unfiltered --force` | Deletes everything — outside production only. | -| `tidy storage --unfiltered --force` *(production)* | **Refused**, with no override. | -| `tidy all --force` | Sweeps cache + logs + temp; **skips** `storage` and says so. | -| `tidy all --days=30 --force` | Sweeps everything, `storage` included. | - -> Through v0.1.0, `tidy storage --force` and `tidy all --force` deleted every -> file in those roots. The containment guard did not catch it and never would -> have: it answers "can this delete something outside `storage_path()`", and -> those roots are inside it. - -### Safety notes - -- **Every deletion is confined to `storage_path()`.** The swept roots are - storage-relative; each is realpath-resolved and proven to sit inside - `realpath(storage_path())` before any delete. Each candidate file is - realpath-resolved and re-checked for containment, so a `..` path or a symlink - pointing **outside** storage is skipped, never followed. Paths are additionally - screened by the `FilePathGuard` (`..` / null-byte rejection). -- **`--dry-run` deletes nothing** — it previews each candidate and reports the - space that *would* be freed. -- **`db` is hard-gated** behind `--force` + `confirmToProceed()` and excluded - from `all`, so a bulk tidy can never drop your tables. -- **`--force` is not the gate for user files.** It appears in every CI - invocation, so it is typed by habit; scoping the `storage` sweep with - `--days`/`--size`, or acknowledging it with `--unfiltered`, is. -- **Signal-safe sweep.** The deletion loop polls - `signals()->shouldKeepRunning()` per root and per file, so a `SIGTERM` / - `SIGINT` stops the sweep cleanly mid-directory rather than leaving it half-run. - Without ext-pcntl (e.g. Windows) the check defaults `true`, so a normal run is - unaffected. Every run is timed and a `logger()->logCompletion()` summary records - files-processed and space-freed. - --- ## See also diff --git a/docs/exceptions.md b/docs/exceptions.md index cf6bd08..1f22d86 100644 --- a/docs/exceptions.md +++ b/docs/exceptions.md @@ -131,8 +131,8 @@ throw InvalidPathException::directoryTraversal($path); // \RuntimeException `InvalidPathException` adds reason-specific factories on top of `create()`: `directoryTraversal()`, `nullByteDetected()`, `outsideAllowedDirectory()`, and -`invalidCharacters()` — used by the path-confinement guards across the file, -database, and tidy code paths. +`invalidCharacters()` — used by the path-confinement guards across the file +and database code paths. `FileTooLargeException::create()` formats both the actual and maximum size as human-readable strings (e.g. `1.50 MB`) in the message. diff --git a/routes/api.php b/routes/api.php deleted file mode 100755 index 174d7fd..0000000 --- a/routes/api.php +++ /dev/null @@ -1,3 +0,0 @@ - 'tidy', 'as' => 'tidy.', 'middleware' => ['web']], function () { - - Route::get('/', ['name' => 'all', 'uses' => function () { - Artisan::call('optimize'); - Artisan::call('route:cache'); - Artisan::call('cache:clear'); - Artisan::call('view:clear'); - Artisan::call('config:cache'); - Artisan::call('clear-compiled'); - - return 'All Cache, Route, Config, and View files have been purged successfully!'; - }]); - - Route::get('/optimize', ['name' => 'optimize', 'uses' => function () { - Artisan::call('optimize'); - - return 'Application optimized successfully!'; - }]); - - Route::get('/route', ['name' => 'route', 'uses' => function () { - Artisan::call('route:cache'); - - return 'Application route cache cleared successfully!'; - }]); - - Route::get('/cache', ['name' => 'cache', 'uses' => function () { - Artisan::call('cache:clear'); - - return 'Application cache cleared successfully!'; - }]); - - Route::get('/view', ['name' => 'view', 'uses' => function () { - Artisan::call('view:clear'); - - return 'Application views cleared successfully!'; - }]); - - Route::get('/config', ['name' => 'config', 'uses' => function () { - Artisan::call('config:cache'); - - return 'Application config cleared successfully!'; - }]); - - Route::get('/clear-compiled', ['name' => 'clear-compiled', 'uses' => function () { - Artisan::call('clear-compiled'); - - return 'Compiled files cleared successfully!'; - }]); - -}); diff --git a/src/Commands/Tidy.php b/src/Commands/Tidy.php deleted file mode 100644 index decdb1e..0000000 --- a/src/Commands/Tidy.php +++ /dev/null @@ -1,539 +0,0 @@ - ['logs'], - 'temp' => ['app/temp', 'app/tmp', 'framework/cache/data'], - 'storage' => ['app/public', 'app/uploads', 'app/exports'], - ]; - - /** - * Actions whose roots hold data the application cannot regenerate. - * - * `logs` and `temp` sweep log files and `framework/cache/data` — losing - * those costs nothing, which is the whole point of the command. `storage` - * sweeps `app/public` (the disk behind `storage:link`), `app/uploads` and - * `app/exports`: user uploads. With no `--days`/`--size` every file matched, - * and `--force` skipped the prompt, so `tidy storage --force` — and - * `tidy all --force`, which used to sweep the same roots — deleted every - * file a user had ever uploaded, in one non-interactive command with a name - * that reads like housekeeping. - * - * @var list - */ - private const USER_DATA_ACTIONS = ['storage']; - - protected $signature = 'laranail::toolkit.tidy - {action? : What to tidy (cache|logs|temp|storage|db|all) — defaults to all} - {--days= : Only delete files older than this many days} - {--size= : Only delete files larger than this many MB} - {--seed : (db) also run db:seed after migrate:fresh} - {--optimize : (cache) also run optimize:clear} - {--dry-run : Show what would be removed without deleting anything} - {--unfiltered : (storage) sweep user files with no age/size filter — refused in production} - {--force : Skip confirmation prompts (required for the db action)}'; - - protected $description = 'Unified, path-confined maintenance: cache|logs|temp|storage|db|all'; - - private int $filesProcessed = 0; - - private int $spaceFreed = 0; - - public function __construct( - private readonly FileServiceInterface $files, - private readonly CacheRepositoryInterface $cache, - private readonly LoggerServiceInterface $log, - ) { - parent::__construct(); - } - - public function handle(): int - { - $argument = $this->argument('action'); - $action = is_string($argument) && $argument !== '' ? $argument : 'all'; - - // Wire SIGTERM/SIGINT graceful-stop handlers (no-op without ext-pcntl) - // so the file sweep can stop between files on Ctrl-C. Time the whole run - // so the completion summary carries a real execution-time figure. - $this->services->signals()->setupSignalHandling(); - $this->services->performance()->startTimer(); - $this->services->metadata()->addMany([ - 'action' => $action, - 'dry_run' => $this->isDryRun(), - ]); - - $result = match ($action) { - 'cache' => $this->tidyCache(), - 'logs', 'temp', 'storage' => $this->tidyFiles($action), - 'db' => $this->tidyDatabase(), - 'all' => $this->tidyAll(), - default => $this->invalidAction($action), - }; - - $this->logSummary($action, $result); - - return $result; - } - - /** - * Record a structured completion summary (files processed, bytes freed, - * execution time) via the logger service after every run. - */ - private function logSummary(string $action, int $exitCode): void - { - $performance = $this->services->performance(); - $performance->endTimer(); - - $this->services->metadata()->addMany([ - 'files_processed' => $this->filesProcessed, - 'space_freed' => $this->services->display()->formatBytes($this->spaceFreed), - ]); - - $this->services->logger()->logCompletion($exitCode, [ - 'execution_time' => $performance->getFormattedExecutionTime(), - ], $this->services->metadata()->all()); - } - - // ----------------------------------------------------------------------- - // cache - // ----------------------------------------------------------------------- - - private function tidyCache(): int - { - $writer = $this->consoleWriter(); - - if ($this->isDryRun()) { - $writer->info('[dry-run] Would flush the application cache' . ($this->boolOption('optimize') ? ' and run optimize:clear' : '') . '.'); - - return self::SUCCESS; - } - - if (! $this->confirmAction('Flush the application cache?')) { - return self::SUCCESS; - } - - $this->cache->forget('__laranail_tidy_probe__'); - - try { - // Flush via the framework's own command so every store/driver is - // honoured; the injected cache repo is exercised above for DI parity. - $this->call('cache:clear'); - } catch (Throwable $e) { - $this->log->error('Cache flush failed during tidy', ['error' => $e->getMessage()]); - } - - if ($this->boolOption('optimize')) { - $this->call('optimize:clear'); - } - - $writer->success('Cache tidied.'); - - return self::SUCCESS; - } - - // ----------------------------------------------------------------------- - // logs / temp / storage (path-confined deletion) - // ----------------------------------------------------------------------- - - private function tidyFiles(string $action, bool $confirm = true): int - { - $this->filesProcessed = 0; - $this->spaceFreed = 0; - - $base = realpath(storage_path()); - - if ($base === false) { - $this->consoleWriter()->error('storage_path() does not resolve — nothing to tidy.'); - - return self::FAILURE; - } - - if (! $this->maySweepUnfiltered($action)) { - return self::FAILURE; - } - - // The `all` action confirms once up front, so its per-root sweeps skip - // the prompt (passing $confirm = false) to avoid re-asking per action. - if ($confirm && ! $this->isDryRun() && ! $this->confirmAction("Clean {$action} files under storage?")) { - return self::SUCCESS; - } - - $signals = $this->services->signals(); - - foreach (self::ROOTS[$action] as $relative) { - // Stop sweeping further roots if a termination signal arrived. - // shouldKeepRunning() defaults true (and without pcntl), so a normal - // run sweeps every root. - if (! $signals->shouldKeepRunning()) { - break; - } - - $this->sweep($base, $relative); - } - - $verb = $this->isDryRun() ? 'Would free' : 'Freed'; - $this->consoleWriter()->success(sprintf( - '%s: %d file(s), %s%s.', - $this->isDryRun() ? '[dry-run] ' . $verb : $verb, - $this->filesProcessed, - $this->files->formatFileSize($this->spaceFreed), - $this->isDryRun() ? ' (nothing deleted)' : '', - )); - - return self::SUCCESS; - } - - /** - * Sweep one storage-relative directory, deleting (or previewing) the files - * that pass the age/size filters. Each candidate is realpath-resolved and - * proven to sit inside the swept root before any delete — a `..` path or a - * symlink pointing outside storage is skipped, never followed. - */ - private function sweep(string $base, string $relative): void - { - if (! $this->isSafePath($relative)) { - return; - } - - $root = realpath($base . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative)); - - if ($root === false || ! is_dir($root) || ! $this->isWithin($root, $base)) { - return; - } - - $cutoff = $this->ageCutoff(); - $minBytes = $this->minBytes(); - - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), - ); - - $signals = $this->services->signals(); - - /** @var SplFileInfo $file */ - foreach ($iterator as $file) { - // Signal-safe sweep: stop deleting mid-directory on Ctrl-C/SIGTERM. - // Defaults true (and without pcntl), so a normal run is unaffected. - if (! $signals->shouldKeepRunning()) { - break; - } - - if (! $file->isFile()) { - continue; - } - - $real = realpath($file->getPathname()); - - // Containment re-check on the resolved path: a symlink that escapes - // the swept root is never deleted. - if ($real === false || ! $this->isWithin($real, $root)) { - continue; - } - - if (str_ends_with($file->getFilename(), '.gitignore')) { - continue; - } - - $size = $file->getSize(); - - if (! $this->matchesFilters($real, $size, $cutoff, $minBytes)) { - continue; - } - - $this->filesProcessed++; - - if ($this->isDryRun()) { - $this->consoleWriter()->line(" would delete {$real}"); - - continue; - } - - if (@unlink($real)) { - $this->spaceFreed += $size; - } - } - } - - private function matchesFilters(string $path, int $size, ?int $cutoff, ?int $minBytes): bool - { - // With no filters, every (non-gitignore) file qualifies. - if ($cutoff === null && $minBytes === null) { - return true; - } - - // Each supplied filter must pass — with both `--days` and `--size` a file - // must be older AND larger, so a huge but fresh file is not swept. - if ($cutoff !== null) { - $mtime = @filemtime($path); - - if ($mtime === false || $mtime >= $cutoff) { - return false; - } - } - - if ($minBytes !== null && $size < $minBytes) { - return false; - } - - return true; - } - - // ----------------------------------------------------------------------- - // db (migrate:fresh — excluded from all, hard-gated) - // ----------------------------------------------------------------------- - - private function tidyDatabase(): int - { - $writer = $this->consoleWriter(); - - if ($this->isDryRun()) { - $writer->info('[dry-run] Would run migrate:fresh' . ($this->boolOption('seed') ? ' --seed' : '') . ' (skipped in dry-run).'); - - return self::SUCCESS; - } - - if (! $this->boolOption('force')) { - $writer->error('The db action runs migrate:fresh and DROPS ALL TABLES — re-run with --force.'); - - return self::FAILURE; - } - - // Production-safety prompt (no-op when --force is honoured outside prod). - if (! $this->confirmToProceed()) { - return self::FAILURE; - } - - $this->call('migrate:fresh', ['--force' => true]); - - if ($this->boolOption('seed')) { - $this->call('db:seed', ['--force' => true]); - } - - $writer->success('Database refreshed.'); - - return self::SUCCESS; - } - - // ----------------------------------------------------------------------- - // all (cache + file roots; NEVER db) - // ----------------------------------------------------------------------- - - /** - * `all` sweeps the regenerable roots unconditionally and the user-file - * roots only when scoped. - * - * Skipping rather than refusing, because `logs` and `temp` are what `all` - * is for and failing the whole run over `storage` would make the useful - * part unreachable. The skip is printed — an omission this consequential - * should not be inferred from a file count. - */ - private function tidyAll(): int - { - if (! $this->isDryRun() && ! $this->confirmAction('Tidy cache, logs, temp and storage? (the db action is excluded.)')) { - return self::SUCCESS; - } - - $this->tidyCacheQuietly(); - - $signals = $this->services->signals(); - $skipped = []; - - foreach (['logs', 'temp', 'storage'] as $action) { - if (! $signals->shouldKeepRunning()) { - break; - } - - if (in_array($action, self::USER_DATA_ACTIONS, true) && ! $this->maySweepUnfiltered($action)) { - $skipped[] = $action; - - continue; - } - - $this->tidyFiles($action, confirm: false); - } - - $this->consoleWriter()->success(sprintf( - 'All tidied (db excluded — run the db action explicitly)%s.', - $skipped === [] ? '' : ', ' . implode(' and ', $skipped) . ' skipped', - )); - - return self::SUCCESS; - } - - private function tidyCacheQuietly(): void - { - if ($this->isDryRun()) { - $this->consoleWriter()->info('[dry-run] Would flush the application cache.'); - - return; - } - - try { - $this->call('cache:clear'); - } catch (Throwable $e) { - $this->log->log(LogLevel::Error, 'Cache flush failed during tidy all', ['error' => $e->getMessage()]); - } - } - - // ----------------------------------------------------------------------- - // filters / helpers - // ----------------------------------------------------------------------- - - private function ageCutoff(): ?int - { - $days = $this->option('days'); - - if (! is_string($days) || trim($days) === '') { - return null; - } - - return time() - (max(0, (int) $days) * 86400); - } - - private function minBytes(): ?int - { - $size = $this->option('size'); - - if (! is_string($size) || trim($size) === '') { - return null; - } - - return max(0, (int) $size) * 1024 * 1024; - } - - private function isDryRun(): bool - { - return $this->boolOption('dry-run'); - } - - /** - * Refuse an unfiltered sweep of the roots that hold user files. - * - * `--force` is habitual — it is in every CI invocation and most of this - * command's own test suite — so it cannot be what stands between a - * maintenance command and every user upload. The gate is therefore a flag - * that exists for nothing else and cannot be typed by muscle memory. - * - * A filter makes the sweep intentional on its own: `--days=30` deletes what - * is stale, `--size=100` what is oversized, and both together only what is - * stale AND oversized. Those pass unchanged. What is refused is the - * no-filter case, which matches every file in `app/public`. - * - * In production the acknowledgement is not offered at all. There is no - * legitimate reason to empty a production public disk through a housekeeping - * command, and `--force` bypasses Laravel's own `confirmToProceed()` by - * design, so a prompt would not have been a gate either. - */ - private function maySweepUnfiltered(string $action): bool - { - if (! in_array($action, self::USER_DATA_ACTIONS, true)) { - return true; - } - - if ($this->ageCutoff() !== null || $this->minBytes() !== null) { - return true; - } - - $roots = implode(', ', array_map( - static fn (string $relative): string => "storage/{$relative}", - self::ROOTS[$action], - )); - - $writer = $this->consoleWriter(); - - if ($this->getLaravel()->environment('production')) { - $writer->error("Refusing to sweep {$roots} unfiltered in production — these hold user files."); - $writer->line(' Pass --days= or --size= to delete only what is stale or oversized.'); - - return false; - } - - if (! $this->boolOption('unfiltered')) { - $writer->error("Refusing to sweep {$roots} with no filter — these hold user files, and every one would match."); - $writer->line(' Pass --days=30 or --size=100 to scope it,'); - $writer->line(' or --unfiltered to delete all of it anyway (never in production).'); - - return false; - } - - return true; - } - - private function boolOption(string $key): bool - { - return $this->option($key) === true; - } - - private function confirmAction(string $message): bool - { - if ($this->boolOption('force')) { - return true; - } - - // Routed through the interaction service: Laravel Prompts on a TTY, and - // the default (false) in non-interactive mode — a piped/CI run never - // silently proceeds with a destructive delete. - return $this->services->interaction()->confirmAction($message, false); - } - - private function isWithin(string $path, string $root): bool - { - $root = rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; - - return $path === rtrim($root, DIRECTORY_SEPARATOR) || str_starts_with($path, $root); - } - - private function invalidAction(string $action): int - { - $this->consoleWriter()->error("Invalid action [{$action}]. Available: cache, logs, temp, storage, db, all."); - - return self::FAILURE; - } -} diff --git a/src/Providers/ToolkitServiceProvider.php b/src/Providers/ToolkitServiceProvider.php index cb4bc1d..6f13fa7 100644 --- a/src/Providers/ToolkitServiceProvider.php +++ b/src/Providers/ToolkitServiceProvider.php @@ -12,7 +12,6 @@ use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\Validator; use Illuminate\View\Factory as ViewFactory; -use Simtabi\Laranail\Toolkit\Commands\Tidy; use Simtabi\Laranail\Toolkit\Helpers\Helper; use Simtabi\Laranail\Toolkit\ToolkitManager; use Illuminate\Foundation\Console\AboutCommand; @@ -195,7 +194,7 @@ public function boot(): void $this->loadJsonTranslationsFrom($this->app->langPath('vendor/laranail-toolkit')); $this->loadMigrationsFrom("{$root}/database/migrations"); - $this->commands([MakeCrud::class, IdeHelperMacros::class, Tidy::class]); + $this->commands([MakeCrud::class, IdeHelperMacros::class]); $router = $this->app->make(Router::class); foreach (self::MIDDLEWARE_ALIASES as $alias => $class) { diff --git a/tests/Fixtures/Legacy/removed-symbols.json b/tests/Fixtures/Legacy/removed-symbols.json index e27539a..2ebf08c 100644 --- a/tests/Fixtures/Legacy/removed-symbols.json +++ b/tests/Fixtures/Legacy/removed-symbols.json @@ -293,7 +293,7 @@ }, "Simtabi\\Laranail\\Laravel\\Commands\\TidyCommand": { "status": "merged", - "target": "Merged + hardened into Simtabi\\Laranail\\Toolkit\\Commands\\Tidy (laranail::toolkit.tidy; storage-confined deletion, gated migrate:fresh)." + "target": "Merged + hardened into Simtabi\\Laranail\\Toolkit\\Commands\\Tidy (laranail::toolkit.tidy), which moved to laranail/artisan-ui as laranail::artisan-ui.tidy in toolkit 0.3.0." }, "Simtabi\\Laranail\\Laravel\\Listeners\\BaseListener": { "status": "merged", diff --git a/tests/Unit/Console/CommandConsoleFeaturesTest.php b/tests/Unit/Console/CommandConsoleFeaturesTest.php deleted file mode 100644 index 66e8e2f..0000000 --- a/tests/Unit/Console/CommandConsoleFeaturesTest.php +++ /dev/null @@ -1,111 +0,0 @@ -services` lifecycle (metadata, signals, - * performance, logger) and the fluent `consoleWriter()` output. Signal handling - * is driven through the service's running flag — no real OS signal is raised, so - * these pass with or without ext-pcntl. - */ -class CommandConsoleFeaturesTest extends TestCase -{ - // ----------------------------------------------------------------------- - // metadata is populated through the lifecycle - // ----------------------------------------------------------------------- - - public function test_tidy_records_files_processed_and_space_freed_metadata(): void - { - $log = storage_path('logs/meta_' . uniqid() . '.log'); - @mkdir(dirname($log), 0777, true); - file_put_contents($log, str_repeat('x', 128)); - - /** @var Tidy $command */ - $command = $this->app->make(Tidy::class); - - $exit = $this->runCommand($command, ['action' => 'logs', '--force' => true]); - - $this->assertSame(0, $exit); - - $metadata = $command->getServices()->metadata(); - $this->assertSame('logs', $metadata->get('action')); - $this->assertGreaterThanOrEqual(1, $metadata->get('files_processed')); - $this->assertIsString($metadata->get('space_freed')); - $this->assertFileDoesNotExist($log); - } - - // ----------------------------------------------------------------------- - // signal-safe sweep / clean loop (driven via the service flag, no OS signal) - // ----------------------------------------------------------------------- - - public function test_tidy_sweep_stops_when_termination_requested(): void - { - $log = storage_path('logs/keep_' . uniqid() . '.log'); - @mkdir(dirname($log), 0777, true); - file_put_contents($log, 'keep-me'); - - /** @var Tidy $command */ - $command = $this->app->make(Tidy::class); - - // Flip the running flag off before the run so the file sweep bails on the - // first iteration — no file is deleted. shouldKeepRunning() defaults true, - // so this isolates the signal-safe break path without an OS signal. - $exit = $this->runCommand($command, ['action' => 'logs', '--force' => true], static function (Tidy $c): void { - $c->getServices()->signals()->stop(); - }); - - $this->assertSame(0, $exit); - $this->assertFileExists($log, 'A termination request must stop the sweep before deleting.'); - - @unlink($log); - } - - public function test_tidy_all_stops_sweeping_roots_when_termination_requested(): void - { - $upload = storage_path('app/uploads/all_' . uniqid() . '.bin'); - @mkdir(dirname($upload), 0777, true); - file_put_contents($upload, 'keep-me'); - - /** @var Tidy $command */ - $command = $this->app->make(Tidy::class); - - // Stop before the run: tidyAll flushes the cache, then the per-root loop - // breaks on the first check — so the storage upload is never swept. - $exit = $this->runCommand($command, ['action' => 'all', '--force' => true], static function (Tidy $c): void { - $c->getServices()->signals()->stop(); - }); - - $this->assertSame(0, $exit); - $this->assertFileExists($upload); - - @unlink($upload); - } - - /** - * Resolve a registered command, bind a buffered output and run it with the - * given input array — returning the exit code so the caller can assert on - * both the effect and the populated command metadata. - * - * @param array $input - */ - private function runCommand(object $command, array $input, ?callable $before = null): int - { - $command->setLaravel($this->app); - $command->setApplication(new Application); - - if ($before !== null) { - $before($command); - } - - return $command->run(new ArrayInput($input), new BufferedOutput); - } -} diff --git a/tests/Unit/Console/TidyBranchesTest.php b/tests/Unit/Console/TidyBranchesTest.php deleted file mode 100644 index 60cce7d..0000000 --- a/tests/Unit/Console/TidyBranchesTest.php +++ /dev/null @@ -1,263 +0,0 @@ - */ - private array $created = []; - - protected function tearDown(): void - { - foreach ($this->created as $path) { - if (is_file($path) || is_link($path)) { - @unlink($path); - } - } - - parent::tearDown(); - } - - // ----------------------------------------------------------------------- - // Declined confirmations (non-interactive, no --force) - // ----------------------------------------------------------------------- - - public function test_cache_action_declined_in_non_interactive_mode_is_a_noop(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'cache', '--no-interaction' => true]) - ->doesntExpectOutputToContain('Cache tidied.') - ->assertExitCode(0); - } - - public function test_all_action_declined_in_non_interactive_mode_is_a_noop(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'all', '--no-interaction' => true]) - ->doesntExpectOutputToContain('All tidied') - ->assertExitCode(0); - } - - public function test_all_dry_run_previews_the_cache_flush(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'all', '--dry-run' => true]) - ->expectsOutputToContain('[dry-run] Would flush the application cache.') - ->assertExitCode(0); - } - - // ----------------------------------------------------------------------- - // Unresolvable storage path - // ----------------------------------------------------------------------- - - public function test_unresolvable_storage_path_fails(): void - { - $this->app->useStoragePath('/laranail-nonexistent-' . uniqid()); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--force' => true]) - ->expectsOutputToContain('storage_path() does not resolve') - ->assertExitCode(1); - } - - // ----------------------------------------------------------------------- - // Cache-flush failure catch - // ----------------------------------------------------------------------- - - public function test_cache_flush_failure_is_caught_and_the_run_completes(): void - { - $spy = $this->makeSpyCommand(); - $spy->throwOnCacheClear = true; - - // cache:clear throws, but the catch logs and the run still reports the - // cache as tidied (exit success). - $this->artisan('laranail::toolkit.tidy-spy', ['action' => 'cache', '--force' => true]) - ->expectsOutputToContain('Cache tidied.') - ->assertExitCode(0); - - $this->assertContains('cache:clear', $spy->calledCommands); - } - - // ----------------------------------------------------------------------- - // db action: declined confirmation + seed - // ----------------------------------------------------------------------- - - public function test_db_action_fails_when_confirm_to_proceed_declines(): void - { - $spy = $this->makeSpyCommand(); - $spy->confirmResult = false; - - $this->artisan('laranail::toolkit.tidy-spy', ['action' => 'db', '--force' => true]) - ->assertExitCode(1); - - // It bailed out before invoking the destructive refresh. - $this->assertNotContains('migrate:fresh', $spy->calledCommands); - } - - public function test_db_action_with_seed_runs_migrate_fresh_then_seed(): void - { - $spy = $this->makeSpyCommand(); - - $this->artisan('laranail::toolkit.tidy-spy', ['action' => 'db', '--force' => true, '--seed' => true]) - ->expectsOutputToContain('Database refreshed.') - ->assertExitCode(0); - - $this->assertSame(['migrate:fresh', 'db:seed'], $spy->calledCommands); - } - - // ----------------------------------------------------------------------- - // sweep(): guard + signal skips (driven directly) - // ----------------------------------------------------------------------- - - public function test_sweep_skips_an_unsafe_relative_root(): void - { - $keep = $this->makeStorageFile('logs/guarded_' . uniqid() . '.log', 'keep'); - - $command = $this->app->make(Tidy::class); - $base = (string) realpath(storage_path()); - - (new ReflectionMethod($command, 'sweep'))->invoke($command, $base, '../escape'); - - $this->assertSame(0, $this->filesProcessed($command)); - $this->assertFileExists($keep); - } - - public function test_sweep_stops_when_a_termination_signal_arrived(): void - { - $keep = $this->makeStorageFile('logs/signalled_' . uniqid() . '.log', 'keep'); - - $command = $this->app->make(Tidy::class); - $command->setLaravel($this->app); - - // Provide an input so the age/size option reads resolve to their defaults. - $input = new ArrayInput([], $command->getDefinition()); - (new ReflectionProperty(Command::class, 'input'))->setValue($command, $input); - - // Flip the running flag off so the per-file loop breaks immediately. - $services = (new ReflectionProperty($command, 'services'))->getValue($command); - $services->signals()->stop(); - - $base = (string) realpath(storage_path()); - (new ReflectionMethod($command, 'sweep'))->invoke($command, $base, 'logs'); - - $this->assertSame(0, $this->filesProcessed($command)); - $this->assertFileExists($keep); - } - - // ----------------------------------------------------------------------- - // sweep(): non-file entries are skipped (via the normal run) - // ----------------------------------------------------------------------- - - public function test_empty_subdirectories_are_skipped_while_files_are_deleted(): void - { - $dir = storage_path('logs/branch_' . uniqid()); - @mkdir($dir . '/emptydir', 0777, true); - $file = $dir . '/old.log'; - file_put_contents($file, 'log'); - $this->created[] = $file; - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($file); - - @rmdir($dir . '/emptydir'); - @rmdir($dir); - } - - private function makeStorageFile(string $relative, string $contents = 'x'): string - { - $path = storage_path($relative); - @mkdir(dirname($path), 0777, true); - file_put_contents($path, $contents); - $this->created[] = $path; - - return $path; - } - - private function makeSpyCommand(): SpyTidy - { - $spy = new SpyTidy( - $this->app->make(FileServiceInterface::class), - $this->app->make(CacheRepositoryInterface::class), - $this->app->make(LoggerServiceInterface::class), - ); - $spy->setLaravel($this->app); - $this->app->make(Kernel::class)->registerCommand($spy); - - return $spy; - } - - private function filesProcessed(Tidy $command): int - { - $value = (new ReflectionProperty(Tidy::class, 'filesProcessed'))->getValue($command); - - return is_int($value) ? $value : -1; - } -} - -/** - * A Tidy subclass that records dispatched sub-commands (without running them), - * can force a cache:clear failure, and can override the production-safety - * confirmation — exercising the db/cache branches deterministically. - */ -class SpyTidy extends Tidy -{ - /** @var list */ - public array $calledCommands = []; - - public bool $throwOnCacheClear = false; - - public ?bool $confirmResult = null; - - protected $signature = 'laranail::toolkit.tidy-spy - {action? : What to tidy} - {--days= : Only delete files older than this many days} - {--size= : Only delete files larger than this many MB} - {--seed : also run db:seed} - {--optimize : also run optimize:clear} - {--dry-run : Show what would be removed} - {--force : Skip confirmation prompts}'; - - /** - * @param array $arguments - */ - public function call($command, array $arguments = []) - { - $name = $command instanceof \Symfony\Component\Console\Command\Command - ? (string) $command->getName() - : $command; - - $this->calledCommands[] = $name; - - if ($this->throwOnCacheClear && $name === 'cache:clear') { - throw new RuntimeException('cache flush failed'); - } - - return self::SUCCESS; - } - - public function confirmToProceed($warning = 'Application In Production', $callback = null) - { - if ($this->confirmResult !== null) { - return $this->confirmResult; - } - - return parent::confirmToProceed($warning, $callback); - } -} diff --git a/tests/Unit/Console/TidyCommandTest.php b/tests/Unit/Console/TidyCommandTest.php deleted file mode 100644 index ae1441e..0000000 --- a/tests/Unit/Console/TidyCommandTest.php +++ /dev/null @@ -1,317 +0,0 @@ -created as $path) { - if (is_file($path) || is_link($path)) { - @unlink($path); - } - } - - parent::tearDown(); - } - - // ----------------------------------------------------------------------- - // Registration - // ----------------------------------------------------------------------- - - public function test_command_is_registered_under_namespaced_name_and_alias(): void - { - $all = collect($this->app[Kernel::class]->all()); - - $this->assertTrue($all->has('laranail::toolkit.tidy')); - $this->assertTrue($all->has('laranail::toolkit.tidy')); - } - - public function test_invalid_action_fails(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'nope']) - ->expectsOutputToContain('Invalid action') - ->assertExitCode(1); - } - - // ----------------------------------------------------------------------- - // logs / temp / storage - // ----------------------------------------------------------------------- - - public function test_logs_action_deletes_log_files_in_storage(): void - { - // A uniquely-named log file so the console lifecycle logger (which may - // (re)write laravel.log during the run) cannot mask the deletion. - $log = $this->makeStorageFile('logs/tidy_' . uniqid() . '.log', 'old log'); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($log); - } - - public function test_gitignore_is_never_deleted(): void - { - $keep = $this->makeStorageFile('logs/.gitignore', '*'); - $this->makeStorageFile('logs/app.log', 'log'); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileExists($keep); - } - - public function test_age_filter_keeps_recent_files(): void - { - $recent = $this->makeStorageFile('logs/recent.log', 'recent'); - - // Keep only files older than 30 days — the just-created file survives. - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--days' => '30', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileExists($recent); - } - - public function test_temp_action_deletes_temp_files(): void - { - $tmp = $this->makeStorageFile('app/temp/old.tmp', 'tmp'); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'temp', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($tmp); - } - - public function test_storage_action_deletes_uploaded_files_when_scoped(): void - { - $upload = $this->makeStorageFile('app/uploads/photo.bin', 'bytes'); - touch($upload, time() - (60 * 86400)); - - // Scoped by age. This assertion used to pass with no filter at all, - // which is the defect: --force plus a housekeeping verb emptied - // app/public, app/uploads and app/exports in one non-interactive run. - $this->artisan('laranail::toolkit.tidy', ['action' => 'storage', '--days' => '30', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($upload); - } - - public function test_size_filter_only_deletes_large_files(): void - { - $small = $this->makeStorageFile('logs/small.log', 'x'); // 1 byte - $big = $this->makeStorageFile('logs/big.log', str_repeat('y', 3 * 1024 * 1024)); // 3 MB - - // Delete only files >= 2 MB. - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--size' => '2', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileExists($small); - $this->assertFileDoesNotExist($big); - } - - public function test_dry_run_deletes_nothing_and_reports(): void - { - $log = $this->makeStorageFile('logs/dry.log', 'log'); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--dry-run' => true]) - ->expectsOutputToContain('[dry-run]') - ->assertExitCode(0); - - $this->assertFileExists($log); - } - - // ----------------------------------------------------------------------- - // db gating - // ----------------------------------------------------------------------- - - public function test_db_action_requires_force(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'db']) - ->expectsOutputToContain('re-run with --force') - ->assertExitCode(1); - } - - public function test_db_action_dry_run_is_noop(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'db', '--dry-run' => true]) - ->expectsOutputToContain('[dry-run]') - ->assertExitCode(0); - } - - public function test_db_action_with_force_runs_migrate_fresh(): void - { - // The in-memory sqlite test DB makes migrate:fresh safe to actually run. - // --force satisfies the gate and confirmToProceed() is a no-op outside - // production, so the real refresh path executes and reports success. - $this->artisan('laranail::toolkit.tidy', ['action' => 'db', '--force' => true]) - ->expectsOutputToContain('Database refreshed') - ->assertExitCode(0); - - // migrate:fresh re-runs the package's own filesystem migrations, so its - // tables exist after the refresh. - $this->assertTrue(Schema::hasTable('access_logs')); - } - - public function test_all_excludes_db_action(): void - { - // `all` must not drop tables: the users table (from loadLaravelMigrations) - // still exists afterwards. - $this->artisan('laranail::toolkit.tidy', ['action' => 'all', '--force' => true]) - ->expectsOutputToContain('db excluded') - ->assertExitCode(0); - - $this->assertTrue(Schema::hasTable('users')); - } - - // ----------------------------------------------------------------------- - // cache - // ----------------------------------------------------------------------- - - public function test_cache_action_succeeds(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'cache', '--force' => true]) - ->assertExitCode(0); - } - - public function test_cache_dry_run_previews(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'cache', '--dry-run' => true]) - ->expectsOutputToContain('[dry-run]') - ->assertExitCode(0); - } - - public function test_cache_optimize_flag_also_clears_optimized_caches(): void - { - // --optimize runs optimize:clear alongside the cache flush. - $this->artisan('laranail::toolkit.tidy', ['action' => 'cache', '--optimize' => true, '--force' => true]) - ->assertExitCode(0); - } - - public function test_cache_dry_run_optimize_is_previewed_in_message(): void - { - $this->artisan('laranail::toolkit.tidy', ['action' => 'cache', '--optimize' => true, '--dry-run' => true]) - ->expectsOutputToContain('optimize:clear') - ->assertExitCode(0); - } - - // ----------------------------------------------------------------------- - // non-interactive confirmation (declines without --force) - // ----------------------------------------------------------------------- - - public function test_logs_without_force_in_non_interactive_mode_deletes_nothing(): void - { - // --no-interaction (no --force): the interaction service is put in - // non-interactive mode and confirmAction() returns the default (false), - // so the destructive sweep is declined and the file survives — a piped/CI - // run never silently deletes. - $keep = $this->makeStorageFile('logs/noforce_' . uniqid() . '.log', 'keep'); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--no-interaction' => true]) - ->assertExitCode(0); - - $this->assertFileExists($keep); - } - - // ----------------------------------------------------------------------- - // combined age + size filter - // ----------------------------------------------------------------------- - - public function test_age_filter_deletes_files_older_than_threshold(): void - { - $old = $this->makeStorageFile('logs/aged_' . uniqid() . '.log', 'old'); - // Backdate well beyond the threshold so the age branch deletes it. - touch($old, time() - (40 * 86400)); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--days' => '30', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($old); - } - - // ----------------------------------------------------------------------- - // Security - // ----------------------------------------------------------------------- - - #[Group('security')] - public function test_symlink_escaping_storage_is_not_deleted(): void - { - $outsideDir = sys_get_temp_dir() . '/laranail-tidy-out-' . uniqid(); - @mkdir($outsideDir, 0777, true); - $secret = $outsideDir . '/secret.log'; - file_put_contents($secret, 'do-not-delete'); - - // A symlink inside storage/logs pointing at the external secret. - $escape = storage_path('logs/escape.log'); - @mkdir(dirname($escape), 0777, true); - @symlink($secret, $escape); - $this->created[] = $escape; - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--force' => true]) - ->assertExitCode(0); - - // The external target survives; only in-tree files are removed. - $this->assertFileExists($secret); - $this->assertSame('do-not-delete', file_get_contents($secret)); - - @unlink($escape); - @unlink($secret); - @rmdir($outsideDir); - } - - #[Group('security')] - public function test_deletion_is_confined_to_storage_path_in_source(): void - { - $code = $this->executableSource(dirname(__DIR__, 3) . '/src/Commands/Tidy.php'); - - // Roots are storage-relative; deletion uses realpath containment and the - // FilePathGuard; the executable code never sweeps sys_get_temp_dir(). - $this->assertStringContainsString('realpath(storage_path())', $code); - $this->assertStringContainsString('$this->isWithin($real, $root)', $code); - $this->assertStringContainsString('use FilePathGuard;', $code); - $this->assertStringNotContainsString('sys_get_temp_dir()', $code); - } - - private function makeStorageFile(string $relative, string $contents = 'x'): string - { - $path = storage_path($relative); - @mkdir(dirname($path), 0777, true); - file_put_contents($path, $contents); - $this->created[] = $path; - - return $path; - } - - /** - * Return a file's source with comments/docblocks stripped, so assertions - * reflect executable code (not prose that mentions a pattern). - */ - private function executableSource(string $path): string - { - $code = ''; - - foreach (token_get_all((string) file_get_contents($path)) as $token) { - if (is_array($token)) { - if (in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true)) { - continue; - } - - $code .= $token[1]; - - continue; - } - - $code .= $token; - } - - return $code; - } -} diff --git a/tests/Unit/Console/TidyUserFileGuardTest.php b/tests/Unit/Console/TidyUserFileGuardTest.php deleted file mode 100644 index 7dae4be..0000000 --- a/tests/Unit/Console/TidyUserFileGuardTest.php +++ /dev/null @@ -1,223 +0,0 @@ - */ - private array $created = []; - - private ?string $originalEnv = null; - - protected function tearDown(): void - { - foreach ($this->created as $path) { - if (is_file($path)) { - @unlink($path); - } - } - - // Restore before parent::tearDown(): Testbench rolls migrations back - // there, and `migrate:rollback` runs confirmToProceed(), which prompts - // in production against a mocked output that expects no questions. - if ($this->originalEnv !== null) { - $this->app['env'] = $this->originalEnv; - } - - parent::tearDown(); - } - - #[Test] - public function an_unfiltered_storage_sweep_is_refused_even_with_force(): void - { - $upload = $this->upload(); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'storage', '--force' => true]) - ->expectsOutputToContain('Refusing to sweep') - ->assertExitCode(1); - - $this->assertFileExists($upload); - } - - #[Test] - public function the_refusal_names_the_roots_it_declined(): void - { - $this->upload(); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'storage', '--force' => true]) - ->expectsOutputToContain('storage/app/public') - ->assertExitCode(1); - } - - #[Test] - public function an_age_filter_makes_the_sweep_intentional(): void - { - $stale = $this->upload(); - touch($stale, time() - (60 * 86400)); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'storage', '--days' => '30', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($stale); - } - - #[Test] - public function a_size_filter_makes_the_sweep_intentional(): void - { - $big = $this->upload('app/exports/report.csv'); - file_put_contents($big, str_repeat('y', 3 * 1024 * 1024)); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'storage', '--size' => '2', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($big); - } - - #[Test] - public function unfiltered_is_the_explicit_way_through(): void - { - // Deleting everything remains reachable — it just cannot be reached by - // a flag anyone types for another reason. - $upload = $this->upload(); - - $this->artisan('laranail::toolkit.tidy', [ - 'action' => 'storage', - '--unfiltered' => true, - '--force' => true, - ])->assertExitCode(0); - - $this->assertFileDoesNotExist($upload); - } - - #[Test] - public function unfiltered_is_refused_outright_in_production(): void - { - // No override, deliberately. --force bypasses Laravel's own - // confirmToProceed() by design, so a prompt would not be a gate here. - $this->pretendProduction(); - - $upload = $this->upload(); - - $this->artisan('laranail::toolkit.tidy', [ - 'action' => 'storage', - '--unfiltered' => true, - '--force' => true, - ]) - ->expectsOutputToContain('in production') - ->assertExitCode(1); - - $this->assertFileExists($upload); - } - - #[Test] - public function a_scoped_sweep_still_runs_in_production(): void - { - $this->pretendProduction(); - - $stale = $this->upload(); - touch($stale, time() - (60 * 86400)); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'storage', '--days' => '30', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($stale); - } - - #[Test] - public function tidy_all_skips_storage_rather_than_failing(): void - { - // `all` exists for logs and temp. Failing the whole run over storage - // would make the useful part unreachable, so it skips — and says so, - // because an omission this consequential should not have to be - // inferred from a file count. - $upload = $this->upload(); - $log = storage_path('logs/tidy-all-guard.log'); - @mkdir(dirname($log), 0777, true); - file_put_contents($log, 'noise'); - $this->created[] = $log; - - $this->artisan('laranail::toolkit.tidy', ['action' => 'all', '--force' => true]) - ->expectsOutputToContain('storage skipped') - ->assertExitCode(0); - - $this->assertFileExists($upload, 'tidy all --force deleted a user upload.'); - $this->assertFileDoesNotExist($log, 'tidy all stopped sweeping logs.'); - } - - #[Test] - public function tidy_all_sweeps_storage_when_scoped(): void - { - $stale = $this->upload(); - touch($stale, time() - (60 * 86400)); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'all', '--days' => '30', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($stale); - } - - #[Test] - public function logs_and_temp_are_unaffected_by_the_guard(): void - { - // They hold regenerable data, which is what makes an unfiltered sweep - // the whole point of the command there. - $log = storage_path('logs/regenerable.log'); - @mkdir(dirname($log), 0777, true); - file_put_contents($log, 'noise'); - $this->created[] = $log; - - $this->artisan('laranail::toolkit.tidy', ['action' => 'logs', '--force' => true]) - ->assertExitCode(0); - - $this->assertFileDoesNotExist($log); - } - - #[Test] - public function a_dry_run_is_refused_too_rather_than_previewing_the_lot(): void - { - // A preview is harmless, but it would print every file in app/public as - // "would delete" — which reads as a plan the next --force will carry - // out, and that plan is the bug. - $upload = $this->upload(); - - $this->artisan('laranail::toolkit.tidy', ['action' => 'storage', '--dry-run' => true]) - ->expectsOutputToContain('Refusing to sweep') - ->assertExitCode(1); - - $this->assertFileExists($upload); - } - - private function pretendProduction(): void - { - $this->originalEnv = (string) $this->app['env']; - $this->app['env'] = 'production'; - } - - private function upload(string $relative = 'app/public/invoice.pdf'): string - { - $path = storage_path($relative); - @mkdir(dirname($path), 0777, true); - file_put_contents($path, 'a user uploaded this'); - $this->created[] = $path; - - return $path; - } -}