@myvillage/cli 1.9.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.9.0",
3
+ "version": "1.10.1",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,7 +6,7 @@ import ora from 'ora';
6
6
  import { villageSpinner, brand } from '../utils/brand.js';
7
7
  import inquirer from 'inquirer';
8
8
  import { isAuthenticated } from '../utils/auth.js';
9
- import { createGameProject } from '../utils/templates.js';
9
+ import { createGameProject, createUnityGameProject } from '../utils/templates.js';
10
10
 
11
11
  export async function createGameCommand() {
12
12
  // Check authentication
@@ -15,7 +15,25 @@ export async function createGameCommand() {
15
15
  return;
16
16
  }
17
17
 
18
- // Interactive prompts
18
+ // Engine choice
19
+ const { engine } = await inquirer.prompt([
20
+ {
21
+ type: 'list',
22
+ name: 'engine',
23
+ message: 'Which engine are you using?',
24
+ choices: [
25
+ { name: 'Three.js — Web-based 3D game (runs in browser)', value: 'threejs' },
26
+ { name: 'Unity — Mobile or WebGL game (built in Unity Editor)', value: 'unity' },
27
+ ],
28
+ },
29
+ ]);
30
+
31
+ if (engine === 'unity') {
32
+ return createUnityGame();
33
+ }
34
+
35
+ // ── Three.js path (existing flow) ──
36
+
19
37
  const answers = await inquirer.prompt([
20
38
  {
21
39
  type: 'list',
@@ -57,7 +75,6 @@ export async function createGameCommand() {
57
75
  },
58
76
  ]);
59
77
 
60
- // Create project directory
61
78
  const slug = answers.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
62
79
  const targetDir = resolve(process.cwd(), slug);
63
80
 
@@ -69,7 +86,6 @@ export async function createGameCommand() {
69
86
  const spinner = villageSpinner('Creating game project...').start();
70
87
 
71
88
  try {
72
- // Generate project from template
73
89
  createGameProject(targetDir, {
74
90
  name: answers.name,
75
91
  description: answers.description,
@@ -79,7 +95,6 @@ export async function createGameCommand() {
79
95
 
80
96
  spinner.text = 'Installing dependencies...';
81
97
 
82
- // Run npm install in the new project directory
83
98
  execSync('npm install', {
84
99
  cwd: targetDir,
85
100
  stdio: 'pipe',
@@ -87,7 +102,6 @@ export async function createGameCommand() {
87
102
 
88
103
  spinner.succeed(`Game "${answers.name}" created successfully!`);
89
104
 
90
- // Display next steps
91
105
  console.log();
92
106
  console.log(brand.green(` \u2713 Game "${answers.name}" created successfully!`));
93
107
  console.log();
@@ -105,3 +119,102 @@ export async function createGameCommand() {
105
119
  console.error(chalk.red(` ${err.message}`));
106
120
  }
107
121
  }
122
+
123
+ // ── Unity path ──
124
+
125
+ async function createUnityGame() {
126
+ const answers = await inquirer.prompt([
127
+ {
128
+ type: 'input',
129
+ name: 'name',
130
+ message: 'Game name:',
131
+ default: 'My Unity Game',
132
+ validate: (input) => {
133
+ if (!input.trim()) return 'Game name is required.';
134
+ return true;
135
+ },
136
+ },
137
+ {
138
+ type: 'input',
139
+ name: 'description',
140
+ message: 'Game description:',
141
+ default: 'A Unity game for MyVillageOS',
142
+ },
143
+ {
144
+ type: 'list',
145
+ name: 'category',
146
+ message: 'Game category:',
147
+ choices: [
148
+ { name: 'Education', value: 'education' },
149
+ { name: 'Action', value: 'action' },
150
+ { name: 'Adventure', value: 'adventure' },
151
+ { name: 'Puzzle', value: 'puzzle' },
152
+ { name: 'Other', value: 'other' },
153
+ ],
154
+ },
155
+ {
156
+ type: 'list',
157
+ name: 'platform',
158
+ message: 'Target platform:',
159
+ choices: [
160
+ { name: 'Mobile (iOS / Android) — AssetBundle loaded by Izi Graham', value: 'mobile' },
161
+ { name: 'WebGL — Runs in browser on M-UNI', value: 'webgl' },
162
+ ],
163
+ },
164
+ {
165
+ type: 'list',
166
+ name: 'ageGroup',
167
+ message: 'Target age group:',
168
+ choices: [
169
+ { name: '5-8 years', value: '5-8' },
170
+ { name: '9-12 years', value: '9-12' },
171
+ { name: '13-15 years', value: '13-15' },
172
+ { name: '16-18 years', value: '16-18' },
173
+ ],
174
+ },
175
+ ]);
176
+
177
+ const slug = answers.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
178
+ const targetDir = resolve(process.cwd(), slug);
179
+
180
+ if (existsSync(targetDir)) {
181
+ console.log(chalk.red(`\n \u2717 Directory "${slug}" already exists.`));
182
+ return;
183
+ }
184
+
185
+ const spinner = villageSpinner('Creating Unity game project...').start();
186
+
187
+ try {
188
+ createUnityGameProject(targetDir, {
189
+ name: answers.name,
190
+ description: answers.description,
191
+ type: answers.category,
192
+ ageGroup: answers.ageGroup,
193
+ platform: answers.platform,
194
+ });
195
+
196
+ spinner.succeed(`Unity game "${answers.name}" created!`);
197
+
198
+ console.log();
199
+ console.log(brand.green(` \u2713 Unity game "${answers.name}" created!`));
200
+ console.log();
201
+ console.log(brand.teal(' Next steps:'));
202
+ console.log();
203
+ console.log(` 1. ${brand.gold('Copy Scripts/')} into your Unity project's Assets/ folder`);
204
+ console.log(` 2. ${brand.teal('Attach GameSessionReporter and MyVillageAuth to a GameObject')}`);
205
+ console.log(` 3. ${brand.teal('Build your game in Unity Editor')}`);
206
+ if (answers.platform === 'mobile') {
207
+ console.log(` 4. ${brand.teal('Place built AssetBundles in')} ${brand.gold('builds/ios/')} ${brand.teal('or')} ${brand.gold('builds/android/')}`);
208
+ console.log(` 5. ${brand.gold('cd ' + slug + ' && myvillage deploy --platform ios')}`);
209
+ } else {
210
+ console.log(` 4. ${brand.teal('Build WebGL to')} ${brand.gold('builds/webgl/')}`);
211
+ console.log(` 5. ${brand.gold('cd ' + slug + ' && myvillage deploy --platform webgl')}`);
212
+ }
213
+ console.log();
214
+ console.log(brand.teal(` See ${slug}/README.md for full integration guide.`));
215
+ console.log();
216
+ } catch (err) {
217
+ spinner.fail('Failed to create Unity game project.');
218
+ console.error(chalk.red(` ${err.message}`));
219
+ }
220
+ }
@@ -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');
@@ -18,6 +20,17 @@ function readPackageJson(dir) {
18
20
  }
19
21
  }
20
22
 
23
+ function readMyVillageJson(dir) {
24
+ const configPath = join(dir, 'myvillage.json');
25
+ if (!existsSync(configPath)) return null;
26
+
27
+ try {
28
+ return JSON.parse(readFileSync(configPath, 'utf-8'));
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
21
34
  function saveGameIdToPackageJson(dir, gameId, slug) {
22
35
  const pkgPath = join(dir, 'package.json');
23
36
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
@@ -25,6 +38,15 @@ function saveGameIdToPackageJson(dir, gameId, slug) {
25
38
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
26
39
  }
27
40
 
41
+ function saveGameIdToMyVillageJson(dir, gameId, slug) {
42
+ const configPath = join(dir, 'myvillage.json');
43
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
44
+ config.gameId = gameId;
45
+ config.slug = slug;
46
+ config.lastDeployed = new Date().toISOString();
47
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
48
+ }
49
+
28
50
  // Recursively collect file paths from a directory
29
51
  function collectFilePaths(dir) {
30
52
  const paths = [];
@@ -45,7 +67,7 @@ function collectFilePaths(dir) {
45
67
  return paths;
46
68
  }
47
69
 
48
- export async function deployCommand() {
70
+ export async function deployCommand(options = {}) {
49
71
  // Check authentication
50
72
  if (!isAuthenticated()) {
51
73
  console.log(chalk.red(' \u2717 Authentication required. Run \'myvillage login\' first.'));
@@ -53,15 +75,30 @@ export async function deployCommand() {
53
75
  }
54
76
 
55
77
  const projectDir = resolve(process.cwd());
78
+ const mvConfig = readMyVillageJson(projectDir);
56
79
  const pkg = readPackageJson(projectDir);
57
80
 
58
- // Verify this is a valid MyVillageOS game project
59
- if (!pkg || !pkg.myvillage) {
81
+ // Detect project type
82
+ const isUnity = mvConfig && mvConfig.engine === 'unity';
83
+ const isThreeJs = pkg && pkg.myvillage;
84
+
85
+ if (!isUnity && !isThreeJs) {
60
86
  console.log(chalk.red(' \u2717 Not a valid game project directory.'));
61
87
  console.log(brand.teal(' Make sure you are inside a game directory created with "myvillage create-game".'));
88
+ console.log(brand.teal(' Expected: package.json with myvillage block, or myvillage.json with engine: unity'));
62
89
  return;
63
90
  }
64
91
 
92
+ if (isUnity) {
93
+ return deployUnityGame(projectDir, mvConfig, options);
94
+ }
95
+
96
+ return deployThreeJsGame(projectDir, pkg, options);
97
+ }
98
+
99
+ // ── Three.js deploy (existing flow) ──
100
+
101
+ async function deployThreeJsGame(projectDir, pkg) {
65
102
  const spinner = villageSpinner('Building game...').start();
66
103
 
67
104
  try {
@@ -80,7 +117,6 @@ export async function deployCommand() {
80
117
 
81
118
  spinner.text = 'Packaging build artifacts...';
82
119
 
83
- // Collect build files
84
120
  const filePaths = collectFilePaths(distDir);
85
121
 
86
122
  if (filePaths.length === 0) {
@@ -88,10 +124,8 @@ export async function deployCommand() {
88
124
  return;
89
125
  }
90
126
 
91
- // Build multipart FormData
92
127
  const formData = new FormData();
93
128
 
94
- // Add metadata
95
129
  const metadata = {
96
130
  title: pkg.myvillage.name || pkg.name,
97
131
  description: pkg.description || '',
@@ -102,7 +136,6 @@ export async function deployCommand() {
102
136
  };
103
137
  formData.append('metadata', JSON.stringify(metadata));
104
138
 
105
- // Add each file with relative path as the filename
106
139
  for (const fullPath of filePaths) {
107
140
  const relPath = relative(distDir, fullPath);
108
141
  const fileBuffer = readFileSync(fullPath);
@@ -112,11 +145,9 @@ export async function deployCommand() {
112
145
 
113
146
  spinner.text = 'Deploying to MyVillageOS...';
114
147
 
115
- // Use existing gameId if this project was deployed before, otherwise "new"
116
148
  const gameIdOrNew = pkg.myvillage.gameId || 'new';
117
149
  const result = await deployGame(gameIdOrNew, formData);
118
150
 
119
- // Save gameId to package.json for subsequent deploys
120
151
  if (result.gameId && !pkg.myvillage.gameId) {
121
152
  saveGameIdToPackageJson(projectDir, result.gameId, result.slug);
122
153
  }
@@ -125,12 +156,13 @@ export async function deployCommand() {
125
156
  console.log();
126
157
  console.log(brand.green(` \u2713 Game "${metadata.title}" submitted for review`));
127
158
  console.log(` Status: ${formatStatus(result.status)}`);
159
+ if (result.slug) console.log(brand.teal(` Slug: ${result.slug}`));
160
+ if (result.gameId) console.log(brand.teal(` Game ID: ${result.gameId}`));
128
161
 
129
- if (result.slug) {
130
- console.log(brand.teal(` Slug: ${result.slug}`));
131
- }
132
- if (result.gameId) {
133
- console.log(brand.teal(` Game ID: ${result.gameId}`));
162
+ // Prompt for thumbnail/banner images
163
+ const finalGameId = result.gameId || pkg.myvillage.gameId;
164
+ if (finalGameId) {
165
+ await promptForImages(finalGameId);
134
166
  }
135
167
 
136
168
  console.log();
@@ -138,14 +170,12 @@ export async function deployCommand() {
138
170
  console.log();
139
171
  } catch (err) {
140
172
  if (err.response) {
141
- // API error
142
173
  const message = err.response.data?.error_description
143
174
  || err.response.data?.error
144
175
  || err.response.data?.message
145
176
  || 'Unknown server error';
146
177
  spinner.fail(`Deployment failed: ${message}`);
147
178
  } else if (err.stderr) {
148
- // Build error
149
179
  spinner.fail('Build failed.');
150
180
  console.error(chalk.red(` ${err.stderr.toString().trim()}`));
151
181
  } else {
@@ -154,6 +184,219 @@ export async function deployCommand() {
154
184
  }
155
185
  }
156
186
 
187
+ // ── Unity deploy ──
188
+
189
+ async function deployUnityGame(projectDir, mvConfig, options) {
190
+ const platform = options.platform;
191
+
192
+ if (!platform) {
193
+ console.log(chalk.red(' \u2717 --platform is required for Unity games.'));
194
+ console.log(brand.teal(' Usage: myvillage deploy --platform <ios|android|webgl>'));
195
+ return;
196
+ }
197
+
198
+ const validPlatforms = ['ios', 'android', 'webgl'];
199
+ if (!validPlatforms.includes(platform)) {
200
+ console.log(chalk.red(` \u2717 Invalid platform "${platform}". Must be one of: ${validPlatforms.join(', ')}`));
201
+ return;
202
+ }
203
+
204
+ // Determine build directory
205
+ const buildDir = join(projectDir, 'builds', platform);
206
+
207
+ if (!existsSync(buildDir)) {
208
+ console.log(chalk.red(` \u2717 Build directory not found: builds/${platform}/`));
209
+ console.log(brand.teal(` Build your game in Unity and place the output in builds/${platform}/`));
210
+ return;
211
+ }
212
+
213
+ const filePaths = collectFilePaths(buildDir);
214
+
215
+ if (filePaths.length === 0) {
216
+ console.log(chalk.red(` \u2717 No files found in builds/${platform}/`));
217
+ return;
218
+ }
219
+
220
+ const spinner = villageSpinner(`Deploying ${platform} build to MyVillageOS...`).start();
221
+
222
+ try {
223
+ spinner.text = `Packaging ${filePaths.length} files...`;
224
+
225
+ const formData = new FormData();
226
+
227
+ // Determine gameType based on platform
228
+ const gameType = platform === 'webgl' ? 'UNITY_WEBGL' : 'UNITY_NATIVE';
229
+
230
+ const metadata = {
231
+ title: mvConfig.name,
232
+ description: mvConfig.description || '',
233
+ category: (mvConfig.category || 'OTHER').toUpperCase(),
234
+ gameType,
235
+ targetAge: mvConfig.targetAge || 'all',
236
+ buildPlatform: platform,
237
+ };
238
+
239
+ // WebGL builds have an index.html entry file
240
+ if (platform === 'webgl') {
241
+ metadata.entryFile = 'index.html';
242
+ }
243
+
244
+ formData.append('metadata', JSON.stringify(metadata));
245
+
246
+ for (const fullPath of filePaths) {
247
+ const relPath = relative(buildDir, fullPath);
248
+ const fileBuffer = readFileSync(fullPath);
249
+ const blob = new Blob([fileBuffer], { type: getContentType(relPath) });
250
+ formData.append('files', blob, relPath);
251
+ }
252
+
253
+ spinner.text = 'Uploading to MyVillageOS...';
254
+
255
+ const gameIdOrNew = mvConfig.gameId || 'new';
256
+ const result = await deployGame(gameIdOrNew, formData);
257
+
258
+ // Save gameId back to myvillage.json
259
+ if (result.gameId && !mvConfig.gameId) {
260
+ saveGameIdToMyVillageJson(projectDir, result.gameId, result.slug);
261
+ }
262
+
263
+ spinner.succeed(`${platform} deployment complete!`);
264
+ console.log();
265
+ console.log(brand.green(` \u2713 Game "${metadata.title}" (${gameType}) submitted for review`));
266
+ console.log(` Status: ${formatStatus(result.status)}`);
267
+ console.log(` Platform: ${platform}`);
268
+ if (result.slug) console.log(brand.teal(` Slug: ${result.slug}`));
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
+
277
+ console.log();
278
+ console.log(brand.teal(' Run "myvillage status" to check the review status.'));
279
+ console.log();
280
+ } catch (err) {
281
+ if (err.response) {
282
+ const message = err.response.data?.error_description
283
+ || err.response.data?.error
284
+ || err.response.data?.message
285
+ || 'Unknown server error';
286
+ spinner.fail(`Deployment failed: ${message}`);
287
+ } else {
288
+ spinner.fail(`Deployment failed: ${err.message}`);
289
+ }
290
+ }
291
+ }
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
+
157
400
  function formatStatus(status) {
158
401
  switch (status) {
159
402
  case 'SUBMITTED':
@@ -193,6 +436,11 @@ function getContentType(filePath) {
193
436
  woff2: 'font/woff2',
194
437
  ttf: 'font/ttf',
195
438
  wasm: 'application/wasm',
439
+ // Unity-specific
440
+ data: 'application/octet-stream',
441
+ unityweb: 'application/octet-stream',
442
+ br: 'application/x-brotli',
443
+ gz: 'application/gzip',
196
444
  };
197
445
  return types[ext] || 'application/octet-stream';
198
446
  }