@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 +1 -1
- package/src/commands/deploy.js +126 -3
- package/src/commands/game.js +147 -0
- package/src/index.js +12 -0
- package/src/utils/templates.js +4 -2
package/package.json
CHANGED
package/src/commands/deploy.js
CHANGED
|
@@ -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':
|
package/src/commands/game.js
CHANGED
|
@@ -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')
|
package/src/utils/templates.js
CHANGED
|
@@ -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';
|