diff --git a/app/Console/Commands/UpdateCommand.php b/app/Console/Commands/UpdateCommand.php index 7d6a57a5..dd15ec35 100644 --- a/app/Console/Commands/UpdateCommand.php +++ b/app/Console/Commands/UpdateCommand.php @@ -79,10 +79,8 @@ class UpdateCommand extends Command return; } - if (isset($this->response->deleted_files) && ! empty($this->response->deleted_files)) { - if (! $this->deleteFiles($this->response->deleted_files)) { - return; - } + if (! $this->cleanFiles()) { + return; } if (! $this->migrateUpdate()) { @@ -194,12 +192,19 @@ class UpdateCommand extends Command return true; } - public function deleteFiles($files) + public function cleanFiles() { - $this->info('Deleting unused old files...'); - try { - Updater::deleteFiles($files); + // When the release ships a manifest.json (e.g. v3), remove every stale file + // not listed in it. Otherwise fall back to the explicit deleted_files list + // for same-line updates. + if (File::exists(base_path('manifest.json'))) { + $this->info('Cleaning stale files...'); + Updater::cleanStaleFiles(); + } elseif (isset($this->response->deleted_files) && ! empty($this->response->deleted_files)) { + $this->info('Deleting unused old files...'); + Updater::deleteFiles($this->response->deleted_files); + } } catch (\Exception $e) { $this->error($e->getMessage()); diff --git a/app/Http/Controllers/V1/Admin/Update/DeleteFilesController.php b/app/Http/Controllers/V1/Admin/Update/DeleteFilesController.php index bddd76c1..360183c8 100644 --- a/app/Http/Controllers/V1/Admin/Update/DeleteFilesController.php +++ b/app/Http/Controllers/V1/Admin/Update/DeleteFilesController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Space\Updater; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Facades\File; class DeleteFilesController extends Controller { @@ -23,12 +24,17 @@ class DeleteFilesController extends Controller ], 401); } - if (isset($request->deleted_files) && ! empty($request->deleted_files)) { + // Backward compatibility: use the explicit deleted_files list only when the + // release ships no manifest.json (same-line v2 updates). When a manifest is + // present (e.g. the v3 release), clean every stale file not in the manifest. + if (! File::exists(base_path('manifest.json')) + && isset($request->deleted_files) + && ! empty($request->deleted_files)) { Updater::deleteFiles($request->deleted_files); + + return response()->json(['success' => true, 'cleaned' => 0]); } - return response()->json([ - 'success' => true, - ]); + return response()->json(Updater::cleanStaleFiles()); } } diff --git a/app/Space/Updater.php b/app/Space/Updater.php index 28f5774b..130a83ff 100644 --- a/app/Space/Updater.php +++ b/app/Space/Updater.php @@ -113,12 +113,119 @@ class Updater return false; } + // Clear stale compiled caches from the previous version so the newly copied + // release boots cleanly. This runs as the currently-installed code — the last + // reliable point before the new release boots — and is critical for major + // upgrades (e.g. v2 -> v3) where cached config/routes/package discovery differ. + static::clearCompiledCaches(); + // Delete temp directory File::deleteDirectory($temp_extract_dir); return true; } + /** + * Remove files left behind by the previous version that are not part of the new + * release. The new release ships a manifest.json listing every file it contains; + * anything under base_path() not in that manifest (and not protected) is deleted. + * No manifest present (same-line update) is a safe no-op. + */ + public static function cleanStaleFiles(?string $basePath = null): array + { + $basePath = $basePath ?? base_path(); + $manifestPath = $basePath.'/manifest.json'; + + if (! File::exists($manifestPath)) { + return ['success' => true, 'cleaned' => 0]; + } + + $manifest = json_decode(File::get($manifestPath), true); + + if (! is_array($manifest)) { + return ['success' => false, 'error' => 'Invalid manifest']; + } + + $manifestLookup = array_flip($manifest); + $protectedPaths = config('invoiceshelf.update_protected_paths', []); + $cleaned = 0; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($basePath, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + $relativePath = substr($file->getPathname(), strlen($basePath) + 1); + + if (static::isProtectedPath($relativePath, $protectedPaths)) { + continue; + } + + if ($file->isFile() && ! isset($manifestLookup[$relativePath])) { + File::delete($file->getPathname()); + $cleaned++; + } + } + + // Second pass: remove now-empty directories. + $dirIterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($basePath, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($dirIterator as $item) { + if (! $item->isDir()) { + continue; + } + + $relativePath = substr($item->getPathname(), strlen($basePath) + 1); + + if (static::isProtectedPath($relativePath, $protectedPaths)) { + continue; + } + + $entries = scandir($item->getPathname()); + + if (count($entries) <= 2) { + @rmdir($item->getPathname()); + } + } + + return ['success' => true, 'cleaned' => $cleaned]; + } + + /** + * Delete compiled config/route/event/package caches and compiled views so the + * freshly copied release re-reads config and re-runs package discovery on boot. + * bootstrap/cache is a protected path for cleanStaleFiles(), so it is cleared here. + */ + public static function clearCompiledCaches(): void + { + foreach (File::glob(base_path('bootstrap/cache/*.php')) as $file) { + File::delete($file); + } + + $compiledViews = storage_path('framework/views'); + + if (File::isDirectory($compiledViews)) { + foreach (File::glob($compiledViews.'/*.php') as $file) { + File::delete($file); + } + } + } + + private static function isProtectedPath(string $relativePath, array $protectedPaths): bool + { + foreach ($protectedPaths as $protected) { + if ($relativePath === $protected || str_starts_with($relativePath, $protected.'/')) { + return true; + } + } + + return false; + } + public static function deleteFiles($json) { $files = json_decode($json); diff --git a/config/invoiceshelf.php b/config/invoiceshelf.php index 0fe83bc2..1b3f1564 100644 --- a/config/invoiceshelf.php +++ b/config/invoiceshelf.php @@ -47,6 +47,23 @@ return [ */ 'base_url' => 'https://invoiceshelf.com', + /* + * Paths protected from cleanup during updates. + * The updater (Updater::cleanStaleFiles) will never delete files under these + * prefixes when reconciling the install against a release manifest.json. + */ + 'update_protected_paths' => [ + '.env', + 'storage', + 'vendor', + 'node_modules', + 'Modules', + 'public/storage', + '.git', + 'bootstrap/cache', + 'manifest.json', + ], + /* * List of languages supported by InvoiceShelf. */ diff --git a/tests/Unit/UpdaterTest.php b/tests/Unit/UpdaterTest.php new file mode 100644 index 00000000..cac51445 --- /dev/null +++ b/tests/Unit/UpdaterTest.php @@ -0,0 +1,97 @@ +sandbox = sys_get_temp_dir().'/updater-test-'.uniqid(); + File::makeDirectory($this->sandbox, 0755, true); +}); + +afterEach(function () { + if (File::isDirectory($this->sandbox)) { + File::deleteDirectory($this->sandbox); + } +}); + +function writeSandboxFile(string $base, string $relative, string $contents = 'x'): void +{ + $path = $base.'/'.$relative; + File::ensureDirectoryExists(dirname($path)); + File::put($path, $contents); +} + +test('removes stale files not present in the manifest', function () { + writeSandboxFile($this->sandbox, 'app/Keep.php'); + writeSandboxFile($this->sandbox, 'app/Stale.php'); + writeSandboxFile($this->sandbox, 'public/old-chunk.js'); + + File::put($this->sandbox.'/manifest.json', json_encode(['app/Keep.php'])); + + $result = Updater::cleanStaleFiles($this->sandbox); + + expect($result['success'])->toBeTrue(); + expect($result['cleaned'])->toBe(2); + expect(File::exists($this->sandbox.'/app/Keep.php'))->toBeTrue(); + expect(File::exists($this->sandbox.'/app/Stale.php'))->toBeFalse(); + expect(File::exists($this->sandbox.'/public/old-chunk.js'))->toBeFalse(); +}); + +test('never deletes protected paths even when absent from the manifest', function () { + writeSandboxFile($this->sandbox, 'storage/logs/laravel.log'); + writeSandboxFile($this->sandbox, '.env', 'APP_KEY=base64:xxx'); + writeSandboxFile($this->sandbox, 'vendor/autoload.php'); + writeSandboxFile($this->sandbox, 'app/Stale.php'); + + // Manifest lists none of the above. + File::put($this->sandbox.'/manifest.json', json_encode(['app/Keep.php'])); + + Updater::cleanStaleFiles($this->sandbox); + + expect(File::exists($this->sandbox.'/storage/logs/laravel.log'))->toBeTrue(); + expect(File::exists($this->sandbox.'/.env'))->toBeTrue(); + expect(File::exists($this->sandbox.'/vendor/autoload.php'))->toBeTrue(); + expect(File::exists($this->sandbox.'/manifest.json'))->toBeTrue(); + // ...but unprotected stale files are still removed. + expect(File::exists($this->sandbox.'/app/Stale.php'))->toBeFalse(); +}); + +test('prunes directories left empty after cleaning', function () { + writeSandboxFile($this->sandbox, 'old-feature/View.php'); + + File::put($this->sandbox.'/manifest.json', json_encode(['app/Keep.php'])); + writeSandboxFile($this->sandbox, 'app/Keep.php'); + + Updater::cleanStaleFiles($this->sandbox); + + expect(File::exists($this->sandbox.'/old-feature/View.php'))->toBeFalse(); + expect(File::isDirectory($this->sandbox.'/old-feature'))->toBeFalse(); +}); + +test('is a safe no-op when no manifest is present', function () { + writeSandboxFile($this->sandbox, 'app/Anything.php'); + + $result = Updater::cleanStaleFiles($this->sandbox); + + expect($result)->toBe(['success' => true, 'cleaned' => 0]); + expect(File::exists($this->sandbox.'/app/Anything.php'))->toBeTrue(); +}); + +test('reports an error for an invalid manifest', function () { + writeSandboxFile($this->sandbox, 'app/Anything.php'); + File::put($this->sandbox.'/manifest.json', 'not-json'); + + $result = Updater::cleanStaleFiles($this->sandbox); + + expect($result['success'])->toBeFalse(); + // Nothing is deleted when the manifest cannot be parsed. + expect(File::exists($this->sandbox.'/app/Anything.php'))->toBeTrue(); +});