@myvillage/cli 1.10.0 → 1.10.2

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.2",
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() {
@@ -584,6 +665,72 @@ export async function gameSubmitCommand() {
584
665
  }
585
666
  }
586
667
 
668
+ // ── game draft (return to draft) ────────────────────
669
+
670
+ export async function gameDraftCommand() {
671
+ if (!isAuthenticated()) {
672
+ console.log(chalk.red(' ✗ Authentication required. Run \'myvillage login\' first.'));
673
+ return;
674
+ }
675
+
676
+ const gameId = getGameId();
677
+ if (!gameId) return;
678
+
679
+ const checkSpinner = villageSpinner('Checking game status...').start();
680
+ let game;
681
+ try {
682
+ const result = await getGameDetail(gameId);
683
+ game = result.game;
684
+ checkSpinner.stop();
685
+ } catch (err) {
686
+ const message = err.response?.data?.error || err.message;
687
+ checkSpinner.fail(`Failed to fetch game: ${message}`);
688
+ return;
689
+ }
690
+
691
+ if (game.status === 'DRAFT') {
692
+ console.log(brand.teal(`\n "${game.title}" is already in DRAFT status.\n`));
693
+ return;
694
+ }
695
+
696
+ if (game.status === 'PUBLISHED') {
697
+ console.log(chalk.red(`\n ✗ "${game.title}" is PUBLISHED and cannot be returned to draft.\n`));
698
+ return;
699
+ }
700
+
701
+ console.log();
702
+ console.log(brand.gold(` Return "${game.title}" to DRAFT?`));
703
+ console.log(brand.teal(` Current status: ${game.status}`));
704
+ console.log(brand.teal(' This will withdraw the game from review so you can make changes.'));
705
+ console.log();
706
+
707
+ const { confirm } = await inquirer.prompt([{
708
+ type: 'confirm',
709
+ name: 'confirm',
710
+ message: 'Return to draft?',
711
+ default: true,
712
+ }]);
713
+
714
+ if (!confirm) {
715
+ console.log(brand.teal('\n Cancelled.\n'));
716
+ return;
717
+ }
718
+
719
+ const spinner = villageSpinner('Updating status...').start();
720
+
721
+ try {
722
+ await updateGameMetadata(gameId, { status: 'DRAFT' });
723
+ spinner.succeed('Game returned to draft!');
724
+ console.log();
725
+ console.log(brand.green(` ✓ "${game.title}" is now in DRAFT status.`));
726
+ console.log(brand.teal(' You can now upload assets, update metadata, and redeploy.'));
727
+ console.log();
728
+ } catch (err) {
729
+ const message = err.response?.data?.error || err.message;
730
+ spinner.fail(`Failed to update status: ${message}`);
731
+ }
732
+ }
733
+
587
734
  // ── Helpers ───────────────────────────────────────────
588
735
 
589
736
  function getGameId() {
package/src/index.js CHANGED
@@ -56,7 +56,9 @@ import {
56
56
  import {
57
57
  gameUpdateCommand,
58
58
  gameUploadThumbnailCommand,
59
+ gameUploadBannerCommand,
59
60
  gameSubmitCommand,
61
+ gameDraftCommand,
60
62
  gameMissionsInitCommand,
61
63
  gameMissionsSyncCommand,
62
64
  gameMissionsListCommand,
@@ -144,11 +146,21 @@ export function run() {
144
146
  .description('Upload or replace game thumbnail image')
145
147
  .action(gameUploadThumbnailCommand);
146
148
 
149
+ gameCmd
150
+ .command('upload-banner <file>')
151
+ .description('Upload or replace game banner image')
152
+ .action(gameUploadBannerCommand);
153
+
147
154
  gameCmd
148
155
  .command('submit')
149
156
  .description('Submit a DRAFT game for admin review')
150
157
  .action(gameSubmitCommand);
151
158
 
159
+ gameCmd
160
+ .command('draft')
161
+ .description('Return a game to DRAFT status (withdraw from review)')
162
+ .action(gameDraftCommand);
163
+
152
164
  // Game missions subcommands
153
165
  const gameMissionsCmd = gameCmd
154
166
  .command('missions')
@@ -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';