@myvillage/cli 1.10.0 → 1.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "1.10.0",
3
+ "version": "1.10.1",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,11 +1,13 @@
1
- import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
2
- import { join, resolve, relative } from 'path';
1
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
2
+ import { join, resolve, relative, extname } from 'path';
3
3
  import { execSync } from 'child_process';
4
4
  import chalk from 'chalk';
5
5
  import ora from 'ora';
6
+ import axios from 'axios';
7
+ import inquirer from 'inquirer';
6
8
  import { villageSpinner, brand } from '../utils/brand.js';
7
9
  import { isAuthenticated } from '../utils/auth.js';
8
- import { deployGame } from '../utils/api.js';
10
+ import { deployGame, getGameUploadUrl, confirmGameUpload } from '../utils/api.js';
9
11
 
10
12
  function readPackageJson(dir) {
11
13
  const pkgPath = join(dir, 'package.json');
@@ -156,6 +158,13 @@ async function deployThreeJsGame(projectDir, pkg) {
156
158
  console.log(` Status: ${formatStatus(result.status)}`);
157
159
  if (result.slug) console.log(brand.teal(` Slug: ${result.slug}`));
158
160
  if (result.gameId) console.log(brand.teal(` Game ID: ${result.gameId}`));
161
+
162
+ // Prompt for thumbnail/banner images
163
+ const finalGameId = result.gameId || pkg.myvillage.gameId;
164
+ if (finalGameId) {
165
+ await promptForImages(finalGameId);
166
+ }
167
+
159
168
  console.log();
160
169
  console.log(brand.teal(' Run "myvillage status" to check the review status.'));
161
170
  console.log();
@@ -258,6 +267,13 @@ async function deployUnityGame(projectDir, mvConfig, options) {
258
267
  console.log(` Platform: ${platform}`);
259
268
  if (result.slug) console.log(brand.teal(` Slug: ${result.slug}`));
260
269
  if (result.gameId) console.log(brand.teal(` Game ID: ${result.gameId}`));
270
+
271
+ // Prompt for thumbnail/banner images
272
+ const finalGameId = result.gameId || mvConfig.gameId;
273
+ if (finalGameId) {
274
+ await promptForImages(finalGameId);
275
+ }
276
+
261
277
  console.log();
262
278
  console.log(brand.teal(' Run "myvillage status" to check the review status.'));
263
279
  console.log();
@@ -274,6 +290,113 @@ async function deployUnityGame(projectDir, mvConfig, options) {
274
290
  }
275
291
  }
276
292
 
293
+ // ── Image upload helpers ──
294
+
295
+ const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.webp', '.gif'];
296
+ const IMAGE_CONTENT_TYPES = {
297
+ '.png': 'image/png',
298
+ '.jpg': 'image/jpeg',
299
+ '.jpeg': 'image/jpeg',
300
+ '.webp': 'image/webp',
301
+ '.gif': 'image/gif',
302
+ };
303
+
304
+ async function uploadImageAsset(gameId, filePath, assetType) {
305
+ const resolved = resolve(filePath);
306
+ if (!existsSync(resolved)) {
307
+ console.log(chalk.red(` ✗ File not found: ${resolved}`));
308
+ return false;
309
+ }
310
+
311
+ const ext = extname(resolved).toLowerCase();
312
+ if (!IMAGE_EXTS.includes(ext)) {
313
+ console.log(chalk.red(` ✗ Unsupported format "${ext}". Use: ${IMAGE_EXTS.join(', ')}`));
314
+ return false;
315
+ }
316
+
317
+ const contentType = IMAGE_CONTENT_TYPES[ext];
318
+ const fileSize = statSync(resolved).size;
319
+ const fileName = `${assetType.toLowerCase()}${ext}`;
320
+ const label = assetType.toLowerCase();
321
+
322
+ const spinner = villageSpinner(`Uploading ${label}...`).start();
323
+
324
+ try {
325
+ const urlResult = await getGameUploadUrl(gameId, {
326
+ fileName,
327
+ assetType,
328
+ contentType,
329
+ fileSize,
330
+ });
331
+
332
+ const fileBuffer = readFileSync(resolved);
333
+ await axios.put(urlResult.uploadUrl, fileBuffer, {
334
+ headers: { 'Content-Type': contentType },
335
+ maxBodyLength: Infinity,
336
+ maxContentLength: Infinity,
337
+ });
338
+
339
+ await confirmGameUpload(gameId, {
340
+ s3Key: urlResult.s3Key,
341
+ fileName,
342
+ assetType,
343
+ contentType,
344
+ });
345
+
346
+ spinner.succeed(`${label.charAt(0).toUpperCase() + label.slice(1)} uploaded!`);
347
+ return true;
348
+ } catch (err) {
349
+ const message = err.response?.data?.error || err.message;
350
+ spinner.fail(`${label} upload failed: ${message}`);
351
+ return false;
352
+ }
353
+ }
354
+
355
+ async function promptForImages(gameId) {
356
+ console.log();
357
+ const { addThumbnail } = await inquirer.prompt([{
358
+ type: 'confirm',
359
+ name: 'addThumbnail',
360
+ message: 'Upload a thumbnail image? (shown on game cards)',
361
+ default: false,
362
+ }]);
363
+
364
+ if (addThumbnail) {
365
+ const { thumbnailPath } = await inquirer.prompt([{
366
+ type: 'input',
367
+ name: 'thumbnailPath',
368
+ message: 'Path to thumbnail image:',
369
+ validate: (input) => {
370
+ if (!input.trim()) return 'Path is required.';
371
+ if (!existsSync(resolve(input.trim()))) return 'File not found.';
372
+ return true;
373
+ },
374
+ }]);
375
+ await uploadImageAsset(gameId, thumbnailPath.trim(), 'THUMBNAIL');
376
+ }
377
+
378
+ const { addBanner } = await inquirer.prompt([{
379
+ type: 'confirm',
380
+ name: 'addBanner',
381
+ message: 'Upload a banner image? (shown on featured displays)',
382
+ default: false,
383
+ }]);
384
+
385
+ if (addBanner) {
386
+ const { bannerPath } = await inquirer.prompt([{
387
+ type: 'input',
388
+ name: 'bannerPath',
389
+ message: 'Path to banner image:',
390
+ validate: (input) => {
391
+ if (!input.trim()) return 'Path is required.';
392
+ if (!existsSync(resolve(input.trim()))) return 'File not found.';
393
+ return true;
394
+ },
395
+ }]);
396
+ await uploadImageAsset(gameId, bannerPath.trim(), 'BANNER');
397
+ }
398
+ }
399
+
277
400
  function formatStatus(status) {
278
401
  switch (status) {
279
402
  case 'SUBMITTED':
@@ -511,6 +511,87 @@ export async function gameUploadThumbnailCommand(filePath) {
511
511
  }
512
512
  }
513
513
 
514
+ // ── game upload-banner ──────────────────────────────
515
+
516
+ export async function gameUploadBannerCommand(filePath) {
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
+ const resolvedPath = resolve(filePath);
526
+
527
+ if (!existsSync(resolvedPath)) {
528
+ console.log(chalk.red(` ✗ File not found: ${resolvedPath}`));
529
+ return;
530
+ }
531
+
532
+ const ext = extname(resolvedPath).toLowerCase();
533
+ const allowedExts = ['.png', '.jpg', '.jpeg', '.webp', '.gif'];
534
+ if (!allowedExts.includes(ext)) {
535
+ console.log(chalk.red(` ✗ Unsupported image format "${ext}". Use: ${allowedExts.join(', ')}`));
536
+ return;
537
+ }
538
+
539
+ const contentTypes = {
540
+ '.png': 'image/png',
541
+ '.jpg': 'image/jpeg',
542
+ '.jpeg': 'image/jpeg',
543
+ '.webp': 'image/webp',
544
+ '.gif': 'image/gif',
545
+ };
546
+ const contentType = contentTypes[ext];
547
+ const fileSize = statSync(resolvedPath).size;
548
+ const fileName = `banner${ext}`;
549
+
550
+ const spinner = villageSpinner(`Uploading banner (${formatBytes(fileSize)})...`).start();
551
+
552
+ try {
553
+ // 1. Get presigned URL
554
+ const urlResult = await getGameUploadUrl(gameId, {
555
+ fileName,
556
+ assetType: 'BANNER',
557
+ contentType,
558
+ fileSize,
559
+ });
560
+
561
+ // 2. Upload to S3
562
+ spinner.text = chalk.yellow('Uploading to storage...');
563
+ const fileBuffer = readFileSync(resolvedPath);
564
+
565
+ await axios.put(urlResult.uploadUrl, fileBuffer, {
566
+ headers: { 'Content-Type': contentType },
567
+ maxBodyLength: Infinity,
568
+ maxContentLength: Infinity,
569
+ onUploadProgress: (progressEvent) => {
570
+ if (progressEvent.total) {
571
+ const pct = Math.round((progressEvent.loaded / progressEvent.total) * 100);
572
+ spinner.text = chalk.yellow(`Uploading banner — ${pct}%`);
573
+ }
574
+ },
575
+ });
576
+
577
+ // 3. Confirm
578
+ spinner.text = chalk.yellow('Confirming upload...');
579
+ await confirmGameUpload(gameId, {
580
+ s3Key: urlResult.s3Key,
581
+ fileName,
582
+ assetType: 'BANNER',
583
+ contentType,
584
+ });
585
+
586
+ spinner.succeed('Banner uploaded!');
587
+ console.log(brand.teal(` S3 key: ${urlResult.s3Key}`));
588
+ console.log();
589
+ } catch (err) {
590
+ const message = err.response?.data?.error || err.message;
591
+ spinner.fail(`Banner upload failed: ${message}`);
592
+ }
593
+ }
594
+
514
595
  // ── game submit ─────────────────────────────────────
515
596
 
516
597
  export async function gameSubmitCommand() {
package/src/index.js CHANGED
@@ -56,6 +56,7 @@ import {
56
56
  import {
57
57
  gameUpdateCommand,
58
58
  gameUploadThumbnailCommand,
59
+ gameUploadBannerCommand,
59
60
  gameSubmitCommand,
60
61
  gameMissionsInitCommand,
61
62
  gameMissionsSyncCommand,
@@ -144,6 +145,11 @@ export function run() {
144
145
  .description('Upload or replace game thumbnail image')
145
146
  .action(gameUploadThumbnailCommand);
146
147
 
148
+ gameCmd
149
+ .command('upload-banner <file>')
150
+ .description('Upload or replace game banner image')
151
+ .action(gameUploadBannerCommand);
152
+
147
153
  gameCmd
148
154
  .command('submit')
149
155
  .description('Submit a DRAFT game for admin review')
@@ -36,7 +36,7 @@ export function createGameProject(targetDir, options) {
36
36
  }
37
37
 
38
38
  // Write common files
39
- writeFileSync(join(targetDir, 'package.json'), generatePackageJson(name, description, slug, type));
39
+ writeFileSync(join(targetDir, 'package.json'), generatePackageJson(name, description, slug, type, ageGroup));
40
40
  writeFileSync(join(targetDir, 'vite.config.js'), generateViteConfig());
41
41
  writeFileSync(join(targetDir, '.gitignore'), generateGitignore());
42
42
  writeFileSync(join(targetDir, 'README.md'), generateReadme(name, description, type, ageGroup));
@@ -670,7 +670,7 @@ Published games appear automatically in the Izi Graham mobile app or M-UNI web p
670
670
 
671
671
  // ── Three.js Templates ─────────────────────────────────────────────
672
672
 
673
- function generatePackageJson(name, description, slug, type) {
673
+ function generatePackageJson(name, description, slug, type, ageGroup) {
674
674
  return JSON.stringify({
675
675
  name: slug,
676
676
  version: '1.0.0',
@@ -689,8 +689,10 @@ function generatePackageJson(name, description, slug, type) {
689
689
  vite: '^5.0.0',
690
690
  },
691
691
  myvillage: {
692
+ name: name,
692
693
  gameId: null,
693
694
  gameType: type,
695
+ targetAge: ageGroup,
694
696
  lastDeployed: null,
695
697
  },
696
698
  }, null, 2) + '\n';