diff --git a/config/nativephp-internal.php b/config/nativephp-internal.php index 35a53fd1..cb23ca0f 100644 --- a/config/nativephp-internal.php +++ b/config/nativephp-internal.php @@ -31,12 +31,12 @@ 'api_url' => env('NATIVEPHP_API_URL', 'http://localhost:4000/api/'), /** - * Configuration for the Zephpyr API. + * Configuration for the Bifrost API. */ - 'zephpyr' => [ - 'host' => env('ZEPHPYR_HOST', 'https://zephpyr.com'), - 'token' => env('ZEPHPYR_TOKEN'), - 'key' => env('ZEPHPYR_KEY'), + 'bifrost' => [ + 'host' => env('BIFROST_HOST', 'https://bifrost.nativephp.com'), + 'token' => env('BIFROST_TOKEN'), + 'project' => env('BIFROST_PROJECT'), ], /** diff --git a/config/nativephp.php b/config/nativephp.php index 91938bd9..430f5a60 100644 --- a/config/nativephp.php +++ b/config/nativephp.php @@ -64,7 +64,7 @@ 'GITHUB_*', 'DO_SPACES_*', '*_SECRET', - 'ZEPHPYR_*', + 'BIFROST_*', 'NATIVEPHP_UPDATER_PATH', 'NATIVEPHP_APPLE_ID', 'NATIVEPHP_APPLE_ID_PASS', diff --git a/src/Builder/Builder.php b/src/Builder/Builder.php index 76db30da..b495af67 100644 --- a/src/Builder/Builder.php +++ b/src/Builder/Builder.php @@ -2,37 +2,35 @@ namespace Native\Desktop\Builder; -use Native\Desktop\Builder\Concerns\CleansEnvFile; use Native\Desktop\Builder\Concerns\CopiesBundleToBuildDirectory; use Native\Desktop\Builder\Concerns\CopiesCertificateAuthority; use Native\Desktop\Builder\Concerns\CopiesToBuildDirectory; use Native\Desktop\Builder\Concerns\HasPreAndPostProcessing; use Native\Desktop\Builder\Concerns\LocatesPhpBinary; +use Native\Desktop\Builder\Concerns\ManagesEnvFile; use Native\Desktop\Builder\Concerns\PrunesVendorDirectory; use Symfony\Component\Filesystem\Path; class Builder { - use CleansEnvFile; use CopiesBundleToBuildDirectory; use CopiesCertificateAuthority; use CopiesToBuildDirectory; use HasPreAndPostProcessing; use LocatesPhpBinary; + use ManagesEnvFile; use PrunesVendorDirectory; public function __construct( - private string $buildPath, + private ?string $buildPath = null, private ?string $sourcePath = null, ) { - - $this->sourcePath = $sourcePath - ? $sourcePath - : base_path(); + $this->buildPath = $buildPath ?? base_path('build'); + $this->sourcePath = $sourcePath ?? base_path(); } public static function make( - string $buildPath, + ?string $buildPath = null, ?string $sourcePath = null ) { return new self($buildPath, $sourcePath); diff --git a/src/Builder/Concerns/CleansEnvFile.php b/src/Builder/Concerns/CleansEnvFile.php deleted file mode 100644 index b2a31cf4..00000000 --- a/src/Builder/Concerns/CleansEnvFile.php +++ /dev/null @@ -1,45 +0,0 @@ -overrideKeys, config('nativephp.cleanup_env_keys', [])); - - $envFile = $this->buildPath('app/'.app()->environmentFile()); - - $contents = collect(file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)) - // Remove cleanup keys - ->filter(function (string $line) use ($cleanUpKeys) { - $key = str($line)->before('='); - - return ! $key->is($cleanUpKeys) - && ! $key->startsWith('#'); - }) - // Set defaults (other config overrides are handled in the NativeServiceProvider) - // The Log channel needs to be configured before anything else. - ->push('LOG_CHANNEL=stack') - ->push('LOG_STACK=daily') - ->push('LOG_DAILY_DAYS=3') - ->push('LOG_LEVEL=warning') - ->join("\n"); - - file_put_contents($envFile, $contents); - } -} diff --git a/src/Builder/Concerns/ManagesEnvFile.php b/src/Builder/Concerns/ManagesEnvFile.php new file mode 100644 index 00000000..e1ea62dc --- /dev/null +++ b/src/Builder/Concerns/ManagesEnvFile.php @@ -0,0 +1,115 @@ +getEnvPath(); + $cleanUpKeys = array_merge($this->overrideKeys, config('nativephp.cleanup_env_keys', [])); + + $contents = collect(file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)) + // Remove cleanup keys + ->filter(function (string $line) use ($cleanUpKeys) { + $key = str($line)->before('='); + + return ! $key->is($cleanUpKeys) + && ! $key->startsWith('#'); + }) + // Set defaults (other config overrides are handled in the NativeServiceProvider) + // The Log channel needs to be configured before anything else. + ->push('LOG_CHANNEL=stack') + ->push('LOG_STACK=daily') + ->push('LOG_DAILY_DAYS=3') + ->push('LOG_LEVEL=warning') + ->join("\n"); + + file_put_contents($envFile, $contents); + } + + /** + * Update or add an environment variable + */ + public function updateEnvFile(string $key, string $value, ?string $envPath = null): void + { + $envPath = $envPath ?? $this->getEnvPath(); + $envContent = file_get_contents($envPath); + + $pattern = "/^{$key}=.*$/m"; + + if (preg_match($pattern, $envContent)) { + $envContent = preg_replace($pattern, "{$key}={$value}", $envContent); + } else { + $envContent = rtrim($envContent, "\n")."\n{$key}={$value}\n"; + } + + file_put_contents($envPath, $envContent); + } + + /** + * Remove environment variables + */ + public function removeFromEnvFile(array $keys, ?string $envPath = null): void + { + $envPath = $envPath ?? $this->getEnvPath(); + $envContent = file_get_contents($envPath); + + foreach ($keys as $key) { + $envContent = preg_replace("/^{$key}=.*$/m", '', $envContent); + } + + // Clean up extra newlines + $envContent = preg_replace('/\n\n+/', "\n\n", $envContent); + $envContent = trim($envContent)."\n"; + + file_put_contents($envPath, $envContent); + } + + /** + * Get an environment variable value + */ + public function getEnvValue(string $key, ?string $envPath = null): ?string + { + $envPath = $envPath ?? $this->getEnvPath(); + + if (! file_exists($envPath)) { + return null; + } + + $envContent = file_get_contents($envPath); + + if (preg_match("/^{$key}=(.*)$/m", $envContent, $matches)) { + return trim($matches[1]); + } + + return null; + } + + /** + * Get the appropriate .env file path based on context + */ + private function getEnvPath(): string + { + return $this->buildPath('app/'.app()->environmentFile()); + } +} diff --git a/src/Commands/Bifrost/ClearBundleCommand.php b/src/Commands/Bifrost/ClearBundleCommand.php new file mode 100644 index 00000000..05846532 --- /dev/null +++ b/src/Commands/Bifrost/ClearBundleCommand.php @@ -0,0 +1,68 @@ +warn('No bundle or signature files found to clear.'); + + return static::SUCCESS; + } + + $cleared = []; + $failed = []; + + if ($bundleExists) { + if (unlink($bundlePath)) { + $cleared[] = 'bundle'; + } else { + $failed[] = 'bundle'; + } + } + + if ($signatureExists) { + if (unlink($signaturePath)) { + $cleared[] = 'GPG signature'; + } else { + $failed[] = 'GPG signature'; + } + } + + if (! empty($cleared)) { + $clearedText = implode(' and ', $cleared); + $this->info("Cleared {$clearedText} successfully!"); + $this->line('Note: Building in this state would be unsecure without a valid bundle.'); + } + + if (! empty($failed)) { + $failedText = implode(' and ', $failed); + $this->error("Failed to remove {$failedText}."); + + return static::FAILURE; + } + + return static::SUCCESS; + } +} diff --git a/src/Commands/Bifrost/Concerns/HandlesBifrost.php b/src/Commands/Bifrost/Concerns/HandlesBifrost.php new file mode 100644 index 00000000..f5d7f103 --- /dev/null +++ b/src/Commands/Bifrost/Concerns/HandlesBifrost.php @@ -0,0 +1,123 @@ +finish('/'); + } + + private function checkAuthenticated(): bool + { + intro('Checking authentication…'); + + try { + return Http::acceptJson() + ->withToken(config('nativephp-internal.bifrost.token')) + ->get($this->baseUrl().'api/v1/auth/user')->successful(); + } catch (Exception $e) { + $this->error('Network error: '.$e->getMessage()); + + return false; + } + } + + private function checkForBifrostToken(): bool + { + if (! config('nativephp-internal.bifrost.token')) { + $this->line(''); + $this->warn('No BIFROST_TOKEN found. Please login first.'); + $this->line(''); + $this->line('Run: php artisan bifrost:login'); + $this->line(''); + + return false; + } + + return true; + } + + private function checkForBifrostProject(): bool + { + if (! config('nativephp-internal.bifrost.project')) { + $this->line(''); + $this->warn('No BIFROST_PROJECT found. Please select a project first.'); + $this->line(''); + $this->line('Run: php artisan bifrost:init'); + $this->line(''); + + return false; + } + + return true; + } + + /** + * Validates authentication and returns user data + * + * @throws Exception + */ + private function validateAuthAndGetUser(): array + { + if (! $this->checkForBifrostToken()) { + throw new Exception('No BIFROST_TOKEN found. Please login first.'); + } + + try { + $response = Http::acceptJson() + ->withToken(config('nativephp-internal.bifrost.token')) + ->get($this->baseUrl().'api/v1/auth/user'); + + if ($response->failed()) { + throw new Exception('Invalid API token. Please login again.'); + } + + $data = $response->json(); + + if (! isset($data['data'])) { + throw new Exception('Invalid API response format.'); + } + + return $data['data']; + } catch (Exception $e) { + throw new Exception('Authentication failed: '.$e->getMessage()); + } + } + + private function getCurrentTeamSlug(): ?string + { + try { + $user = $this->validateAuthAndGetUser(); + + return $user['current_team']['slug'] ?? null; + } catch (Exception $e) { + return null; + } + } + + protected function makeApiRequest(string $method, string $endpoint, array $data = []): Response + { + try { + $request = Http::acceptJson() + ->withToken(config('nativephp-internal.bifrost.token')); + + return match (strtoupper($method)) { + 'GET' => $request->get($this->baseUrl().$endpoint), + 'POST' => $request->post($this->baseUrl().$endpoint, $data), + 'PUT' => $request->put($this->baseUrl().$endpoint, $data), + 'DELETE' => $request->delete($this->baseUrl().$endpoint), + default => throw new Exception("Unsupported HTTP method: {$method}") + }; + } catch (Exception $e) { + throw new Exception("API request failed: {$e->getMessage()}"); + } + } +} diff --git a/src/Commands/Bifrost/DownloadBundleCommand.php b/src/Commands/Bifrost/DownloadBundleCommand.php new file mode 100644 index 00000000..33c950dc --- /dev/null +++ b/src/Commands/Bifrost/DownloadBundleCommand.php @@ -0,0 +1,240 @@ +validateAuthAndGetUser(); + } catch (Exception $e) { + $this->error($e->getMessage()); + $this->line('Run: php artisan bifrost:login'); + + return static::FAILURE; + } + + if (! $this->checkForBifrostProject()) { + return static::FAILURE; + } + + intro('Fetching latest desktop bundle...'); + + try { + $projectId = config('nativephp-internal.bifrost.project'); + $response = $this->makeApiRequest('GET', "api/v1/projects/{$projectId}/builds/latest-desktop-bundle"); + + if ($response->failed()) { + $this->handleApiError($response); + + return static::FAILURE; + } + + $buildData = $response->json(); + + if (! isset($buildData['data']['download_url'])) { + $this->error('Bundle download URL not found in response.'); + + return static::FAILURE; + } + + $this->displayBundleInfo($buildData['data']); + + $bundlePath = $this->prepareBundlePath(); + + if (! $this->downloadBundle($buildData['data']['download_url'], $bundlePath)) { + return static::FAILURE; + } + + // Download GPG signature if available + $signaturePath = null; + if (isset($buildData['data']['signature_url'])) { + $signaturePath = $bundlePath.'.asc'; + if (! $this->downloadSignature($buildData['data']['signature_url'], $signaturePath)) { + $this->warn('Failed to download GPG signature file.'); + } + } + + $this->displaySuccessInfo($bundlePath, $signaturePath); + + return static::SUCCESS; + } catch (Exception $e) { + $this->error('Failed to download bundle: '.$e->getMessage()); + + return static::FAILURE; + } + } + + private function displayBundleInfo(array $buildData): void + { + $this->line(''); + $this->info('Bundle Details:'); + $this->line('Version: '.($buildData['version'] ?? 'Unknown')); + $this->line('Git Commit: '.substr($buildData['git_commit'] ?? '', 0, 8)); + $this->line('Git Branch: '.($buildData['git_branch'] ?? 'Unknown')); + $this->line('Created: '.($buildData['created_at'] ?? 'Unknown')); + } + + private function prepareBundlePath(): string + { + $buildDir = base_path('build'); + if (! is_dir($buildDir)) { + mkdir($buildDir, 0755, true); + } + + return base_path('build/__nativephp_app_bundle'); + } + + private function downloadBundle(string $downloadUrl, string $bundlePath): bool + { + $this->line(''); + $this->info('Downloading bundle...'); + + $progressBar = $this->output->createProgressBar(); + $progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%'); + + try { + $downloadResponse = Http::withOptions([ + 'sink' => $bundlePath, + 'progress' => function ($downloadTotal, $downloadedBytes) use ($progressBar) { + if ($downloadTotal > 0) { + $progressBar->setMaxSteps($downloadTotal); + $progressBar->setProgress($downloadedBytes); + $progressBar->setMessage(sprintf('%.1f MB', $downloadedBytes / 1024 / 1024)); + } + }, + ])->get($downloadUrl); + + $progressBar->finish(); + $this->line(''); + + if ($downloadResponse->failed()) { + $this->error('Failed to download bundle.'); + $this->cleanupFailedDownload($bundlePath); + + return false; + } + + return true; + } catch (Exception $e) { + $progressBar->finish(); + $this->line(''); + $this->error('Download failed: '.$e->getMessage()); + $this->cleanupFailedDownload($bundlePath); + + return false; + } + } + + private function cleanupFailedDownload(string $bundlePath): void + { + if (file_exists($bundlePath)) { + unlink($bundlePath); + $this->line('Cleaned up partial download.'); + } + } + + private function downloadSignature(string $signatureUrl, string $signaturePath): bool + { + $this->line(''); + $this->info('Downloading GPG signature...'); + + try { + $downloadResponse = Http::get($signatureUrl); + + if ($downloadResponse->failed()) { + return false; + } + + file_put_contents($signaturePath, $downloadResponse->body()); + + return true; + } catch (Exception $e) { + return false; + } + } + + private function displaySuccessInfo(string $bundlePath, ?string $signaturePath = null): void + { + $this->line(''); + $this->info('Bundle downloaded successfully!'); + $this->line('Location: '.$bundlePath); + + if (file_exists($bundlePath)) { + $sizeInMB = number_format(filesize($bundlePath) / 1024 / 1024, 2); + $this->line("Size: {$sizeInMB} MB"); + } + + if ($signaturePath && file_exists($signaturePath)) { + $this->line('GPG Signature: '.$signaturePath); + $this->line(''); + $this->info('To verify the bundle integrity:'); + $this->line('gpg --verify '.basename($signaturePath).' '.basename($bundlePath)); + } + } + + private function handleApiError($response): void + { + $status = $response->status(); + $data = $response->json(); + + switch ($status) { + case 404: + $this->line(''); + $this->error('No desktop builds found for this project.'); + $this->line(''); + $teamSlug = $this->getCurrentTeamSlug(); + $projectId = config('nativephp-internal.bifrost.project'); + $baseUrl = rtrim($this->baseUrl(), '/'); + + if ($teamSlug && $projectId) { + $this->info("Create a build at: {$baseUrl}/{$teamSlug}/desktop/projects/{$projectId}"); + } else { + $this->info("Visit the dashboard: {$baseUrl}/dashboard"); + } + break; + + case 503: + $retryAfter = intval($response->header('Retry-After')); + $diff = now()->addSeconds($retryAfter); + $diffMessage = $retryAfter <= 60 ? 'a minute' : $diff->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE); + $this->line(''); + $this->warn('Build is still in progress.'); + $this->line("Please try again in {$diffMessage}."); + break; + + case 500: + $this->line(''); + $this->error('Latest build has failed or was cancelled.'); + if (isset($data['build_id'])) { + $this->line('Build ID: '.$data['build_id']); + } + if (isset($data['status'])) { + $this->line('Status: '.$data['status']); + } + break; + + default: + $this->line(''); + $this->error('Failed to fetch bundle: '.($data['message'] ?? 'Unknown error')); + } + } +} diff --git a/src/Commands/Bifrost/InitCommand.php b/src/Commands/Bifrost/InitCommand.php new file mode 100644 index 00000000..04fa1f9f --- /dev/null +++ b/src/Commands/Bifrost/InitCommand.php @@ -0,0 +1,152 @@ +validateAuthAndGetUser(); + } catch (Exception $e) { + $this->error($e->getMessage()); + $this->line('Run: php artisan bifrost:login'); + + return static::FAILURE; + } + + intro('Fetching your desktop projects...'); + + try { + $response = $this->makeApiRequest('GET', 'api/v1/projects'); + + if ($response->failed()) { + $this->handleApiError($response); + + return static::FAILURE; + } + + $responseData = $response->json(); + + if (! isset($responseData['data']) || ! is_array($responseData['data'])) { + $this->error('Invalid API response format.'); + + return static::FAILURE; + } + + $projects = collect($responseData['data']) + ->filter(fn ($project) => isset($project['type']) && $project['type'] === 'desktop') + ->values() + ->toArray(); + + if (empty($projects)) { + $this->displayNoProjectsMessage($user); + + return static::FAILURE; + } + + $choices = collect($projects)->mapWithKeys(function ($project) { + $name = $project['name'] ?? 'Unknown'; + $repo = $project['repo'] ?? 'No repository'; + + return [$project['uuid'] => "{$name} - {$repo}"]; + })->toArray(); + + $selectedProjectUuid = select( + label: 'Select a desktop project', + options: $choices, + required: true + ); + + $selectedProject = collect($projects)->firstWhere('uuid', $selectedProjectUuid); + + if (! $selectedProject) { + $this->error('Selected project not found.'); + + return static::FAILURE; + } + + // Store project UUID in .env file + Builder::make()->updateEnvFile('BIFROST_PROJECT', $selectedProjectUuid, app()->environmentFile()); + + $this->displaySuccessMessage($selectedProject); + + return static::SUCCESS; + } catch (Exception $e) { + $this->error('Failed to fetch projects: '.$e->getMessage()); + + return static::FAILURE; + } + } + + private function displayNoProjectsMessage(array $user): void + { + $this->line(''); + $this->warn('No desktop projects found.'); + $this->line(''); + + $teamSlug = $user['current_team']['slug'] ?? null; + $baseUrl = rtrim($this->baseUrl(), '/'); + + if ($teamSlug) { + $this->info("Create a desktop project at: {$baseUrl}/{$teamSlug}/onboarding/project/desktop"); + } else { + $this->info("Create a desktop project at: {$baseUrl}/onboarding/project/desktop"); + } + } + + private function displaySuccessMessage(array $project): void + { + $this->line(''); + $this->info('Project selected successfully!'); + $this->line('Project: '.($project['name'] ?? 'Unknown')); + $this->line('Repository: '.($project['repo'] ?? 'Unknown')); + $this->line(''); + $this->line('You can now run "php artisan bifrost:download-bundle" to download the latest bundle.'); + } + + private function handleApiError($response): void + { + $status = $response->status(); + $baseUrl = rtrim($this->baseUrl(), '/'); + + switch ($status) { + case 403: + $this->line(''); + $this->error('No teams found. Please create a team first.'); + $this->line(''); + $this->info("Create a team at: {$baseUrl}/onboarding/team"); + break; + + case 422: + $this->line(''); + $this->error('Team setup incomplete or subscription required.'); + $this->line(''); + $this->info("Complete setup at: {$baseUrl}/dashboard"); + break; + + default: + $this->line(''); + $this->error('Failed to fetch projects: '.$response->json('message', 'Unknown error')); + $this->line(''); + $this->info("Visit the dashboard: {$baseUrl}/dashboard"); + } + } +} diff --git a/src/Commands/Bifrost/LoginCommand.php b/src/Commands/Bifrost/LoginCommand.php new file mode 100644 index 00000000..e2414598 --- /dev/null +++ b/src/Commands/Bifrost/LoginCommand.php @@ -0,0 +1,120 @@ + match (true) { + ! filter_var($value, FILTER_VALIDATE_EMAIL) => 'Please enter a valid email address.', + default => null + } + ); + + $password = password( + label: 'Password', + required: true + ); + + $this->line(''); + $this->info('Logging in...'); + + try { + $response = Http::acceptJson() + ->post($this->baseUrl().'api/v1/auth/login', [ + 'email' => $email, + 'password' => $password, + ]); + + if ($response->failed()) { + $this->line(''); + $this->error('Login failed: '.$response->json('message', 'Invalid credentials')); + + return static::FAILURE; + } + + $responseData = $response->json(); + + if (! isset($responseData['data']['token'])) { + $this->line(''); + $this->error('Login response missing token. Please try again.'); + + return static::FAILURE; + } + + $token = $responseData['data']['token']; + + // Store token in .env file + Builder::make()->updateEnvFile('BIFROST_TOKEN', $token, app()->environmentFile()); + + // Fetch and display user info + $this->displayUserInfo($token); + + $this->line(''); + $this->line('Next step: Run "php artisan bifrost:init" to select a project.'); + + return static::SUCCESS; + } catch (Exception $e) { + $this->line(''); + $this->error('Network error: '.$e->getMessage()); + + return static::FAILURE; + } + } + + private function displayUserInfo(string $token): void + { + try { + $userResponse = Http::acceptJson() + ->withToken($token) + ->get($this->baseUrl().'api/v1/auth/user'); + + if ($userResponse->successful()) { + $userData = $userResponse->json(); + + if (isset($userData['data'])) { + $user = $userData['data']; + $this->line(''); + $this->info('Successfully logged in!'); + $this->line('User: '.($user['name'] ?? 'Unknown').' ('.($user['email'] ?? 'Unknown').')'); + + if (isset($user['current_team']['name'])) { + $this->line('Team: '.$user['current_team']['name']); + } + + return; + } + } + } catch (Exception $e) { + // Silently fail user info display - login was successful + } + + $this->line(''); + $this->info('Successfully logged in!'); + } +} diff --git a/src/Commands/Bifrost/LogoutCommand.php b/src/Commands/Bifrost/LogoutCommand.php new file mode 100644 index 00000000..74fc9cb3 --- /dev/null +++ b/src/Commands/Bifrost/LogoutCommand.php @@ -0,0 +1,46 @@ +checkForBifrostToken()) { + $this->warn('You are not logged in.'); + + return static::SUCCESS; + } + + intro('Logging out from Bifrost...'); + + // Attempt to logout on the server + Http::acceptJson() + ->withToken(config('nativephp-internal.bifrost.token')) + ->post($this->baseUrl().'api/v1/auth/logout'); + + // Remove token and project from .env file regardless of server response + Builder::make()->removeFromEnvFile(['BIFROST_TOKEN', 'BIFROST_PROJECT'], app()->environmentFile()); + + $this->info('Successfully logged out!'); + $this->line('Your API token and project selection have been removed.'); + + return static::SUCCESS; + } +} diff --git a/src/Drivers/Electron/Commands/BundleCommand.php b/src/Drivers/Electron/Commands/BundleCommand.php deleted file mode 100644 index 3304c35a..00000000 --- a/src/Drivers/Electron/Commands/BundleCommand.php +++ /dev/null @@ -1,402 +0,0 @@ -builder = Builder::make( - buildPath: base_path('build/app/') - ); - } - - public function handle(): int - { - // Remove the bundle - if ($this->option('clear')) { - if (file_exists(base_path('build/__nativephp_app_bundle'))) { - unlink(base_path('build/__nativephp_app_bundle')); - } - - $this->info('Bundle removed. Building in this state would be unsecure.'); - - return static::SUCCESS; - } - - // Check for ZEPHPYR_KEY - if (! $this->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; - } - - $this->builder->preProcess(); - - $this->setAppNameAndVersion(); - intro('Copying App to build directory...'); - - // We update composer.json later, - $this->builder->copyToBuildDirectory(); - - $this->newLine(); - intro('Cleaning .env file...'); - $this->builder->cleanEnvFile(); - - $this->newLine(); - intro('Copying app icons...'); - $this->installIcon(); - - $this->newLine(); - intro('Pruning vendor directory'); - $this->builder->pruneVendorDirectory(); - - // 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 = $this->zipPath($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->addFilesToZip($zip); - - $zip->close(); - - return true; - } - - private function checkComposerJson(): bool - { - $composerJson = json_decode(file_get_contents($this->builder->buildPath('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; - // // } - // // } - // } - - // Remove repositories with type path, we include symlinked packages - if (! empty($composerJson['repositories'])) { - - $this->newLine(); - intro('Patching composer.json in development mode…'); - - $filteredRepo = array_filter($composerJson['repositories'], - fn ($repository) => $repository['type'] !== 'path'); - - if (count($filteredRepo) !== count($composerJson['repositories'])) { - $composerJson['repositories'] = $filteredRepo; - file_put_contents($this->builder->buildPath('composer.json'), - json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - - // Process::path($this->builder->buildPath()) - // ->run('composer install --no-dev', function (string $type, string $output) { - // echo $output; - // }); - } - - } - - return true; - } - - // private function checkComposerPackageAuth(string $repositoryUrl): bool - // { - // // Check if the user has authenticated the package on Zephpyr - // $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->newLine(); - intro('Creating zip archive…'); - - $finder = (new Finder)->files() - ->followLinks() - // ->ignoreVCSIgnored(true) // TODO: Make our own list of ignored files - ->in($this->builder->buildPath()) - ->exclude([ - // We add those a few lines below and they are ignored by most .gitignore anyway - 'vendor', - 'node_modules', - - // Exclude the following directories - 'dist', // Compiled nativephp assets - 'build', // Compiled box assets - 'temp', // Temp files - 'tests', // Tests - 'auth.json', // Composer auth file - ]) - ->exclude(config('nativephp.cleanup_exclude_files', [])); - - $this->finderToZip($finder, $zip); - - // Why do I have to force this? please someone explain. - if (file_exists($this->builder->buildPath('public/build'))) { - $this->finderToZip( - (new Finder)->files() - ->followLinks() - ->in($this->builder->buildPath('public/build')), $zip, 'public/build'); - } - - // Add .env file manually because Finder ignores VCS and dot files - $zip->addFile($this->builder->buildPath('.env'), '.env'); - - // Add auth.json file to support private packages - // WARNING: Only for testing purposes, don't uncomment this - // $zip->addFile($this->builder->buildPath('auth.json'), 'auth.json'); - - // Custom binaries - $binaryPath = Str::replaceStart($this->builder->buildPath('vendor'), '', config('nativephp.binary_path')); - - // Add composer dependencies without unnecessary files - $vendor = (new Finder)->files() - ->exclude(array_filter([ - 'nativephp/php-bin', - 'nativephp/desktop/resources/electron', - '*/*/vendor', // Exclude sub-vendor directories - $binaryPath, - ])) - ->in($this->builder->buildPath('vendor')); - - $this->finderToZip($vendor, $zip, 'vendor'); - - // Add javascript dependencies - if (file_exists($this->builder->buildPath('node_modules'))) { - $nodeModules = (new Finder)->files() - ->in($this->builder->buildPath('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; - } - - $zipPath = str($path)->finish('/').$file->getRelativePathname(); - $zipPath = str_replace('\\', '/', $zipPath); - - $zip->addFile($file->getRealPath(), $zipPath); - } - } - - private function sendToZephpyr() - { - intro('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 - { - intro('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) { - $url = $response->json('url'); - - if ($url) { - $this->error('Build failed. Inspect the build here: '.$url); - } else { - $this->error('Build failed. Please try again later.'); - } - } elseif ($response->status() === 503) { - $retryAfter = intval($response->header('Retry-After')); - $diff = now()->addSeconds($retryAfter); - $diffMessage = $retryAfter <= 60 ? 'a minute' : $diff->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE); - $this->warn('Bundle not ready. Please try again in '.$diffMessage.'.'); - } 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 - { - $this->builder->postProcess(); - - if ($this->option('without-cleanup')) { - return; - } - - $previousBuilds = glob($this->zipPath().'/app_*.zip'); - $failedZips = glob($this->zipPath().'/app_*.part'); - - $deleteFiles = array_merge($previousBuilds, $failedZips); - - if (empty($deleteFiles)) { - return; - } - - $this->line('Cleaning up…'); - - foreach ($deleteFiles as $file) { - @unlink($file); - } - } - - protected function zipPath(string $path = ''): string - { - return base_path('build/zip/'.$path); - } -} diff --git a/src/Drivers/Electron/ElectronServiceProvider.php b/src/Drivers/Electron/ElectronServiceProvider.php index 816be6b8..0ca68240 100644 --- a/src/Drivers/Electron/ElectronServiceProvider.php +++ b/src/Drivers/Electron/ElectronServiceProvider.php @@ -5,7 +5,6 @@ use Illuminate\Foundation\Application; use Native\Desktop\Builder\Builder; use Native\Desktop\Drivers\Electron\Commands\BuildCommand; -use Native\Desktop\Drivers\Electron\Commands\BundleCommand; use Native\Desktop\Drivers\Electron\Commands\InstallCommand; use Native\Desktop\Drivers\Electron\Commands\PublishCommand; use Native\Desktop\Drivers\Electron\Commands\ResetCommand; @@ -42,7 +41,6 @@ public function configurePackage(Package $package): void RunCommand::class, BuildCommand::class, PublishCommand::class, - BundleCommand::class, ResetCommand::class, ServeCommand::class, // Deprecated ]); diff --git a/src/Drivers/Electron/Traits/HandlesZephpyr.php b/src/Drivers/Electron/Traits/HandlesZephpyr.php deleted file mode 100644 index b9f51b06..00000000 --- a/src/Drivers/Electron/Traits/HandlesZephpyr.php +++ /dev/null @@ -1,64 +0,0 @@ -finish('/'); - } - - private function checkAuthenticated() - { - intro('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 Zephpyr API token to your .env file (ZEPHPYR_TOKEN):'); - $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 f4d6cb40..4d240ddd 100644 --- a/src/NativeServiceProvider.php +++ b/src/NativeServiceProvider.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Native\Desktop\ChildProcess as ChildProcessImplementation; +use Native\Desktop\Commands\Bifrost; use Native\Desktop\Commands\DebugCommand; use Native\Desktop\Commands\FreshCommand; use Native\Desktop\Commands\LoadPHPConfigurationCommand; @@ -48,6 +49,11 @@ public function configurePackage(Package $package): void MigrateCommand::class, SeedDatabaseCommand::class, WipeDatabaseCommand::class, + Bifrost\LoginCommand::class, + Bifrost\LogoutCommand::class, + Bifrost\InitCommand::class, + Bifrost\DownloadBundleCommand::class, + Bifrost\ClearBundleCommand::class, ]) ->hasConfigFile() ->hasRoute('api') diff --git a/tests/Build/CleanEnvFileTest.php b/tests/Build/CleanEnvFileTest.php index dc34efb3..79ec93b6 100644 --- a/tests/Build/CleanEnvFileTest.php +++ b/tests/Build/CleanEnvFileTest.php @@ -1,6 +1,6 @@