diff --git a/composer.json b/composer.json index 71336ed..39f3081 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,8 @@ "php": "^8.1", "illuminate/contracts": "^10.0|^11.0|^12.0", "spatie/laravel-package-tools": "^1.16.4", - "symfony/finder": "^6.2|^7.0" + "symfony/finder": "^6.2|^7.0", + "ext-zip": "*" }, "require-dev": { "guzzlehttp/guzzle": "^7.0", diff --git a/config/nativephp-internal.php b/config/nativephp-internal.php index 4210df9..368b5b8 100644 --- a/config/nativephp-internal.php +++ b/config/nativephp-internal.php @@ -29,4 +29,10 @@ * The URL to the NativePHP API. */ 'api_url' => env('NATIVEPHP_API_URL', 'http://localhost:4000/api/'), + + 'zephpyr' => [ + 'host' => env('ZEPHPYR_HOST', 'https://zephpyr.com'), + 'token' => env('ZEPHPYR_TOKEN'), + 'key' => env('ZEPHPYR_KEY'), + ], ]; diff --git a/config/nativephp.php b/config/nativephp.php index 6e4edcf..bcb2e42 100644 --- a/config/nativephp.php +++ b/config/nativephp.php @@ -47,6 +47,7 @@ 'AWS_*', 'GITHUB_*', 'DO_SPACES_*', + 'ZEPHPYR_*', '*_SECRET', 'NATIVEPHP_UPDATER_PATH', 'NATIVEPHP_APPLE_ID', @@ -57,9 +58,11 @@ /** * A list of files and folders that should be removed from the * final app before it is bundled for production. - * You may use glob / wildcard patterns here. + * You may use glob wildcard patterns here. */ 'cleanup_exclude_files' => [ + 'build', + 'temp', 'content', 'node_modules', '*/tests', @@ -136,4 +139,9 @@ 'postbuild' => [ // 'rm -rf public/build', ], + + /** + * Custom PHP binary path. + */ + 'binary_path' => env('NATIVEPHP_BINARY_PATH', null), ]; diff --git a/src/Commands/BundleCommand.php b/src/Commands/BundleCommand.php new file mode 100644 index 0000000..8ace10c --- /dev/null +++ b/src/Commands/BundleCommand.php @@ -0,0 +1,299 @@ +checkForZephpyrKey()) { + return static::FAILURE; + } + + // Check for ZEPHPYR_TOKEN + if (! $this->checkForZephpyrToken()) { + return static::FAILURE; + } + + // Check if the token is valid + if (! $this->checkAuthenticated()) { + $this->error('Invalid API token: check your ZEPHPYR_TOKEN on '.$this->baseUrl().'user/api-tokens'); + + return static::FAILURE; + } + + // Download the latest bundle if requested + if ($this->option('fetch')) { + if (! $this->fetchLatestBundle()) { + + return static::FAILURE; + } + + $this->info('Latest bundle downloaded.'); + + return static::SUCCESS; + } + + // Check composer.json for symlinked or private packages + if (! $this->checkComposerJson()) { + return static::FAILURE; + } + + // Package the app up into a zip + if (! $this->zipApplication()) { + $this->error("Failed to create zip archive at {$this->zipPath}."); + + return static::FAILURE; + } + + // Send the zip file + $result = $this->sendToZephpyr(); + $this->handleApiErrors($result); + + // Success + $this->info('Successfully uploaded to Zephpyr.'); + $this->line('Use native:bundle --fetch to retrieve the latest bundle.'); + + // Clean up temp files + $this->cleanUp(); + + return static::SUCCESS; + } + + private function zipApplication(): bool + { + $this->zipName = 'app_'.str()->random(8).'.zip'; + $this->zipPath = base_path('temp/'.$this->zipName); + + // Create zip path + if (! @mkdir(dirname($this->zipPath), recursive: true) && ! is_dir(dirname($this->zipPath))) { + return false; + } + + $zip = new ZipArchive; + + if ($zip->open($this->zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + return false; + } + + $this->prepareNativeEnv(); + + $this->addFilesToZip($zip); + + $zip->close(); + + $this->restoreWebEnv(); + + return true; + } + + private function checkComposerJson(): bool + { + $composerJson = json_decode(file_get_contents(base_path('composer.json')), true); + + // Fail if there is symlinked packages + foreach ($composerJson['repositories'] ?? [] as $repository) { + + $symlinked = $repository['options']['symlink'] ?? true; + if ($repository['type'] === 'path' && $symlinked) { + $this->error('Symlinked packages are not supported. Please remove them from your composer.json.'); + + return false; + } + // Work with private packages but will not in the future + // elseif ($repository['type'] === 'composer') { + // if (! $this->checkComposerPackageAuth($repository['url'])) { + // $this->error('Cannot authenticate with '.$repository['url'].'.'); + // $this->error('Go to '.$this->baseUrl().' and add your composer package credentials.'); + // + // return false; + // } + // } + } + + return true; + } + + // private function checkComposerPackageAuth(string $repositoryUrl): bool + // { + // $host = parse_url($repositoryUrl, PHP_URL_HOST); + // $this->line('Checking '.$host.' authentication…'); + // + // return Http::acceptJson() + // ->withToken(config('nativephp-internal.zephpyr.token')) + // ->get($this->baseUrl().'api/v1/project/'.$this->key.'/composer/auth/'.$host) + // ->successful(); + // } + + private function addFilesToZip(ZipArchive $zip): void + { + $this->line('Creating zip archive…'); + + $app = (new Finder)->files() + ->followLinks() + ->ignoreVCSIgnored(true) + ->in(base_path()) + ->exclude([ + 'vendor', // We add this later + 'node_modules', // We add this later + 'dist', // Compiled nativephp assets + 'build', // Compiled box assets + 'temp', // Temp files + 'tests', // Tests + ...config('nativephp.cleanup_exclude_files', []), // User defined + ]); + + $this->finderToZip($app, $zip); + + // Add .env file manually because Finder ignores hidden files + $zip->addFile(base_path('.env'), '.env'); + + // Add auth.json file to support private packages + // WARNING: Only for testing purposes, don't uncomment this + // $zip->addFile(base_path('auth.json'), 'auth.json'); + + // Custom binaries + $binaryPath = Str::replaceStart(base_path('vendor'), '', config('nativephp.binary_path')); + + // Add composer dependencies without unnecessary files + $vendor = (new Finder)->files() + ->exclude(array_filter([ + 'nativephp/php-bin', + 'nativephp/electron/resources/js', + 'nativephp/*/vendor', + $binaryPath, + ])) + ->in(base_path('vendor')); + + $this->finderToZip($vendor, $zip, 'vendor'); + + // Add javascript dependencies + if (file_exists(base_path('node_modules'))) { + $nodeModules = (new Finder)->files() + ->in(base_path('node_modules')); + + $this->finderToZip($nodeModules, $zip, 'node_modules'); + } + } + + private function finderToZip(Finder $finder, ZipArchive $zip, ?string $path = null): void + { + foreach ($finder as $file) { + if ($file->getRealPath() === false) { + continue; + } + + $zip->addFile($file->getRealPath(), str($path)->finish(DIRECTORY_SEPARATOR).$file->getRelativePathname()); + } + } + + private function sendToZephpyr() + { + $this->line('Uploading zip to Zephpyr…'); + + return Http::acceptJson() + ->timeout(300) // 5 minutes + ->withoutRedirecting() // Upload won't work if we follow redirects (it transform POST to GET) + ->withToken(config('nativephp-internal.zephpyr.token')) + ->attach('archive', fopen($this->zipPath, 'r'), $this->zipName) + ->post($this->baseUrl().'api/v1/project/'.$this->key.'/build/'); + } + + private function fetchLatestBundle(): bool + { + $this->line('Fetching latest bundle…'); + + $response = Http::acceptJson() + ->withToken(config('nativephp-internal.zephpyr.token')) + ->get($this->baseUrl().'api/v1/project/'.$this->key.'/build/download'); + + if ($response->failed()) { + + if ($response->status() === 404) { + $this->error('Project or bundle not found.'); + } elseif ($response->status() === 500) { + $this->error('Build failed. Please try again later.'); + } elseif ($response->status() === 503) { + $this->warn('Bundle not ready. Please try again in '.now()->addSeconds(intval($response->header('Retry-After')))->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE).'.'); + } else { + $this->handleApiErrors($response); + } + + return false; + } + + // Save the bundle + @mkdir(base_path('build'), recursive: true); + file_put_contents(base_path('build/__nativephp_app_bundle'), $response->body()); + + return true; + } + + protected function exitWithMessage(string $message): void + { + $this->error($message); + $this->cleanUp(); + + exit(static::FAILURE); + } + + private function handleApiErrors(Response $result): void + { + if ($result->status() === 413) { + $fileSize = Number::fileSize(filesize($this->zipPath)); + $this->exitWithMessage('File is too large to upload ('.$fileSize.'). Please contact support.'); + } elseif ($result->status() === 422) { + $this->error('Request refused:'.$result->json('message')); + } elseif ($result->status() === 429) { + $this->exitWithMessage('Too many requests. Please try again in '.now()->addSeconds(intval($result->header('Retry-After')))->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE).'.'); + } elseif ($result->failed()) { + $this->exitWithMessage("Request failed. Error code: {$result->status()}"); + } + } + + protected function cleanUp(): void + { + if ($this->option('without-cleanup')) { + return; + } + + $previousBuilds = glob(base_path('temp/app_*.zip')); + $failedZips = glob(base_path('temp/app_*.part')); + + $deleteFiles = array_merge($previousBuilds, $failedZips); + + if (empty($deleteFiles)) { + return; + } + + $this->line('Cleaning up…'); + + foreach ($deleteFiles as $file) { + @unlink($file); + } + } +} diff --git a/src/Commands/Traits/CleansEnvFile.php b/src/Commands/Traits/CleansEnvFile.php new file mode 100644 index 0000000..4c0a3ea --- /dev/null +++ b/src/Commands/Traits/CleansEnvFile.php @@ -0,0 +1,46 @@ +line('Preparing production .env file…'); + + $envFile = app()->environmentFilePath(); + + if (! file_exists($backup = $this->getBackupEnvFilePath())) { + copy($envFile, $backup); + } + + $this->cleanEnvFile($envFile); + } + + protected function cleanEnvFile(string $path): void + { + $cleanUpKeys = config('nativephp.cleanup_env_keys', []); + + $contents = collect(file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)) + ->filter(function (string $line) use ($cleanUpKeys) { + $key = str($line)->before('='); + + return ! $key->is($cleanUpKeys) + && ! $key->startsWith('#'); + }) + ->join("\n"); + + file_put_contents($path, $contents); + } + + protected function restoreWebEnv(): void + { + copy($this->getBackupEnvFilePath(), app()->environmentFilePath()); + unlink($this->getBackupEnvFilePath()); + } + + protected function getBackupEnvFilePath(): string + { + return base_path('.env.backup'); + } +} diff --git a/src/Commands/Traits/HandleApiRequests.php b/src/Commands/Traits/HandleApiRequests.php new file mode 100644 index 0000000..ba6fe20 --- /dev/null +++ b/src/Commands/Traits/HandleApiRequests.php @@ -0,0 +1,62 @@ +finish('/'); + } + + private function checkAuthenticated() + { + $this->line('Checking authentication…'); + + return Http::acceptJson() + ->withToken(config('nativephp-internal.zephpyr.token')) + ->get($this->baseUrl().'api/v1/user')->successful(); + } + + private function checkForZephpyrKey() + { + $this->key = config('nativephp-internal.zephpyr.key'); + + if (! $this->key) { + $this->line(''); + $this->warn('No ZEPHPYR_KEY found. Cannot bundle!'); + $this->line(''); + $this->line('Add this app\'s ZEPHPYR_KEY to its .env file:'); + $this->line(base_path('.env')); + $this->line(''); + $this->info('Not set up with Zephpyr yet? Secure your NativePHP app builds and more!'); + $this->info('Check out '.$this->baseUrl().''); + $this->line(''); + + return false; + } + + return true; + } + + private function checkForZephpyrToken() + { + if (! config('nativephp-internal.zephpyr.token')) { + $this->line(''); + $this->warn('No ZEPHPYR_TOKEN found. Cannot bundle!'); + $this->line(''); + $this->line('Add your api ZEPHPYR_TOKEN to its .env file:'); + $this->line(base_path('.env')); + $this->line(''); + $this->info('Not set up with Zephpyr yet? Secure your NativePHP app builds and more!'); + $this->info('Check out '.$this->baseUrl().''); + $this->line(''); + + return false; + } + + return true; + } +} diff --git a/src/NativeServiceProvider.php b/src/NativeServiceProvider.php index b5b3225..784f2a7 100644 --- a/src/NativeServiceProvider.php +++ b/src/NativeServiceProvider.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Native\Laravel\ChildProcess as ChildProcessImplementation; +use Native\Laravel\Commands\BundleCommand; use Native\Laravel\Commands\FreshCommand; use Native\Laravel\Commands\LoadPHPConfigurationCommand; use Native\Laravel\Commands\LoadStartupConfigurationCommand; @@ -25,6 +26,7 @@ use Native\Laravel\Logging\LogWatcher; use Native\Laravel\PowerMonitor as PowerMonitorImplementation; use Native\Laravel\Windows\WindowManager as WindowManagerImplementation; +use Phar; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -38,6 +40,7 @@ public function configurePackage(Package $package): void MigrateCommand::class, FreshCommand::class, SeedDatabaseCommand::class, + BundleCommand::class, ]) ->hasConfigFile() ->hasRoute('api') @@ -148,7 +151,7 @@ public function rewriteDatabase() { $databasePath = config('nativephp-internal.database_path'); - if (config('app.debug')) { + if (config('app.debug') && ! Phar::running()) { $databasePath = database_path('nativephp.sqlite'); if (! file_exists($databasePath)) { diff --git a/tests/Pest.php b/tests/Pest.php index defff2e..856437c 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,4 +2,81 @@ use Native\Laravel\Tests\TestCase; +/* +|-------------------------------------------------------------------------- +| Test Case +|-------------------------------------------------------------------------- +| +| The closure you provide to your test functions is always bound to a specific PHPUnit test +| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may +| need to change it using the "uses()" function to bind a different classes or traits. +| +*/ + uses(TestCase::class)->in(__DIR__); + +/* +|-------------------------------------------------------------------------- +| Expectations +|-------------------------------------------------------------------------- +| +| When you're writing tests, you often need to check that values meet certain conditions. The +| "expect()" function gives you access to a set of "expectations" methods that you can use +| to assert different things. Of course, you may extend the Expectation API at any time. +| +*/ + +expect()->extend('toBeInZip', function (string $zipFile) { + $zip = new ZipArchive; + $zip->open($zipFile); + + $found = $zip->locateName($this->value) !== false; + + $zip->close(); + + return $this->toBeTrue($found); +}); + +/* +|-------------------------------------------------------------------------- +| Functions +|-------------------------------------------------------------------------- +| +| While Pest is very powerful out-of-the-box, you may have some testing code specific to your +| project that you don't want to repeat in every file. Here you can also expose helpers as +| global functions to help you to reduce the number of lines of code in your test files. +| +*/ + +function rmdir_recursive($dir): void +{ + foreach (scandir($dir) as $file) { + if ($file === '.' || $file === '..') { + continue; + } + if (is_dir("$dir/$file")) { + rmdir_recursive("$dir/$file"); + } else { + unlink("$dir/$file"); + } + } + rmdir($dir); +} + +function findLatestZipPath(): ?string +{ + $latestZip = null; + $latestTime = 0; + + dump(glob(base_path('temp/app_*.zip'))); + foreach (glob(base_path('temp/app_*.zip')) as $zip) { + $time = filemtime($zip); + + if ($time > $latestTime) { + $latestTime = $time; + $latestZip = $zip; + } + } + + return $latestZip; +}