@myvillage/cli 1.9.0 → 1.10.0

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.
@@ -0,0 +1,631 @@
1
+ import { existsSync, readFileSync, writeFileSync, statSync } from 'fs';
2
+ import { join, resolve, basename, extname } from 'path';
3
+ import chalk from 'chalk';
4
+ import axios from 'axios';
5
+ import inquirer from 'inquirer';
6
+ import pLimit from 'p-limit';
7
+ import { villageSpinner, brand } from '../utils/brand.js';
8
+ import { isAuthenticated } from '../utils/auth.js';
9
+ import {
10
+ getGameMissions,
11
+ updateGameMissions,
12
+ getGameUploadUrl,
13
+ confirmGameUpload,
14
+ getGameDetail,
15
+ updateGameMetadata,
16
+ submitGameForReview,
17
+ } from '../utils/api.js';
18
+
19
+ // ── game missions init ────────────────────────────────
20
+
21
+ export async function gameMissionsInitCommand() {
22
+ const manifestPath = resolve(process.cwd(), 'manifest.json');
23
+
24
+ if (existsSync(manifestPath)) {
25
+ console.log(chalk.yellow(' manifest.json already exists. Edit it directly or use "myvillage game missions sync" to upload.'));
26
+ return;
27
+ }
28
+
29
+ const template = {
30
+ version: '1.0',
31
+ engine: 'unity',
32
+ engineVersion: '6000.0.27f1',
33
+ platforms: ['ios', 'android'],
34
+ missions: [
35
+ {
36
+ id: 'example-mission',
37
+ title: 'Example Mission',
38
+ bundleName: 'example',
39
+ category: 'EDUCATION',
40
+ subject: 'math',
41
+ difficulty: ['easy', 'medium'],
42
+ mvtReward: 10,
43
+ unlockRequirements: [],
44
+ thumbnailPath: 'thumbnails/example.png',
45
+ },
46
+ ],
47
+ };
48
+
49
+ writeFileSync(manifestPath, JSON.stringify(template, null, 2) + '\n');
50
+
51
+ console.log(brand.green(' ✓ Created manifest.json'));
52
+ console.log();
53
+ console.log(brand.teal(' Edit the missions array, then run:'));
54
+ console.log(` ${brand.gold('myvillage game missions sync')} ${brand.teal('— Upload to ed-platform')}`);
55
+ console.log();
56
+ }
57
+
58
+ // ── game missions sync ────────────────────────────────
59
+
60
+ export async function gameMissionsSyncCommand() {
61
+ if (!isAuthenticated()) {
62
+ console.log(chalk.red(' ✗ Authentication required. Run \'myvillage login\' first.'));
63
+ return;
64
+ }
65
+
66
+ const gameId = getGameId();
67
+ if (!gameId) return;
68
+
69
+ const manifestPath = resolve(process.cwd(), 'manifest.json');
70
+ if (!existsSync(manifestPath)) {
71
+ console.log(chalk.red(' ✗ No manifest.json found. Run "myvillage game missions init" first.'));
72
+ return;
73
+ }
74
+
75
+ let manifest;
76
+ try {
77
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
78
+ } catch (err) {
79
+ console.log(chalk.red(` ✗ Invalid JSON in manifest.json: ${err.message}`));
80
+ return;
81
+ }
82
+
83
+ const spinner = villageSpinner('Uploading manifest...').start();
84
+
85
+ try {
86
+ const result = await updateGameMissions(gameId, manifest);
87
+ spinner.succeed('Manifest synced successfully!');
88
+ console.log();
89
+
90
+ const missions = result.manifest?.missions || manifest.missions || [];
91
+ console.log(brand.green(` ✓ ${missions.length} mission(s) uploaded`));
92
+
93
+ for (const m of missions) {
94
+ const reward = m.mvtReward ? ` (${m.mvtReward} MVT)` : '';
95
+ console.log(` ${brand.gold('•')} ${m.title || m.id}${brand.teal(reward)}`);
96
+ }
97
+
98
+ if (result.manifestS3Key) {
99
+ console.log();
100
+ console.log(brand.teal(` S3 key: ${result.manifestS3Key}`));
101
+ }
102
+ console.log();
103
+ } catch (err) {
104
+ const message = err.response?.data?.error || err.response?.data?.message || err.message;
105
+ spinner.fail(`Manifest sync failed: ${message}`);
106
+
107
+ if (err.response?.data?.details) {
108
+ console.log(chalk.red(' Validation errors:'));
109
+ for (const detail of err.response.data.details) {
110
+ console.log(chalk.red(` • ${detail}`));
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ // ── game missions list ────────────────────────────────
117
+
118
+ export async function gameMissionsListCommand(options) {
119
+ if (!isAuthenticated()) {
120
+ console.log(chalk.red(' ✗ Authentication required. Run \'myvillage login\' first.'));
121
+ return;
122
+ }
123
+
124
+ const gameId = getGameId();
125
+ if (!gameId) return;
126
+
127
+ const spinner = villageSpinner('Fetching missions...').start();
128
+
129
+ try {
130
+ const result = await getGameMissions(gameId);
131
+ spinner.stop();
132
+
133
+ if (!result.manifest) {
134
+ console.log(brand.teal(' No manifest uploaded yet. Run "myvillage game missions sync" first.'));
135
+ return;
136
+ }
137
+
138
+ const manifest = result.manifest;
139
+ console.log();
140
+ console.log(brand.gold(` ${manifest.engine || 'Game'} v${manifest.version || '?'}`));
141
+ console.log(brand.teal(` Platforms: ${(manifest.platforms || []).join(', ')}`));
142
+ console.log();
143
+
144
+ if (!manifest.missions || manifest.missions.length === 0) {
145
+ console.log(brand.teal(' No missions defined.'));
146
+ return;
147
+ }
148
+
149
+ for (const m of manifest.missions) {
150
+ const reward = m.mvtReward ? `${m.mvtReward} MVT` : '—';
151
+ const diff = m.difficulty ? m.difficulty.join('/') : '—';
152
+ const lock = m.unlockRequirements?.length
153
+ ? chalk.yellow(` 🔒 requires: ${m.unlockRequirements.join(', ')}`)
154
+ : '';
155
+
156
+ console.log(` ${brand.gold(m.title || m.id)}`);
157
+ console.log(` ID: ${m.id} Bundle: ${m.bundleName} Category: ${m.category}`);
158
+ console.log(` Difficulty: ${diff} Reward: ${brand.green(reward)}${lock}`);
159
+ console.log();
160
+ }
161
+
162
+ if (options?.json) {
163
+ console.log(JSON.stringify(manifest, null, 2));
164
+ }
165
+ } catch (err) {
166
+ const message = err.response?.data?.error || err.message;
167
+ spinner.fail(`Failed to fetch missions: ${message}`);
168
+ }
169
+ }
170
+
171
+ // ── game bundles upload ───────────────────────────────
172
+
173
+ export async function gameBundlesUploadCommand(filePath, options) {
174
+ if (!isAuthenticated()) {
175
+ console.log(chalk.red(' ✗ Authentication required. Run \'myvillage login\' first.'));
176
+ return;
177
+ }
178
+
179
+ const gameId = getGameId();
180
+ if (!gameId) return;
181
+
182
+ const platform = options.platform;
183
+ if (!platform) {
184
+ console.log(chalk.red(' ✗ --platform is required (ios, android, webgl)'));
185
+ return;
186
+ }
187
+
188
+ const bundleName = options.bundleName || basename(filePath).replace(/\.[^.]+$/, '');
189
+ const resolvedPath = resolve(filePath);
190
+
191
+ if (!existsSync(resolvedPath)) {
192
+ console.log(chalk.red(` ✗ File not found: ${resolvedPath}`));
193
+ return;
194
+ }
195
+
196
+ const fileSize = statSync(resolvedPath).size;
197
+ const fileName = `bundles/${platform}/${bundleName}`;
198
+ const contentType = 'application/octet-stream';
199
+
200
+ const spinner = villageSpinner(`${bundleName} (${formatBytes(fileSize)}) — requesting upload URL...`).start();
201
+
202
+ try {
203
+ // 1. Get presigned URL
204
+ const urlResult = await getGameUploadUrl(gameId, {
205
+ fileName,
206
+ assetType: 'ASSET_BUNDLE',
207
+ contentType,
208
+ fileSize,
209
+ });
210
+
211
+ // 2. Upload to S3
212
+ spinner.text = chalk.yellow(`${bundleName} — uploading to storage...`);
213
+ const fileBuffer = readFileSync(resolvedPath);
214
+
215
+ await axios.put(urlResult.uploadUrl, fileBuffer, {
216
+ headers: { 'Content-Type': contentType },
217
+ maxBodyLength: Infinity,
218
+ maxContentLength: Infinity,
219
+ onUploadProgress: (progressEvent) => {
220
+ if (progressEvent.total) {
221
+ const pct = Math.round((progressEvent.loaded / progressEvent.total) * 100);
222
+ spinner.text = chalk.yellow(`${bundleName} — ${pct}% (${formatBytes(progressEvent.loaded)} / ${formatBytes(progressEvent.total)})`);
223
+ }
224
+ },
225
+ });
226
+
227
+ // 3. Confirm
228
+ spinner.text = chalk.yellow(`${bundleName} — confirming...`);
229
+ await confirmGameUpload(gameId, {
230
+ s3Key: urlResult.s3Key,
231
+ fileName,
232
+ assetType: 'ASSET_BUNDLE',
233
+ contentType,
234
+ });
235
+
236
+ spinner.succeed(`${bundleName} (${formatBytes(fileSize)}) — uploaded for ${platform}`);
237
+ } catch (err) {
238
+ const message = err.response?.data?.error || err.message;
239
+ spinner.fail(`${bundleName} — upload failed: ${message}`);
240
+ }
241
+ }
242
+
243
+ // ── game bundles upload-dir ───────────────────────────
244
+
245
+ export async function gameBundlesUploadDirCommand(dirPath, options) {
246
+ if (!isAuthenticated()) {
247
+ console.log(chalk.red(' ✗ Authentication required. Run \'myvillage login\' first.'));
248
+ return;
249
+ }
250
+
251
+ const gameId = getGameId();
252
+ if (!gameId) return;
253
+
254
+ const platform = options.platform;
255
+ if (!platform) {
256
+ console.log(chalk.red(' ✗ --platform is required (ios, android, webgl)'));
257
+ return;
258
+ }
259
+
260
+ const resolvedDir = resolve(dirPath);
261
+ if (!existsSync(resolvedDir)) {
262
+ console.log(chalk.red(` ✗ Directory not found: ${resolvedDir}`));
263
+ return;
264
+ }
265
+
266
+ // Collect all files in directory
267
+ const { readdirSync } = await import('fs');
268
+ const entries = readdirSync(resolvedDir, { withFileTypes: true });
269
+ const files = entries
270
+ .filter((e) => e.isFile())
271
+ .map((e) => ({
272
+ path: join(resolvedDir, e.name),
273
+ bundleName: e.name,
274
+ }));
275
+
276
+ if (files.length === 0) {
277
+ console.log(chalk.yellow(` No files found in ${dirPath}`));
278
+ return;
279
+ }
280
+
281
+ console.log(brand.teal(` Uploading ${files.length} bundle(s) for ${platform}...\n`));
282
+
283
+ const limit = pLimit(3);
284
+ const tasks = files.map((file) =>
285
+ limit(async () => {
286
+ const fileSize = statSync(file.path).size;
287
+ const fileName = `bundles/${platform}/${file.bundleName}`;
288
+ const contentType = 'application/octet-stream';
289
+ const spinner = villageSpinner(`${file.bundleName} (${formatBytes(fileSize)})...`).start();
290
+
291
+ try {
292
+ const urlResult = await getGameUploadUrl(gameId, {
293
+ fileName,
294
+ assetType: 'ASSET_BUNDLE',
295
+ contentType,
296
+ fileSize,
297
+ });
298
+
299
+ const fileBuffer = readFileSync(file.path);
300
+ await axios.put(urlResult.uploadUrl, fileBuffer, {
301
+ headers: { 'Content-Type': contentType },
302
+ maxBodyLength: Infinity,
303
+ maxContentLength: Infinity,
304
+ onUploadProgress: (progressEvent) => {
305
+ if (progressEvent.total) {
306
+ const pct = Math.round((progressEvent.loaded / progressEvent.total) * 100);
307
+ spinner.text = chalk.yellow(`${file.bundleName} — ${pct}%`);
308
+ }
309
+ },
310
+ });
311
+
312
+ await confirmGameUpload(gameId, {
313
+ s3Key: urlResult.s3Key,
314
+ fileName,
315
+ assetType: 'ASSET_BUNDLE',
316
+ contentType,
317
+ });
318
+
319
+ spinner.succeed(`${file.bundleName} (${formatBytes(fileSize)})`);
320
+ } catch (err) {
321
+ const message = err.response?.data?.error || err.message;
322
+ spinner.fail(`${file.bundleName} — failed: ${message}`);
323
+ }
324
+ })
325
+ );
326
+
327
+ await Promise.all(tasks);
328
+ console.log();
329
+ }
330
+
331
+ // ── game update ─────────────────────────────────────
332
+
333
+ export async function gameUpdateCommand(options) {
334
+ if (!isAuthenticated()) {
335
+ console.log(chalk.red(' ✗ Authentication required. Run \'myvillage login\' first.'));
336
+ return;
337
+ }
338
+
339
+ const gameId = getGameId();
340
+ if (!gameId) return;
341
+
342
+ // Fetch current game details to show current values
343
+ const spinner = villageSpinner('Fetching current game info...').start();
344
+ let game;
345
+ try {
346
+ const result = await getGameDetail(gameId);
347
+ game = result.game;
348
+ spinner.stop();
349
+ } catch (err) {
350
+ const message = err.response?.data?.error || err.message;
351
+ spinner.fail(`Failed to fetch game: ${message}`);
352
+ return;
353
+ }
354
+
355
+ console.log();
356
+ console.log(brand.gold(` Updating: ${game.title}`));
357
+ console.log(brand.teal(` Status: ${game.status} | Slug: ${game.slug}`));
358
+ console.log();
359
+ console.log(brand.teal(' Press Enter to keep current values.'));
360
+ console.log();
361
+
362
+ const CATEGORIES = ['EDUCATION', 'ACTION', 'ADVENTURE', 'PUZZLE', 'SIMULATION', 'STRATEGY', 'OTHER'];
363
+
364
+ const answers = await inquirer.prompt([
365
+ {
366
+ type: 'input',
367
+ name: 'title',
368
+ message: 'Title:',
369
+ default: game.title,
370
+ },
371
+ {
372
+ type: 'input',
373
+ name: 'description',
374
+ message: 'Description:',
375
+ default: game.description || '',
376
+ },
377
+ {
378
+ type: 'list',
379
+ name: 'category',
380
+ message: 'Category:',
381
+ choices: CATEGORIES,
382
+ default: game.category || 'OTHER',
383
+ },
384
+ {
385
+ type: 'input',
386
+ name: 'targetAge',
387
+ message: 'Target age:',
388
+ default: game.targetAge || '',
389
+ },
390
+ {
391
+ type: 'input',
392
+ name: 'tags',
393
+ message: 'Tags (comma-separated):',
394
+ default: (game.tags || []).join(', '),
395
+ },
396
+ ]);
397
+
398
+ // Build update payload — only send fields that changed
399
+ const updates = {};
400
+ if (answers.title !== game.title) updates.title = answers.title;
401
+ if (answers.description !== (game.description || '')) updates.description = answers.description;
402
+ if (answers.category !== (game.category || 'OTHER')) updates.category = answers.category;
403
+ if (answers.targetAge !== (game.targetAge || '')) updates.targetAge = answers.targetAge;
404
+
405
+ const newTags = answers.tags
406
+ ? answers.tags.split(',').map((t) => t.trim()).filter(Boolean)
407
+ : [];
408
+ const oldTags = game.tags || [];
409
+ if (JSON.stringify(newTags) !== JSON.stringify(oldTags)) updates.tags = newTags;
410
+
411
+ if (Object.keys(updates).length === 0) {
412
+ console.log(brand.teal('\n No changes made.\n'));
413
+ return;
414
+ }
415
+
416
+ const updateSpinner = villageSpinner('Saving changes...').start();
417
+
418
+ try {
419
+ const result = await updateGameMetadata(gameId, updates);
420
+ updateSpinner.succeed('Game updated!');
421
+ console.log();
422
+ for (const [key, val] of Object.entries(updates)) {
423
+ const display = Array.isArray(val) ? val.join(', ') : val;
424
+ console.log(` ${brand.gold(key)}: ${display}`);
425
+ }
426
+ console.log();
427
+ } catch (err) {
428
+ const message = err.response?.data?.error || err.message;
429
+ updateSpinner.fail(`Update failed: ${message}`);
430
+ }
431
+ }
432
+
433
+ // ── game upload-thumbnail ───────────────────────────
434
+
435
+ export async function gameUploadThumbnailCommand(filePath) {
436
+ if (!isAuthenticated()) {
437
+ console.log(chalk.red(' ✗ Authentication required. Run \'myvillage login\' first.'));
438
+ return;
439
+ }
440
+
441
+ const gameId = getGameId();
442
+ if (!gameId) return;
443
+
444
+ const resolvedPath = resolve(filePath);
445
+
446
+ if (!existsSync(resolvedPath)) {
447
+ console.log(chalk.red(` ✗ File not found: ${resolvedPath}`));
448
+ return;
449
+ }
450
+
451
+ const ext = extname(resolvedPath).toLowerCase();
452
+ const allowedExts = ['.png', '.jpg', '.jpeg', '.webp', '.gif'];
453
+ if (!allowedExts.includes(ext)) {
454
+ console.log(chalk.red(` ✗ Unsupported image format "${ext}". Use: ${allowedExts.join(', ')}`));
455
+ return;
456
+ }
457
+
458
+ const contentTypes = {
459
+ '.png': 'image/png',
460
+ '.jpg': 'image/jpeg',
461
+ '.jpeg': 'image/jpeg',
462
+ '.webp': 'image/webp',
463
+ '.gif': 'image/gif',
464
+ };
465
+ const contentType = contentTypes[ext];
466
+ const fileSize = statSync(resolvedPath).size;
467
+ const fileName = `thumbnail${ext}`;
468
+
469
+ const spinner = villageSpinner(`Uploading thumbnail (${formatBytes(fileSize)})...`).start();
470
+
471
+ try {
472
+ // 1. Get presigned URL
473
+ const urlResult = await getGameUploadUrl(gameId, {
474
+ fileName,
475
+ assetType: 'THUMBNAIL',
476
+ contentType,
477
+ fileSize,
478
+ });
479
+
480
+ // 2. Upload to S3
481
+ spinner.text = chalk.yellow('Uploading to storage...');
482
+ const fileBuffer = readFileSync(resolvedPath);
483
+
484
+ await axios.put(urlResult.uploadUrl, fileBuffer, {
485
+ headers: { 'Content-Type': contentType },
486
+ maxBodyLength: Infinity,
487
+ maxContentLength: Infinity,
488
+ onUploadProgress: (progressEvent) => {
489
+ if (progressEvent.total) {
490
+ const pct = Math.round((progressEvent.loaded / progressEvent.total) * 100);
491
+ spinner.text = chalk.yellow(`Uploading thumbnail — ${pct}%`);
492
+ }
493
+ },
494
+ });
495
+
496
+ // 3. Confirm
497
+ spinner.text = chalk.yellow('Confirming upload...');
498
+ await confirmGameUpload(gameId, {
499
+ s3Key: urlResult.s3Key,
500
+ fileName,
501
+ assetType: 'THUMBNAIL',
502
+ contentType,
503
+ });
504
+
505
+ spinner.succeed('Thumbnail uploaded!');
506
+ console.log(brand.teal(` S3 key: ${urlResult.s3Key}`));
507
+ console.log();
508
+ } catch (err) {
509
+ const message = err.response?.data?.error || err.message;
510
+ spinner.fail(`Thumbnail upload failed: ${message}`);
511
+ }
512
+ }
513
+
514
+ // ── game submit ─────────────────────────────────────
515
+
516
+ export async function gameSubmitCommand() {
517
+ if (!isAuthenticated()) {
518
+ console.log(chalk.red(' ✗ Authentication required. Run \'myvillage login\' first.'));
519
+ return;
520
+ }
521
+
522
+ const gameId = getGameId();
523
+ if (!gameId) return;
524
+
525
+ // Fetch current status first
526
+ const checkSpinner = villageSpinner('Checking game status...').start();
527
+ let game;
528
+ try {
529
+ const result = await getGameDetail(gameId);
530
+ game = result.game;
531
+ checkSpinner.stop();
532
+ } catch (err) {
533
+ const message = err.response?.data?.error || err.message;
534
+ checkSpinner.fail(`Failed to fetch game: ${message}`);
535
+ return;
536
+ }
537
+
538
+ if (game.status === 'SUBMITTED') {
539
+ console.log(brand.teal(`\n "${game.title}" is already submitted for review.\n`));
540
+ return;
541
+ }
542
+
543
+ if (game.status === 'PUBLISHED') {
544
+ console.log(brand.teal(`\n "${game.title}" is already published.\n`));
545
+ return;
546
+ }
547
+
548
+ if (game.status !== 'DRAFT') {
549
+ console.log(brand.teal(`\n "${game.title}" has status "${game.status}" — only DRAFT games can be submitted.\n`));
550
+ return;
551
+ }
552
+
553
+ console.log();
554
+ console.log(brand.gold(` Submit "${game.title}" for admin review?`));
555
+ console.log(brand.teal(' An admin will review your game, set MVT rewards, and publish it.'));
556
+ console.log();
557
+
558
+ const { confirm } = await inquirer.prompt([
559
+ {
560
+ type: 'confirm',
561
+ name: 'confirm',
562
+ message: 'Submit for review?',
563
+ default: true,
564
+ },
565
+ ]);
566
+
567
+ if (!confirm) {
568
+ console.log(brand.teal('\n Cancelled.\n'));
569
+ return;
570
+ }
571
+
572
+ const spinner = villageSpinner('Submitting for review...').start();
573
+
574
+ try {
575
+ const result = await submitGameForReview(gameId);
576
+ spinner.succeed('Game submitted for review!');
577
+ console.log();
578
+ console.log(brand.green(` ✓ "${game.title}" is now awaiting admin review.`));
579
+ console.log(brand.teal(' Run "myvillage status" to check the review status.'));
580
+ console.log();
581
+ } catch (err) {
582
+ const message = err.response?.data?.error || err.message;
583
+ spinner.fail(`Submit failed: ${message}`);
584
+ }
585
+ }
586
+
587
+ // ── Helpers ───────────────────────────────────────────
588
+
589
+ function getGameId() {
590
+ // Check myvillage.json for gameId (Unity projects)
591
+ const mvPath = join(process.cwd(), 'myvillage.json');
592
+ if (existsSync(mvPath)) {
593
+ try {
594
+ const config = JSON.parse(readFileSync(mvPath, 'utf-8'));
595
+ if (config.gameId) return config.gameId;
596
+ } catch { /* ignore */ }
597
+ }
598
+
599
+ // Check package.json for gameId (Three.js projects)
600
+ const pkgPath = join(process.cwd(), 'package.json');
601
+ if (existsSync(pkgPath)) {
602
+ try {
603
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
604
+ if (pkg.myvillage?.gameId) return pkg.myvillage.gameId;
605
+ } catch { /* ignore */ }
606
+ }
607
+
608
+ // Check manifest.json for a gameId field
609
+ const manifestPath = join(process.cwd(), 'manifest.json');
610
+ if (existsSync(manifestPath)) {
611
+ try {
612
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
613
+ if (manifest.gameId) return manifest.gameId;
614
+ } catch { /* ignore */ }
615
+ }
616
+
617
+ console.log(chalk.red(' ✗ No game ID found.'));
618
+ console.log(brand.teal(' Either:'));
619
+ console.log(brand.teal(' • Run "myvillage deploy" first to register the game'));
620
+ console.log(brand.teal(' • Add "gameId" to your myvillage.json or package.json'));
621
+ console.log(brand.teal(' • Add "gameId" to your manifest.json'));
622
+ return null;
623
+ }
624
+
625
+ function formatBytes(bytes) {
626
+ if (!bytes) return 'unknown';
627
+ if (bytes < 1024) return `${bytes} B`;
628
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
629
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
630
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
631
+ }