Skip to content

Commit 2bdf295

Browse files
committed
feat: native:bundle command
1 parent 258cbc3 commit 2bdf295

File tree

6 files changed

+444
-3
lines changed

6 files changed

+444
-3
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
"nativephp/laravel": "dev-main",
3838
"nativephp/php-bin": "^0.5.1",
3939
"spatie/laravel-package-tools": "^1.16.4",
40-
"symfony/filesystem": "^6.4|^7.2"
40+
"symfony/filesystem": "^6.4|^7.2",
41+
"ext-zip": "*"
4142
},
4243
"require-dev": {
4344
"laravel/pint": "^1.0",

phpstan.neon

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ parameters:
44
- src
55
- config
66
- database
7-
tmpDir: build/phpstan
87
checkOctaneCompatibility: true
98
checkModelProperties: true
10-
9+
noEnvCallsOutsideOfConfig: false

src/Commands/BundleCommand.php

Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
<?php
2+
3+
namespace Native\Electron\Commands;
4+
5+
use Carbon\CarbonInterface;
6+
use Illuminate\Console\Command;
7+
use Illuminate\Http\Client\Response;
8+
use Illuminate\Support\Facades\Http;
9+
use Illuminate\Support\Facades\Process;
10+
use Illuminate\Support\Number;
11+
use Illuminate\Support\Str;
12+
use Native\Electron\Traits\CleansEnvFile;
13+
use Native\Electron\Traits\CopiesToBuildDirectory;
14+
use Native\Electron\Traits\HandleApiRequests;
15+
use Native\Electron\Traits\HasPreAndPostProcessing;
16+
use Native\Electron\Traits\InstallsAppIcon;
17+
use Native\Electron\Traits\PrunesVendorDirectory;
18+
use Native\Electron\Traits\SetsAppName;
19+
use Symfony\Component\Finder\Finder;
20+
use ZipArchive;
21+
22+
use function Laravel\Prompts\intro;
23+
24+
class BundleCommand extends Command
25+
{
26+
use CleansEnvFile;
27+
use CopiesToBuildDirectory;
28+
use HandleApiRequests;
29+
use HasPreAndPostProcessing;
30+
use InstallsAppIcon;
31+
use PrunesVendorDirectory;
32+
use SetsAppName;
33+
34+
protected $signature = 'native:bundle {--fetch} {--without-cleanup}';
35+
36+
protected $description = 'Bundle your application for distribution.';
37+
38+
private ?string $key;
39+
40+
private string $zipPath;
41+
42+
private string $zipName;
43+
44+
public function handle(): int
45+
{
46+
// Check for ZEPHPYR_KEY
47+
if (! $this->checkForZephpyrKey()) {
48+
return static::FAILURE;
49+
}
50+
51+
// Check for ZEPHPYR_TOKEN
52+
if (! $this->checkForZephpyrToken()) {
53+
return static::FAILURE;
54+
}
55+
56+
// Check if the token is valid
57+
if (! $this->checkAuthenticated()) {
58+
$this->error('Invalid API token: check your ZEPHPYR_TOKEN on '.$this->baseUrl().'user/api-tokens');
59+
60+
return static::FAILURE;
61+
}
62+
63+
// Download the latest bundle if requested
64+
if ($this->option('fetch')) {
65+
if (! $this->fetchLatestBundle()) {
66+
67+
return static::FAILURE;
68+
}
69+
70+
$this->info('Latest bundle downloaded.');
71+
72+
return static::SUCCESS;
73+
}
74+
75+
$this->preProcess();
76+
77+
$this->setAppName(slugify: true);
78+
intro('Copying App to build directory...');
79+
80+
// We update composer.json later,
81+
$this->copyToBuildDirectory();
82+
83+
$this->newLine();
84+
intro('Cleaning .env file...');
85+
$this->cleanEnvFile();
86+
87+
$this->newLine();
88+
intro('Copying app icons...');
89+
$this->installIcon();
90+
91+
$this->newLine();
92+
intro('Pruning vendor directory');
93+
$this->pruneVendorDirectory();
94+
95+
// Check composer.json for symlinked or private packages
96+
if (! $this->checkComposerJson()) {
97+
return static::FAILURE;
98+
}
99+
100+
// Package the app up into a zip
101+
if (! $this->zipApplication()) {
102+
$this->error("Failed to create zip archive at {$this->zipPath}.");
103+
104+
return static::FAILURE;
105+
}
106+
107+
// Send the zip file
108+
$result = $this->sendToZephpyr();
109+
$this->handleApiErrors($result);
110+
111+
// Success
112+
$this->info('Successfully uploaded to Zephpyr.');
113+
$this->line('Use native:bundle --fetch to retrieve the latest bundle.');
114+
115+
// Clean up temp files
116+
$this->cleanUp();
117+
118+
return static::SUCCESS;
119+
}
120+
121+
private function zipApplication(): bool
122+
{
123+
$this->zipName = 'app_'.str()->random(8).'.zip';
124+
$this->zipPath = $this->zipPath($this->zipName);
125+
126+
// Create zip path
127+
if (! @mkdir(dirname($this->zipPath), recursive: true) && ! is_dir(dirname($this->zipPath))) {
128+
return false;
129+
}
130+
131+
$zip = new ZipArchive;
132+
133+
if ($zip->open($this->zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
134+
return false;
135+
}
136+
137+
$this->cleanEnvFile();
138+
139+
$this->addFilesToZip($zip);
140+
141+
$zip->close();
142+
143+
return true;
144+
}
145+
146+
private function checkComposerJson(): bool
147+
{
148+
$composerJson = json_decode(file_get_contents($this->buildPath('composer.json')), true);
149+
150+
// // Fail if there is symlinked packages
151+
// foreach ($composerJson['repositories'] ?? [] as $repository) {
152+
//
153+
// $symlinked = $repository['options']['symlink'] ?? true;
154+
// if ($repository['type'] === 'path' && $symlinked) {
155+
// $this->error('Symlinked packages are not supported. Please remove them from your composer.json.');
156+
//
157+
// return false;
158+
// }
159+
// // Work with private packages but will not in the future
160+
// // elseif ($repository['type'] === 'composer') {
161+
// // if (! $this->checkComposerPackageAuth($repository['url'])) {
162+
// // $this->error('Cannot authenticate with '.$repository['url'].'.');
163+
// // $this->error('Go to '.$this->baseUrl().' and add your composer package credentials.');
164+
// //
165+
// // return false;
166+
// // }
167+
// // }
168+
// }
169+
170+
// Remove repositories with type path
171+
if (! empty($composerJson['repositories'])) {
172+
173+
$this->newLine();
174+
intro('Patching composer.json in development mode…');
175+
176+
$filteredRepo = array_filter($composerJson['repositories'], fn ($repository) => $repository['type'] !== 'path');
177+
178+
if (count($filteredRepo) !== count($composerJson['repositories'])) {
179+
$composerJson['repositories'] = $filteredRepo;
180+
file_put_contents($this->buildPath('composer.json'), json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
181+
182+
Process::path($this->buildPath())
183+
->run('composer update --no-dev', function (string $type, string $output) {
184+
echo $output;
185+
});
186+
}
187+
188+
}
189+
190+
return true;
191+
}
192+
193+
// private function checkComposerPackageAuth(string $repositoryUrl): bool
194+
// {
195+
// // Check if the user has authenticated the package on Zephpyr
196+
// $host = parse_url($repositoryUrl, PHP_URL_HOST);
197+
// $this->line('Checking '.$host.' authentication…');
198+
//
199+
// return Http::acceptJson()
200+
// ->withToken(config('nativephp-internal.zephpyr.token'))
201+
// ->get($this->baseUrl().'api/v1/project/'.$this->key.'/composer/auth/'.$host)
202+
// ->successful();
203+
// }
204+
205+
private function addFilesToZip(ZipArchive $zip): void
206+
{
207+
$this->newLine();
208+
intro('Creating zip archive…');
209+
210+
$app = (new Finder)->files()
211+
->followLinks()
212+
->ignoreVCSIgnored(true)
213+
->in($this->buildPath())
214+
->exclude([
215+
// We add those a few lines below
216+
'vendor',
217+
'node_modules',
218+
219+
// Exclude the following directories
220+
'dist', // Compiled nativephp assets
221+
'build', // Compiled box assets
222+
'temp', // Temp files
223+
'tests', // Tests
224+
225+
// TODO: include everything in the .gitignore file
226+
227+
...config('nativephp.cleanup_exclude_files', []), // User defined
228+
]);
229+
230+
$this->finderToZip($app, $zip);
231+
232+
// Add .env file manually because Finder ignores hidden files
233+
$zip->addFile($this->buildPath('.env'), '.env');
234+
235+
// Add auth.json file to support private packages
236+
// WARNING: Only for testing purposes, don't uncomment this
237+
// $zip->addFile($this->buildPath('auth.json'), 'auth.json');
238+
239+
// Custom binaries
240+
$binaryPath = Str::replaceStart($this->buildPath('vendor'), '', config('nativephp.binary_path'));
241+
242+
// Add composer dependencies without unnecessary files
243+
$vendor = (new Finder)->files()
244+
->exclude(array_filter([
245+
'nativephp/php-bin',
246+
'nativephp/electron/resources/js',
247+
'*/*/vendor', // Exclude sub-vendor directories
248+
$binaryPath,
249+
]))
250+
->in($this->buildPath('vendor'));
251+
252+
$this->finderToZip($vendor, $zip, 'vendor');
253+
254+
// Add javascript dependencies
255+
if (file_exists($this->buildPath('node_modules'))) {
256+
$nodeModules = (new Finder)->files()
257+
->in($this->buildPath('node_modules'));
258+
259+
$this->finderToZip($nodeModules, $zip, 'node_modules');
260+
}
261+
}
262+
263+
private function finderToZip(Finder $finder, ZipArchive $zip, ?string $path = null): void
264+
{
265+
foreach ($finder as $file) {
266+
if ($file->getRealPath() === false) {
267+
continue;
268+
}
269+
270+
$zip->addFile($file->getRealPath(), str($path)->finish(DIRECTORY_SEPARATOR).$file->getRelativePathname());
271+
}
272+
}
273+
274+
private function sendToZephpyr()
275+
{
276+
intro('Uploading zip to Zephpyr…');
277+
278+
return Http::acceptJson()
279+
->timeout(300) // 5 minutes
280+
->withoutRedirecting() // Upload won't work if we follow redirects (it transform POST to GET)
281+
->withToken(config('nativephp-internal.zephpyr.token'))
282+
->attach('archive', fopen($this->zipPath, 'r'), $this->zipName)
283+
->post($this->baseUrl().'api/v1/project/'.$this->key.'/build/');
284+
}
285+
286+
private function fetchLatestBundle(): bool
287+
{
288+
intro('Fetching latest bundle…');
289+
290+
$response = Http::acceptJson()
291+
->withToken(config('nativephp-internal.zephpyr.token'))
292+
->get($this->baseUrl().'api/v1/project/'.$this->key.'/build/download');
293+
294+
if ($response->failed()) {
295+
296+
if ($response->status() === 404) {
297+
$this->error('Project or bundle not found.');
298+
} elseif ($response->status() === 500) {
299+
$this->error('Build failed. Please try again later.');
300+
} elseif ($response->status() === 503) {
301+
$this->warn('Bundle not ready. Please try again in '.now()->addSeconds(intval($response->header('Retry-After')))->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE).'.');
302+
} else {
303+
$this->handleApiErrors($response);
304+
}
305+
306+
return false;
307+
}
308+
309+
// Save the bundle
310+
@mkdir(base_path('build'), recursive: true);
311+
file_put_contents(base_path('build/__nativephp_app_bundle'), $response->body());
312+
313+
return true;
314+
}
315+
316+
protected function exitWithMessage(string $message): void
317+
{
318+
$this->error($message);
319+
$this->cleanUp();
320+
321+
exit(static::FAILURE);
322+
}
323+
324+
private function handleApiErrors(Response $result): void
325+
{
326+
if ($result->status() === 413) {
327+
$fileSize = Number::fileSize(filesize($this->zipPath));
328+
$this->exitWithMessage('File is too large to upload ('.$fileSize.'). Please contact support.');
329+
} elseif ($result->status() === 422) {
330+
$this->error('Request refused:'.$result->json('message'));
331+
} elseif ($result->status() === 429) {
332+
$this->exitWithMessage('Too many requests. Please try again in '.now()->addSeconds(intval($result->header('Retry-After')))->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE).'.');
333+
} elseif ($result->failed()) {
334+
$this->exitWithMessage("Request failed. Error code: {$result->status()}");
335+
}
336+
}
337+
338+
protected function cleanUp(): void
339+
{
340+
$this->postProcess();
341+
342+
if ($this->option('without-cleanup')) {
343+
return;
344+
}
345+
346+
$previousBuilds = glob($this->zipPath().'/app_*.zip');
347+
$failedZips = glob($this->zipPath().'/app_*.part');
348+
349+
$deleteFiles = array_merge($previousBuilds, $failedZips);
350+
351+
if (empty($deleteFiles)) {
352+
return;
353+
}
354+
355+
$this->line('Cleaning up…');
356+
357+
foreach ($deleteFiles as $file) {
358+
@unlink($file);
359+
}
360+
}
361+
362+
protected function buildPath(string $path = ''): string
363+
{
364+
return base_path('temp/build/'.$path);
365+
}
366+
367+
protected function zipPath(string $path = ''): string
368+
{
369+
return base_path('temp/zip/'.$path);
370+
}
371+
372+
protected function sourcePath(string $path = ''): string
373+
{
374+
return base_path($path);
375+
}
376+
}

0 commit comments

Comments
 (0)