@fleetbase/registry-bridge-engine 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/.php-cs-fixer.php +29 -0
  2. package/LICENSE.md +651 -0
  3. package/README.md +122 -0
  4. package/addon/adapters/registry-bridge.js +5 -0
  5. package/addon/adapters/registry-extension-bundle.js +1 -0
  6. package/addon/adapters/registry-extension.js +1 -0
  7. package/addon/components/extension-card.hbs +12 -0
  8. package/addon/components/extension-card.js +235 -0
  9. package/addon/components/extension-form.hbs +237 -0
  10. package/addon/components/extension-form.js +123 -0
  11. package/addon/components/extension-modal-title.hbs +14 -0
  12. package/addon/components/extension-modal-title.js +20 -0
  13. package/addon/components/extension-monetize-form.hbs +56 -0
  14. package/addon/components/extension-monetize-form.js +7 -0
  15. package/addon/components/extension-pending-publish-viewer.hbs +52 -0
  16. package/addon/components/extension-pending-publish-viewer.js +37 -0
  17. package/addon/components/extension-reviewer-control.hbs +68 -0
  18. package/addon/components/extension-reviewer-control.js +68 -0
  19. package/addon/components/modals/confirm-extension-purchase.hbs +5 -0
  20. package/addon/components/modals/confirm-extension-purchase.js +3 -0
  21. package/addon/components/modals/extension-details.hbs +69 -0
  22. package/addon/components/modals/extension-details.js +33 -0
  23. package/addon/components/modals/extension-purchase-form.hbs +5 -0
  24. package/addon/components/modals/extension-purchase-form.js +3 -0
  25. package/addon/components/modals/extension-uninstall.hbs +25 -0
  26. package/addon/components/modals/extension-uninstall.js +11 -0
  27. package/addon/components/modals/select-extension-bundle.hbs +43 -0
  28. package/addon/components/modals/select-extension-bundle.js +31 -0
  29. package/addon/components/progress-bar.hbs +12 -0
  30. package/addon/components/progress-bar.js +8 -0
  31. package/addon/controllers/application.js +6 -0
  32. package/addon/controllers/developers/analytics.js +26 -0
  33. package/addon/controllers/developers/extensions/edit/bundles.js +70 -0
  34. package/addon/controllers/developers/extensions/edit/index.js +3 -0
  35. package/addon/controllers/developers/extensions/edit/monetize.js +7 -0
  36. package/addon/controllers/developers/extensions/edit.js +107 -0
  37. package/addon/controllers/developers/extensions/index.js +3 -0
  38. package/addon/controllers/developers/extensions/new.js +32 -0
  39. package/addon/controllers/developers/payments/index.js +39 -0
  40. package/addon/controllers/developers/payments/onboard.js +67 -0
  41. package/addon/controllers/explore/category.js +22 -0
  42. package/addon/controllers/explore/index.js +15 -0
  43. package/addon/controllers/installed.js +86 -0
  44. package/addon/controllers/purchased.js +18 -0
  45. package/addon/engine.js +44 -0
  46. package/addon/models/registry-extension-bundle.js +62 -0
  47. package/addon/models/registry-extension.js +215 -0
  48. package/addon/routes/application.js +12 -0
  49. package/addon/routes/developers/analytics.js +10 -0
  50. package/addon/routes/developers/credentials.js +3 -0
  51. package/addon/routes/developers/extensions/edit/bundles.js +21 -0
  52. package/addon/routes/developers/extensions/edit/details.js +3 -0
  53. package/addon/routes/developers/extensions/edit/index.js +3 -0
  54. package/addon/routes/developers/extensions/edit/monetize.js +3 -0
  55. package/addon/routes/developers/extensions/edit.js +18 -0
  56. package/addon/routes/developers/extensions/index.js +10 -0
  57. package/addon/routes/developers/extensions/new.js +3 -0
  58. package/addon/routes/developers/extensions.js +3 -0
  59. package/addon/routes/developers/payments/index.js +26 -0
  60. package/addon/routes/developers/payments/onboard.js +21 -0
  61. package/addon/routes/developers/payments.js +3 -0
  62. package/addon/routes/developers.js +3 -0
  63. package/addon/routes/explore/category.js +27 -0
  64. package/addon/routes/explore/index.js +17 -0
  65. package/addon/routes/explore.js +3 -0
  66. package/addon/routes/installed.js +10 -0
  67. package/addon/routes/purchased.js +10 -0
  68. package/addon/routes.js +28 -0
  69. package/addon/serializers/registry-extension-bundle.js +15 -0
  70. package/addon/serializers/registry-extension.js +21 -0
  71. package/addon/services/stripe.js +83 -0
  72. package/addon/styles/registry-bridge-engine.css +142 -0
  73. package/addon/templates/application.hbs +26 -0
  74. package/addon/templates/developers/analytics.hbs +83 -0
  75. package/addon/templates/developers/credentials.hbs +1 -0
  76. package/addon/templates/developers/extensions/edit/bundles.hbs +71 -0
  77. package/addon/templates/developers/extensions/edit/details.hbs +16 -0
  78. package/addon/templates/developers/extensions/edit/index.hbs +1 -0
  79. package/addon/templates/developers/extensions/edit/monetize.hbs +3 -0
  80. package/addon/templates/developers/extensions/edit.hbs +48 -0
  81. package/addon/templates/developers/extensions/index.hbs +27 -0
  82. package/addon/templates/developers/extensions/new.hbs +39 -0
  83. package/addon/templates/developers/extensions.hbs +1 -0
  84. package/addon/templates/developers/payments/index.hbs +33 -0
  85. package/addon/templates/developers/payments/onboard.hbs +48 -0
  86. package/addon/templates/developers/payments.hbs +1 -0
  87. package/addon/templates/developers.hbs +1 -0
  88. package/addon/templates/explore/category.hbs +12 -0
  89. package/addon/templates/explore/index.hbs +12 -0
  90. package/addon/templates/explore.hbs +1 -0
  91. package/addon/templates/installed.hbs +32 -0
  92. package/addon/templates/purchased.hbs +34 -0
  93. package/app/adapters/registry-bridge.js +1 -0
  94. package/app/adapters/registry-extension-bundle.js +1 -0
  95. package/app/adapters/registry-extension.js +1 -0
  96. package/app/components/extension-card.js +1 -0
  97. package/app/components/extension-form.js +1 -0
  98. package/app/components/extension-modal-title.js +1 -0
  99. package/app/components/extension-monetize-form.js +1 -0
  100. package/app/components/extension-pending-publish-viewer.js +1 -0
  101. package/app/components/extension-reviewer-control.js +1 -0
  102. package/app/components/modals/confirm-extension-purchase.js +1 -0
  103. package/app/components/modals/extension-details.js +1 -0
  104. package/app/components/modals/extension-purchase-form.js +1 -0
  105. package/app/components/modals/extension-uninstall.js +1 -0
  106. package/app/components/modals/select-extension-bundle.js +1 -0
  107. package/app/components/progress-bar.js +1 -0
  108. package/app/controllers/application.js +1 -0
  109. package/app/controllers/developers/analytics.js +1 -0
  110. package/app/controllers/developers/extensions/edit/bundles.js +1 -0
  111. package/app/controllers/developers/extensions/edit/index.js +1 -0
  112. package/app/controllers/developers/extensions/edit/monetize.js +1 -0
  113. package/app/controllers/developers/extensions/edit.js +1 -0
  114. package/app/controllers/developers/extensions/index.js +1 -0
  115. package/app/controllers/developers/extensions/new.js +1 -0
  116. package/app/controllers/developers/payments/index.js +1 -0
  117. package/app/controllers/developers/payments/onboard.js +1 -0
  118. package/app/controllers/explore/category.js +1 -0
  119. package/app/controllers/explore/index.js +1 -0
  120. package/app/controllers/installed.js +1 -0
  121. package/app/controllers/purchased.js +1 -0
  122. package/app/models/registry-extension-bundle.js +1 -0
  123. package/app/models/registry-extension.js +1 -0
  124. package/app/routes/developers/analytics.js +1 -0
  125. package/app/routes/developers/credentials.js +1 -0
  126. package/app/routes/developers/extensions/edit/bundles.js +1 -0
  127. package/app/routes/developers/extensions/edit/details.js +1 -0
  128. package/app/routes/developers/extensions/edit/index.js +1 -0
  129. package/app/routes/developers/extensions/edit/monetize.js +1 -0
  130. package/app/routes/developers/extensions/edit.js +1 -0
  131. package/app/routes/developers/extensions/index.js +1 -0
  132. package/app/routes/developers/extensions/new.js +1 -0
  133. package/app/routes/developers/extensions.js +1 -0
  134. package/app/routes/developers/payments/index.js +1 -0
  135. package/app/routes/developers/payments/onboard.js +1 -0
  136. package/app/routes/developers/payments.js +1 -0
  137. package/app/routes/developers.js +1 -0
  138. package/app/routes/explore/category.js +1 -0
  139. package/app/routes/explore/index.js +1 -0
  140. package/app/routes/explore.js +1 -0
  141. package/app/routes/installed.js +1 -0
  142. package/app/routes/purchased.js +1 -0
  143. package/app/serializers/registry-extension-bundle.js +1 -0
  144. package/app/serializers/registry-extension.js +1 -0
  145. package/app/services/stripe.js +1 -0
  146. package/app/templates/developers/analytics.js +1 -0
  147. package/app/templates/developers/credentials.js +1 -0
  148. package/app/templates/developers/extensions/edit/bundles.js +1 -0
  149. package/app/templates/developers/extensions/edit/details.js +1 -0
  150. package/app/templates/developers/extensions/edit/index.js +1 -0
  151. package/app/templates/developers/extensions/edit/monetize.js +1 -0
  152. package/app/templates/developers/extensions/edit.js +1 -0
  153. package/app/templates/developers/extensions/index.js +1 -0
  154. package/app/templates/developers/extensions/new.js +1 -0
  155. package/app/templates/developers/extensions.js +1 -0
  156. package/app/templates/developers/payments/index.js +1 -0
  157. package/app/templates/developers/payments/onboard.js +1 -0
  158. package/app/templates/developers/payments.js +1 -0
  159. package/app/templates/developers.js +1 -0
  160. package/app/templates/explore/category.js +1 -0
  161. package/app/templates/explore/index.js +1 -0
  162. package/app/templates/explore.js +1 -0
  163. package/app/templates/installed.js +1 -0
  164. package/app/templates/purchased.js +1 -0
  165. package/composer.json +95 -0
  166. package/config/environment.js +28 -0
  167. package/extension.json +10 -0
  168. package/index.js +26 -0
  169. package/package.json +129 -0
  170. package/phpstan.neon.dist +8 -0
  171. package/phpunit.xml.dist +16 -0
  172. package/server/.gitattributes +14 -0
  173. package/server/config/registry-bridge.php +32 -0
  174. package/server/migrations/2024_03_19_060627_create_registry_users_table.php +42 -0
  175. package/server/migrations/2024_03_21_051614_create_registry_extensions_table.php +76 -0
  176. package/server/migrations/2024_03_25_044537_create_registry_extension_bundles_table.php +54 -0
  177. package/server/migrations/2024_03_29_072101_registry_extension_installs.php +35 -0
  178. package/server/migrations/2024_07_16_155000_create_registry_extension_purchases.php +41 -0
  179. package/server/seeders/ExtensionsCategorySeeder.php +359 -0
  180. package/server/src/Console/Commands/Initialize.php +35 -0
  181. package/server/src/Console/Commands/PostInstallExtension.php +84 -0
  182. package/server/src/Exceptions/InstallFailedException.php +21 -0
  183. package/server/src/Expansions/CategoryExpansion.php +30 -0
  184. package/server/src/Http/Controllers/Internal/v1/ExtensionInstallerController.php +153 -0
  185. package/server/src/Http/Controllers/Internal/v1/RegistryAuthController.php +230 -0
  186. package/server/src/Http/Controllers/Internal/v1/RegistryController.php +54 -0
  187. package/server/src/Http/Controllers/Internal/v1/RegistryExtensionBundleController.php +112 -0
  188. package/server/src/Http/Controllers/Internal/v1/RegistryExtensionController.php +257 -0
  189. package/server/src/Http/Controllers/Internal/v1/RegistryPaymentsController.php +227 -0
  190. package/server/src/Http/Controllers/RegistryBridgeController.php +13 -0
  191. package/server/src/Http/Filter/RegistryExtensionFilter.php +80 -0
  192. package/server/src/Http/Requests/AddRegistryUserRequest.php +47 -0
  193. package/server/src/Http/Requests/AuthenticateRegistryUserRequest.php +47 -0
  194. package/server/src/Http/Requests/CreateRegistryExtensionBundleRequest.php +42 -0
  195. package/server/src/Http/Requests/CreateRegistryExtensionRequest.php +31 -0
  196. package/server/src/Http/Requests/InstallExtensionRequest.php +30 -0
  197. package/server/src/Http/Requests/RegistryAuthRequest.php +46 -0
  198. package/server/src/Http/Requests/RegistryExtensionActionRequest.php +30 -0
  199. package/server/src/Http/Resources/RegistryUser.php +40 -0
  200. package/server/src/Models/RegistryExtension.php +656 -0
  201. package/server/src/Models/RegistryExtensionBundle.php +1015 -0
  202. package/server/src/Models/RegistryExtensionInstall.php +76 -0
  203. package/server/src/Models/RegistryExtensionPurchase.php +87 -0
  204. package/server/src/Models/RegistryUser.php +140 -0
  205. package/server/src/Providers/RegistryBridgeServiceProvider.php +117 -0
  206. package/server/src/Support/Bridge.php +53 -0
  207. package/server/src/Support/Utils.php +19 -0
  208. package/server/src/routes.php +58 -0
  209. package/server/tests/Feature.php +5 -0
  210. package/translations/en-us.yaml +119 -0
  211. package/tsconfig.declarations.json +10 -0
@@ -0,0 +1,1015 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\RegistryBridge\Models;
4
+
5
+ use Fleetbase\Casts\Json;
6
+ use Fleetbase\Models\Company;
7
+ use Fleetbase\Models\File;
8
+ use Fleetbase\Models\Model;
9
+ use Fleetbase\Models\User;
10
+ use Fleetbase\RegistryBridge\Exceptions\InstallFailedException;
11
+ use Fleetbase\Support\SocketCluster\SocketClusterService;
12
+ use Fleetbase\Support\Utils;
13
+ use Fleetbase\Traits\HasApiModelBehavior;
14
+ use Fleetbase\Traits\HasMetaAttributes;
15
+ use Fleetbase\Traits\HasPublicId;
16
+ use Fleetbase\Traits\HasUuid;
17
+ use Fleetbase\Traits\Searchable;
18
+ use Illuminate\Support\Facades\Storage;
19
+ use Illuminate\Support\Str;
20
+ use stdClass;
21
+ use Symfony\Component\Process\Process;
22
+
23
+ class RegistryExtensionBundle extends Model
24
+ {
25
+ use HasUuid;
26
+ use HasPublicId;
27
+ use HasMetaAttributes;
28
+ use HasApiModelBehavior;
29
+ use Searchable;
30
+
31
+ /**
32
+ * The database table used by the model.
33
+ *
34
+ * @var string
35
+ */
36
+ protected $table = 'registry_extension_bundles';
37
+
38
+ /**
39
+ * The type of public Id to generate.
40
+ *
41
+ * @var string
42
+ */
43
+ protected $publicIdType = 'bundle';
44
+
45
+ /**
46
+ * The attributes that are mass assignable.
47
+ */
48
+ protected $fillable = [
49
+ 'uuid',
50
+ 'company_uuid',
51
+ 'created_by_uuid',
52
+ 'extension_uuid',
53
+ 'bundle_uuid',
54
+ 'bundle_id',
55
+ 'bundle_number',
56
+ 'version',
57
+ 'meta',
58
+ 'status',
59
+ ];
60
+
61
+ /**
62
+ * The attributes that should be cast to native types.
63
+ */
64
+ protected $casts = [
65
+ 'meta' => Json::class,
66
+ ];
67
+
68
+ /**
69
+ * Dynamic attributes that are appended to object.
70
+ *
71
+ * @var array
72
+ */
73
+ protected $appends = [
74
+ 'bundle_filename',
75
+ ];
76
+
77
+ /**
78
+ * Relations that should be loaded with model.
79
+ *
80
+ * @var array
81
+ */
82
+ protected $with = [];
83
+
84
+ /**
85
+ * Relations that should not be loaded.
86
+ *
87
+ * @var array
88
+ */
89
+ protected $without = ['company', 'createdBy', 'extension'];
90
+
91
+ /**
92
+ * Searchable columns.
93
+ *
94
+ * @var array
95
+ */
96
+ protected $searchableColumns = ['name'];
97
+
98
+ /**
99
+ * The "booting" method of the model.
100
+ *
101
+ * This method is called on the model boot and sets up
102
+ * event listeners, such as creating a unique bundle ID
103
+ * when a new model instance is being created.
104
+ */
105
+ protected static function boot()
106
+ {
107
+ parent::boot();
108
+
109
+ static::creating(function ($model) {
110
+ $model->bundle_id = self::generateUniqueBundleId();
111
+ });
112
+ }
113
+
114
+ /**
115
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
116
+ */
117
+ public function company()
118
+ {
119
+ return $this->belongsTo(Company::class, 'company_uuid', 'uuid');
120
+ }
121
+
122
+ /**
123
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
124
+ */
125
+ public function createdBy()
126
+ {
127
+ return $this->belongsTo(User::class, 'created_by_uuid', 'uuid');
128
+ }
129
+
130
+ /**
131
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
132
+ */
133
+ public function extension()
134
+ {
135
+ return $this->belongsTo(RegistryExtension::class, 'extension_uuid', 'uuid');
136
+ }
137
+
138
+ /**
139
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
140
+ */
141
+ public function bundle()
142
+ {
143
+ return $this->belongsTo(File::class, 'bundle_uuid', 'uuid');
144
+ }
145
+
146
+ /**
147
+ * Get the bundle original filename.
148
+ *
149
+ * @return string
150
+ */
151
+ public function getBundleFilenameAttribute()
152
+ {
153
+ if ($this->bundle instanceof File) {
154
+ return $this->bundle->original_filename;
155
+ }
156
+
157
+ return data_get($this, 'bundle.original_filename');
158
+ }
159
+
160
+ /**
161
+ * Generates a unique bundle ID.
162
+ *
163
+ * This static method constructs a unique bundle identifier (ID) by appending a random string to a fixed prefix.
164
+ * The prefix used is 'EXTBNDL', and the total length of the generated ID is 14 characters, including the prefix.
165
+ * The function ensures uniqueness by checking the generated ID against existing IDs in the database and
166
+ * regenerating if a duplicate is found. The characters used for the random string are upper and lower case
167
+ * alphabets and numerals. The final bundle ID is returned in upper case.
168
+ *
169
+ * @return string a unique, uppercase bundle ID
170
+ */
171
+ public static function generateUniqueBundleId()
172
+ {
173
+ do {
174
+ $prefix = 'EXTBNDL';
175
+ $remainingLength = 14 - strlen($prefix);
176
+ $characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
177
+ $result = '';
178
+
179
+ for ($i = 0; $i < $remainingLength; $i++) {
180
+ $result .= $characters[rand(0, strlen($characters) - 1)];
181
+ }
182
+
183
+ $bundleId = strtoupper($prefix . $result);
184
+ } while (self::where('bundle_id', $bundleId)->exists());
185
+
186
+ return $bundleId;
187
+ }
188
+
189
+ /**
190
+ * Extracts specified file(s) from a zipped bundle and returns their contents.
191
+ *
192
+ * This method downloads a file indicated by the File model, unzips it, and looks for
193
+ * specified filename(s) within the unzipped contents. The contents of each found file
194
+ * are returned as a stdClass object, with each property corresponding to a filename.
195
+ * If 'parse_json' option is true (default), file contents are decoded as JSON.
196
+ * Temporary files are cleaned up after extraction.
197
+ *
198
+ * @param File $bundle the File model instance representing the zipped bundle
199
+ * @param string|array $filenames a single filename or an array of filenames to extract from the bundle
200
+ * @param array $options Additional options, e.g., ['parse_json' => false] to get raw file contents.
201
+ *
202
+ * @return \stdClass|null an object containing the contents of each extracted file, or null if files are not found
203
+ */
204
+ protected static function extractBundleFile(File $bundle, $filenames = 'extension.json', $options = []): ?\stdClass
205
+ {
206
+ $shouldParseJson = data_get($options, 'parse_json', true);
207
+ $tempDir = sys_get_temp_dir() . '/' . str_replace(['.', ','], '_', uniqid('fleetbase_zip_', true));
208
+ mkdir($tempDir);
209
+
210
+ // Download the file to a local temporary directory
211
+ $tempFilePath = $tempDir . '/' . basename($bundle->path);
212
+ $contents = Storage::disk($bundle->disk)->get($bundle->path);
213
+ file_put_contents($tempFilePath, $contents);
214
+
215
+ // Extract file paths
216
+ $extractedFilePaths = static::_extractAndFindFile($tempFilePath, $tempDir, $filenames);
217
+ $result = new \stdClass();
218
+ foreach ($extractedFilePaths as $filename => $path) {
219
+ if (file_exists($path)) {
220
+ $fileContents = file_get_contents($path);
221
+ if ($shouldParseJson) {
222
+ $result->$filename = json_decode($fileContents);
223
+ } else {
224
+ $result->$filename = $fileContents;
225
+ }
226
+ }
227
+ }
228
+
229
+ // Cleanup: Delete the temporary directory
230
+ try {
231
+ array_map('unlink', glob("$tempDir/*.*"));
232
+ } catch (\Throwable $e) {
233
+ // Probably a directory ...
234
+ }
235
+ Utils::deleteDirectory($tempDir);
236
+
237
+ return $result;
238
+ }
239
+
240
+ /**
241
+ * Extracts and finds the path(s) of specified file(s) within a zipped archive.
242
+ *
243
+ * Opens the specified ZIP archive and extracts it to a temporary directory. It then
244
+ * searches for the given file(s) in both the root and the first valid subdirectory of
245
+ * the unzipped content. It returns an associative array of file paths, indexed by filenames.
246
+ * Invalid directories such as '__MACOSX', '.', '..', etc., are excluded from the search.
247
+ *
248
+ * @param string $zipFilePath path to the ZIP archive file
249
+ * @param string $tempDir temporary directory where the ZIP file is extracted
250
+ * @param string|array $targetFiles a single filename or an array of filenames to search for within the archive
251
+ *
252
+ * @return array associative array of paths for the requested files within the archive
253
+ */
254
+ private static function _extractAndFindFile($zipFilePath, $tempDir, $targetFiles)
255
+ {
256
+ $paths = [];
257
+ $zip = new \ZipArchive();
258
+
259
+ if ($zip->open($zipFilePath) === true) {
260
+ $zip->extractTo($tempDir);
261
+ $zip->close();
262
+
263
+ foreach ((array) $targetFiles as $targetFile) {
264
+ // Direct check in the tempDir
265
+ $directPath = $tempDir . '/' . $targetFile;
266
+ if (file_exists($directPath)) {
267
+ $paths[$targetFile] = $directPath;
268
+ continue;
269
+ }
270
+
271
+ // Check in the first subdirectory
272
+ $files = scandir($tempDir);
273
+ foreach ($files as $file) {
274
+ $invalidDirectories = ['__MACOSX', '.', '..', 'DS_Store', '.DS_Store', '.idea', '.vscode'];
275
+ if (!Str::startsWith($file, ['.', '_']) && !in_array($file, $invalidDirectories) && is_dir($tempDir . '/' . $file)) {
276
+ $nestedPath = $tempDir . '/' . $file . '/' . $targetFile;
277
+ if (file_exists($nestedPath)) {
278
+ $paths[$targetFile] = $nestedPath;
279
+ break;
280
+ }
281
+ }
282
+ }
283
+ }
284
+ }
285
+
286
+ return $paths;
287
+ }
288
+
289
+ /**
290
+ * Extracts multiple configuration files from the bundle.
291
+ *
292
+ * This method leverages the extractBundleFile function to extract an array
293
+ * of specified configuration files ('extension.json', 'composer.json', 'package.json')
294
+ * from the bundle. It returns an object containing the contents of each file,
295
+ * where each property name corresponds to a filename. If a specified file is not found
296
+ * in the bundle, its corresponding property will be absent in the returned object.
297
+ * The method is useful for retrieving multiple configuration files in a single operation.
298
+ *
299
+ * @return \stdClass|null An object containing the contents of each extracted file.
300
+ * Properties of the object correspond to the filenames.
301
+ * Returns null if the bundle property is not an instance of File.
302
+ */
303
+ public static function extractBundleData(File $bundleFile): ?\stdClass
304
+ {
305
+ return static::extractBundleFile($bundleFile, ['extension.json', 'composer.json', 'package.json']);
306
+ }
307
+
308
+ /**
309
+ * Extracts multiple configuration files from the bundle.
310
+ *
311
+ * This method leverages the extractBundleFile function to extract an array
312
+ * of specified configuration files ('extension.json', 'composer.json', 'package.json')
313
+ * from the bundle. It returns an object containing the contents of each file,
314
+ * where each property name corresponds to a filename. If a specified file is not found
315
+ * in the bundle, its corresponding property will be absent in the returned object.
316
+ * The method is useful for retrieving multiple configuration files in a single operation.
317
+ *
318
+ * @return \stdClass|null An object containing the contents of each extracted file.
319
+ * Properties of the object correspond to the filenames.
320
+ * Returns null if the bundle property is not an instance of File.
321
+ */
322
+ public function extractExtensionData()
323
+ {
324
+ if ($this->bundle instanceof File) {
325
+ return static::extractBundleData($this->bundle);
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Extracts 'extension.json' from the bundle file.
331
+ *
332
+ * This method is a specific implementation of extractBundleFile for extracting
333
+ * 'extension.json'. It checks if the 'bundle' property is an instance of File
334
+ * and invokes the extraction process.
335
+ *
336
+ * @return \stdClass|null The decoded JSON object from 'extension.json', or null if not found.
337
+ */
338
+ public function extractExtensionJson(): ?\stdClass
339
+ {
340
+ $filename = 'extension.json';
341
+ if ($this->bundle instanceof File) {
342
+ $data = static::extractBundleFile($this->bundle);
343
+
344
+ return $data[$filename];
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Extracts 'composer.json' from the bundle file.
350
+ *
351
+ * This method is a specific implementation of extractBundleFile for extracting
352
+ * 'composer.json'. It checks if the 'bundle' property is an instance of File
353
+ * and invokes the extraction process.
354
+ *
355
+ * @return \stdClass|null The decoded JSON object from 'composer.json', or null if not found.
356
+ */
357
+ public function extractComposerJson(): ?\stdClass
358
+ {
359
+ $filename = 'composer.json';
360
+ if ($this->bundle instanceof File) {
361
+ $data = static::extractBundleFile($this->bundle, $filename);
362
+
363
+ return $data[$filename];
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Extracts 'package.json' from the bundle file.
369
+ *
370
+ * This method is a specific implementation of extractBundleFile for extracting
371
+ * 'package.json'. It checks if the 'bundle' property is an instance of File
372
+ * and invokes the extraction process.
373
+ *
374
+ * @return \stdClass|null The decoded JSON object from 'package.json', or null if not found.
375
+ */
376
+ public function extractPackageJson(): ?\stdClass
377
+ {
378
+ $filename = 'package.json';
379
+ if ($this->bundle instanceof File) {
380
+ $data = static::extractBundleFile($this->bundle, $filename);
381
+
382
+ return $data[$filename];
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Installs a specific server package using Composer based on provided metadata.
388
+ *
389
+ * This method manages the installation of a server-side package specified in the composer.json metadata.
390
+ * It executes a Composer command to install the package, monitors the output for progress, and broadcasts
391
+ * these updates in real-time to a WebSocket channel.
392
+ *
393
+ * @throws InstallFailedException if the Composer package installation fails, with a user-friendly message
394
+ */
395
+ public function installComposerPackage(): void
396
+ {
397
+ if (!is_array($this->meta) || !isset($this->meta['composer.json'])) {
398
+ return;
399
+ }
400
+
401
+ $composerJson = $this->meta['composer.json'];
402
+ if (!$composerJson) {
403
+ return;
404
+ }
405
+
406
+ // Prepare for install
407
+ $output = '';
408
+ $installChannel = implode('.', ['install', $this->company_uuid, $this->extension_uuid]);
409
+ $packageName = data_get($composerJson, 'name');
410
+ $version = data_get($composerJson, 'version');
411
+ $composerCommand = [
412
+ 'composer',
413
+ 'require',
414
+ $packageName . ($version === 'latest' ? '' : ':' . $version),
415
+ ];
416
+
417
+ // Create process
418
+ $process = new Process($composerCommand);
419
+ $process->setWorkingDirectory('/fleetbase/api');
420
+ $process->setTimeout(3600 * 2);
421
+
422
+ // Run process
423
+ $process->run(function ($type, $buffer) use (&$output, $installChannel) {
424
+ $output .= $buffer;
425
+ $lines = explode("\n", $buffer);
426
+ foreach ($lines as $line) {
427
+ if (trim($line) === '') {
428
+ continue;
429
+ }
430
+ $progress = static::composerInstallOutputProgress($line);
431
+ if ($progress > 0) {
432
+ SocketClusterService::publish($installChannel, [
433
+ 'process' => 'install',
434
+ 'step' => 'server.install',
435
+ 'progress' => $progress,
436
+ ]);
437
+ }
438
+ }
439
+ });
440
+
441
+ if (!$process->isSuccessful()) {
442
+ $friendlyMessage = static::composerOutputFriendly($output);
443
+ throw new InstallFailedException($friendlyMessage);
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Uninstalls a specific server package using Composer based on provided metadata.
449
+ *
450
+ * This function handles the uninstallation of a server-side package as defined in the composer.json metadata.
451
+ * It runs a Composer remove command, processes the output to track uninstallation progress, and publishes
452
+ * these updates through a WebSocket channel.
453
+ *
454
+ * @throws InstallFailedException if the Composer package uninstallation fails, providing a user-friendly message
455
+ */
456
+ public function uninstallComposerPackage(): void
457
+ {
458
+ if (!is_array($this->meta) || !isset($this->meta['composer.json'])) {
459
+ return;
460
+ }
461
+
462
+ $composerJson = $this->meta['composer.json'];
463
+ if (!$composerJson) {
464
+ return;
465
+ }
466
+
467
+ // Prepare for uninstall
468
+ $output = '';
469
+ $uninstallChannel = implode('.', ['uninstall', $this->company_uuid, $this->extension_uuid]);
470
+ $packageName = data_get($composerJson, 'name');
471
+ $composerCommand = [
472
+ 'composer',
473
+ 'remove',
474
+ $packageName,
475
+ ];
476
+
477
+ // Create process
478
+ $process = new Process($composerCommand);
479
+ $process->setWorkingDirectory('/fleetbase/api');
480
+ $process->setTimeout(3600 * 2);
481
+
482
+ // Run process
483
+ $process->run(function ($type, $buffer) use (&$output, $uninstallChannel) {
484
+ $output .= $buffer;
485
+ $lines = explode("\n", $buffer);
486
+ foreach ($lines as $line) {
487
+ if (trim($line) === '') {
488
+ continue;
489
+ }
490
+ $progress = static::composerUninstallOutputProgress($line);
491
+ if ($progress > 0) {
492
+ SocketClusterService::publish($uninstallChannel, [
493
+ 'process' => 'uninstall',
494
+ 'step' => 'server.uninstall',
495
+ 'progress' => $progress,
496
+ ]);
497
+ }
498
+ }
499
+ });
500
+
501
+ if (!$process->isSuccessful()) {
502
+ $friendlyMessage = static::composerOutputFriendly($output);
503
+ throw new InstallFailedException($friendlyMessage);
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Installs a specific engine package defined in the metadata using PNPM.
509
+ *
510
+ * This method is responsible for installing an engine package based on the package metadata provided.
511
+ * It creates and runs a PNPM installation process, monitors its output for progress, and sends real-time
512
+ * updates via a WebSocket channel.
513
+ *
514
+ * @throws \Exception if the engine installation process fails
515
+ */
516
+ public function installEnginePackage(): void
517
+ {
518
+ if (!is_array($this->meta) || !isset($this->meta['package.json'])) {
519
+ return;
520
+ }
521
+
522
+ $packageJson = $this->meta['package.json'];
523
+ if (!$packageJson) {
524
+ return;
525
+ }
526
+
527
+ // Prepare for install
528
+ $output = '';
529
+ $installChannel = implode('.', ['install', $this->company_uuid, $this->extension_uuid]);
530
+ $packageName = data_get($packageJson, 'name');
531
+ $version = data_get($packageJson, 'version');
532
+ $installCommand = [
533
+ 'pnpm',
534
+ 'add',
535
+ $packageName . ($version === 'latest' ? '' : '@' . $version),
536
+ ];
537
+
538
+ // Create process
539
+ $process = new Process($installCommand);
540
+ $process->setWorkingDirectory(config('fleetbase.console.path'));
541
+ $process->setTimeout(3600 * 2);
542
+
543
+ // Run process
544
+ $process->run(function ($type, $buffer) use (&$output, $installChannel) {
545
+ $output .= $buffer;
546
+ $lines = explode("\n", $buffer);
547
+ foreach ($lines as $line) {
548
+ if (trim($line) === '') {
549
+ continue;
550
+ }
551
+ $progress = static::pnpmInstallOutputProgress($line);
552
+ if ($progress > 0) {
553
+ SocketClusterService::publish($installChannel, [
554
+ 'process' => 'install',
555
+ 'step' => 'engine.install',
556
+ 'progress' => $progress,
557
+ ]);
558
+ }
559
+ }
560
+ });
561
+
562
+ if (!$process->isSuccessful()) {
563
+ throw new \Exception('Engine install failed!');
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Uninstalls a specific engine package defined in the metadata using PNPM.
569
+ *
570
+ * This function initiates the uninstallation of an engine package using PNPM, based on the metadata provided.
571
+ * It captures and interprets the output of the uninstall command to provide real-time progress updates through
572
+ * a WebSocket channel.
573
+ *
574
+ * @throws \Exception if the engine uninstallation process fails
575
+ */
576
+ public function uninstallEnginePackage(): void
577
+ {
578
+ if (!is_array($this->meta) || !isset($this->meta['package.json'])) {
579
+ return;
580
+ }
581
+
582
+ $packageJson = $this->meta['package.json'];
583
+ if (!$packageJson) {
584
+ return;
585
+ }
586
+
587
+ // Prepare for uninstall
588
+ $output = '';
589
+ $uninstallChannel = implode('.', ['uninstall', $this->company_uuid, $this->extension_uuid]);
590
+ $packageName = data_get($packageJson, 'name');
591
+ $installCommand = [
592
+ 'pnpm',
593
+ 'remove',
594
+ $packageName,
595
+ ];
596
+
597
+ // Create process
598
+ $process = new Process($installCommand);
599
+ $process->setWorkingDirectory(config('fleetbase.console.path'));
600
+ $process->setTimeout(3600 * 2);
601
+
602
+ // Run process
603
+ $process->run(function ($type, $buffer) use (&$output, $uninstallChannel) {
604
+ $output .= $buffer;
605
+ $lines = explode("\n", $buffer);
606
+ foreach ($lines as $line) {
607
+ if (trim($line) === '') {
608
+ continue;
609
+ }
610
+ $progress = static::pnpmUninstallOutputProgress($line);
611
+ if ($progress > 0) {
612
+ SocketClusterService::publish($uninstallChannel, [
613
+ 'process' => 'uninstall',
614
+ 'step' => 'engine.uninstall',
615
+ 'progress' => $progress,
616
+ ]);
617
+ }
618
+ }
619
+ });
620
+
621
+ if (!$process->isSuccessful()) {
622
+ throw new \Exception('Engine uninstall failed!');
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Initiates and manages the build process of the Fleetbase console using PNPM.
628
+ *
629
+ * This method prepares and executes the build process for the Fleetbase console. It monitors the build
630
+ * output to provide real-time updates via a WebSocket channel. It captures and interprets the output to
631
+ * estimate the progress, which is then published to a designated channel for frontend display.
632
+ *
633
+ * @throws \Exception if the build process fails
634
+ */
635
+ public function buildConsole()
636
+ {
637
+ // Prepare to build/rebuild console
638
+ $output = '';
639
+ $buildChannel = implode('.', ['install', $this->company_uuid, $this->extension_uuid]);
640
+ $buildCommand = [
641
+ 'pnpm',
642
+ 'build',
643
+ '--environment',
644
+ config('app.env'),
645
+ ];
646
+
647
+ // Create process
648
+ $process = new Process($buildCommand);
649
+ $process->setWorkingDirectory(config('fleetbase.console.path'));
650
+ $process->setTimeout(3600 * 2);
651
+
652
+ // Run process
653
+ $process->run(function ($type, $buffer) use (&$output, $buildChannel) {
654
+ $output .= $buffer;
655
+ $lines = explode("\n", $buffer);
656
+ foreach ($lines as $line) {
657
+ if (trim($line) === '') {
658
+ continue;
659
+ }
660
+ $progress = static::pnpmBuildOutputProgress($line);
661
+ if ($progress > 0) {
662
+ SocketClusterService::publish($buildChannel, [
663
+ 'process' => 'build',
664
+ 'step' => 'console.build',
665
+ 'progress' => $progress,
666
+ ]);
667
+ }
668
+ }
669
+ });
670
+
671
+ if (!$process->isSuccessful()) {
672
+ throw new \Exception('Console build failed!');
673
+ }
674
+ }
675
+
676
+ public function runInstallerProgress(): void
677
+ {
678
+ $channel = implode('.', ['install', $this->company_uuid, $this->extension_uuid]);
679
+ $steps = ['api.install', 'engine.install', 'console.build'];
680
+
681
+ foreach ($steps as $step) {
682
+ $run = range(1, 100);
683
+ foreach ($run as $progress) {
684
+ SocketClusterService::publish($channel, [
685
+ 'process' => 'install',
686
+ 'step' => $step,
687
+ 'progress' => $progress,
688
+ ]);
689
+
690
+ // minimal latency
691
+ usleep(500 * rand(2, 4));
692
+ }
693
+ }
694
+ }
695
+
696
+ public function runUninstallerProgress(): void
697
+ {
698
+ $channel = implode('.', ['uninstall', $this->company_uuid, $this->extension_uuid]);
699
+ $steps = ['api.uninstall', 'engine.uninstall', 'console.build'];
700
+
701
+ foreach ($steps as $step) {
702
+ $run = range(1, 100);
703
+ foreach ($run as $progress) {
704
+ SocketClusterService::publish($channel, [
705
+ 'process' => 'uninstall',
706
+ 'step' => $step,
707
+ 'progress' => $progress,
708
+ ]);
709
+
710
+ // minimal latency
711
+ usleep(500 * rand(2, 4));
712
+ }
713
+ }
714
+ }
715
+
716
+ /**
717
+ * Parses Composer output to provide a user-friendly message.
718
+ *
719
+ * This method checks the Composer output for specific keywords or phrases
720
+ * and returns a simplified, more understandable message suitable for end-users.
721
+ *
722
+ * @param string $output the raw output from Composer
723
+ *
724
+ * @return string a user-friendly interpretation of the output
725
+ */
726
+ public static function composerOutputFriendly($output): string
727
+ {
728
+ // Check for successful install/update
729
+ if (strpos($output, 'Generating optimized autoload files') !== false) {
730
+ return 'Installation successful. Packages have been updated.';
731
+ }
732
+
733
+ // Check for 'Nothing to install, update or remove'
734
+ if (strpos($output, 'Nothing to install, update or remove') !== false) {
735
+ return 'No changes made. Everything is already up-to-date.';
736
+ }
737
+
738
+ // Check for 'Package is abandoned'
739
+ if (preg_match_all('/Package (.+) is abandoned/', $output, $matches)) {
740
+ $abandonedPackages = implode(', ', $matches[1]);
741
+
742
+ return "Warning: The following packages are abandoned and should be replaced: $abandonedPackages.";
743
+ }
744
+
745
+ // Check for dependency issues
746
+ if (preg_match('/Problem (\d+)/', $output, $matches)) {
747
+ return 'Unable to install due to dependency compatibility issues.';
748
+ }
749
+
750
+ // If no known patterns are found, return a generic message or the original output
751
+ return 'An error occurred during installation. Please check the log for details.';
752
+ }
753
+
754
+ /**
755
+ * Estimates the progress of a Composer installation process.
756
+ *
757
+ * This method interprets the Composer output to estimate the progress of the installation.
758
+ * It assigns a progress percentage based on identified keywords or phrases in the output.
759
+ *
760
+ * @param string $output the raw output from Composer
761
+ *
762
+ * @return int an estimated progress percentage
763
+ */
764
+ public static function composerInstallOutputProgress($output): int
765
+ {
766
+ // Trim the output to remove unnecessary whitespace
767
+ $output = trim($output);
768
+
769
+ // Initial phase, updating composer.json and resolving dependencies
770
+ if (strpos($output, 'Running composer update') !== false) {
771
+ return 10;
772
+ }
773
+ // Dependencies are being updated
774
+ elseif (strpos($output, 'Loading composer repositories with package information') !== false) {
775
+ return 20;
776
+ }
777
+ // Dependencies updating step
778
+ elseif (strpos($output, 'Updating dependencies') !== false) {
779
+ return 30;
780
+ }
781
+ // Lock file is being written
782
+ elseif (strpos($output, 'Lock file operations') !== false) {
783
+ return 40;
784
+ }
785
+ // Lock file writing in progress
786
+ elseif (strpos($output, 'Writing lock file') !== false) {
787
+ return 50;
788
+ }
789
+ // Installing dependencies
790
+ elseif (strpos($output, 'Installing dependencies from lock file') !== false) {
791
+ return 60;
792
+ }
793
+ // Package operations, installation begins
794
+ elseif (strpos($output, 'Package operations:') !== false) {
795
+ return 70;
796
+ }
797
+ // Downloading a specific package
798
+ elseif (strpos($output, '- Downloading') !== false) {
799
+ return 75;
800
+ }
801
+ // Extracting archive for a package
802
+ elseif (strpos($output, '- Installing') !== false) {
803
+ return 80;
804
+ }
805
+ // Autoload files are generated, nearing completion
806
+ elseif (strpos($output, 'Generating optimized autoload files') !== false) {
807
+ return 90;
808
+ }
809
+ // Final steps, package discovery and publishing assets
810
+ elseif (strpos($output, 'postAutoloadDump') !== false
811
+ || strpos($output, '@php artisan package:discover --ansi') !== false
812
+ || strpos($output, '@php artisan vendor:publish') !== false) {
813
+ return 95;
814
+ }
815
+ // Completion messages
816
+ elseif (strpos($output, 'No security vulnerability advisories found') !== false) {
817
+ return 100;
818
+ }
819
+
820
+ // Default progress if no known phrases are matched
821
+ return 0;
822
+ }
823
+
824
+ /**
825
+ * Parses the output of the composer uninstall process to determine progress.
826
+ *
827
+ * This function analyzes the output of the `composer remove` command and returns a progress percentage
828
+ * based on the stage of the process. It helps in providing real-time progress updates during the
829
+ * uninstallation of a Composer package.
830
+ *
831
+ * @param string $output the output from the composer uninstall process
832
+ *
833
+ * @return int the progress percentage
834
+ */
835
+ public static function composerUninstallOutputProgress($output): int
836
+ {
837
+ // Trim the output to remove unnecessary whitespace
838
+ $output = trim($output);
839
+
840
+ // Initial phase, updating composer.json and resolving dependencies
841
+ if (strpos($output, 'Running composer update') !== false) {
842
+ return 10;
843
+ }
844
+ // Loading repositories
845
+ elseif (strpos($output, 'Loading composer repositories with package information') !== false) {
846
+ return 20;
847
+ }
848
+ // Dependencies are being updated
849
+ elseif (strpos($output, 'Updating dependencies') !== false) {
850
+ return 30;
851
+ }
852
+ // Lock file operations are defined
853
+ elseif (strpos($output, 'Lock file operations') !== false) {
854
+ return 40;
855
+ }
856
+ // Lock file writing in progress
857
+ elseif (strpos($output, 'Writing lock file') !== false) {
858
+ return 50;
859
+ }
860
+ // Installing dependencies from lock file
861
+ elseif (strpos($output, 'Installing dependencies from lock file') !== false) {
862
+ return 60;
863
+ }
864
+ // Package operations, removal begins
865
+ elseif (strpos($output, 'Package operations:') !== false) {
866
+ return 70;
867
+ }
868
+ // Removing a specific package
869
+ elseif (strpos($output, '- Removing') !== false) {
870
+ return 80;
871
+ }
872
+ // Generating autoload files, nearing completion
873
+ elseif (strpos($output, 'Generating optimized autoload files') !== false) {
874
+ return 90;
875
+ }
876
+ // Final steps, package discovery and publishing assets
877
+ elseif (strpos($output, 'postAutoloadDump') !== false
878
+ || strpos($output, '@php artisan package:discover --ansi') !== false
879
+ || strpos($output, '@php artisan vendor:publish') !== false) {
880
+ return 95;
881
+ }
882
+ // Completion messages
883
+ elseif (strpos($output, 'No security vulnerability advisories found') !== false) {
884
+ return 100;
885
+ }
886
+
887
+ // Default progress if no known phrases are matched
888
+ return 0;
889
+ }
890
+
891
+ /**
892
+ * Estimates the progress of a PNPM installation process.
893
+ *
894
+ * This method interprets the PNPM output to estimate the progress of the installation based on specific keywords and phrases found in the output. It assigns a progress percentage based on identified phases of the install process, such as resolving dependencies and writing lock files.
895
+ *
896
+ * @param string $output the raw output from the PNPM install command
897
+ *
898
+ * @return int An estimated progress percentage. This is an integer between 0 and 100 where 0 means just started, and 100 means complete.
899
+ */
900
+ public static function pnpmInstallOutputProgress($output): int
901
+ {
902
+ $output = trim($output);
903
+
904
+ // Check if the output indicates starting phase
905
+ if (strpos($output, 'Packages: +1') !== false) {
906
+ return 10;
907
+ }
908
+ // Check for resolved packages progress
909
+ elseif (preg_match('/Progress: resolved (\d+),/', $output, $matches)) {
910
+ $resolved = (int) $matches[1];
911
+ if ($resolved < 500) {
912
+ return 20;
913
+ } elseif ($resolved < 1000) {
914
+ return 40;
915
+ } elseif ($resolved < 1500) {
916
+ return 60;
917
+ } else {
918
+ return 80;
919
+ }
920
+ }
921
+ // Check for final steps
922
+ elseif (strpos($output, 'dependencies:') !== false) {
923
+ return 90;
924
+ }
925
+ // Completion message
926
+ elseif (strpos($output, 'Done in') !== false) {
927
+ return 100;
928
+ }
929
+
930
+ return 0;
931
+ }
932
+
933
+ /**
934
+ * Estimates the progress of a PNPM uninstallation process.
935
+ *
936
+ * This method analyzes the output from the PNPM uninstall command to estimate the progress of the package removal. It uses specific indicators within the output to assign a progress percentage, such as the stages of resolving dependencies, removing packages, and cleaning up.
937
+ *
938
+ * @param string $output the raw output from the PNPM uninstall command
939
+ *
940
+ * @return int An estimated progress percentage. This value is an integer between 0 and 100, where 0 indicates the beginning of the uninstall process, and 100 indicates completion.
941
+ */
942
+ public static function pnpmUninstallOutputProgress($output): int
943
+ {
944
+ $output = trim($output);
945
+
946
+ // Check for the uninstall initiation
947
+ if (strpos($output, 'Packages: -1') !== false) {
948
+ return 10;
949
+ }
950
+ // Check for resolved packages progress
951
+ elseif (preg_match('/Progress: resolved (\d+),/', $output, $matches)) {
952
+ $resolved = (int) $matches[1];
953
+ if ($resolved < 500) {
954
+ return 20;
955
+ } elseif ($resolved < 1000) {
956
+ return 40;
957
+ } elseif ($resolved < 1500) {
958
+ return 60;
959
+ } else {
960
+ return 80;
961
+ }
962
+ }
963
+ // Check for final steps
964
+ elseif (strpos($output, 'dependencies:') !== false) {
965
+ return 90;
966
+ }
967
+ // Completion message
968
+ elseif (strpos($output, 'Done in') !== false) {
969
+ return 100;
970
+ }
971
+
972
+ return 0;
973
+ }
974
+
975
+ /**
976
+ * Estimates the progress of a PNPM Ember build process.
977
+ *
978
+ * This function interprets the output from the 'pnpm build' command, specifically tailored for an Ember project build,
979
+ * to estimate the build's progress. The function identifies specific phases of the build process and assigns a
980
+ * progress percentage based on these phases.
981
+ *
982
+ * @param string $output the raw output from the PNPM build command
983
+ *
984
+ * @return int An estimated progress percentage. This is an integer between 0 and 100, where 0 means just started,
985
+ * and 100 indicates the build is complete.
986
+ */
987
+ public static function pnpmBuildOutputProgress($output): int
988
+ {
989
+ $output = trim($output);
990
+
991
+ // Prebuild and setup phase
992
+ if (strpos($output, 'node prebuild.js') !== false) {
993
+ return 10;
994
+ }
995
+ // Initial building phase
996
+ elseif (strpos($output, '- Building') !== false) {
997
+ return 20;
998
+ }
999
+ // Middle build process, handling various transformations and optimizations
1000
+ elseif (strpos($output, 'postcss-is-pseudo-class') !== false) {
1001
+ return 50;
1002
+ }
1003
+ // Cleanup phase before completion
1004
+ elseif (strpos($output, 'cleaning up...') !== false) {
1005
+ return 80;
1006
+ }
1007
+ // Build completion message
1008
+ elseif (strpos($output, 'Built project successfully') !== false) {
1009
+ return 100;
1010
+ }
1011
+
1012
+ // Default progress if no known phrases are matched
1013
+ return 0;
1014
+ }
1015
+ }