@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
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
+ }
@@ -18,6 +18,17 @@ function readPackageJson(dir) {
18
18
  }
19
19
  }
20
20
 
21
+ function readMyVillageJson(dir) {
22
+ const configPath = join(dir, 'myvillage.json');
23
+ if (!existsSync(configPath)) return null;
24
+
25
+ try {
26
+ return JSON.parse(readFileSync(configPath, 'utf-8'));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
21
32
  function saveGameIdToPackageJson(dir, gameId, slug) {
22
33
  const pkgPath = join(dir, 'package.json');
23
34
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
@@ -25,6 +36,15 @@ function saveGameIdToPackageJson(dir, gameId, slug) {
25
36
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
26
37
  }
27
38
 
39
+ function saveGameIdToMyVillageJson(dir, gameId, slug) {
40
+ const configPath = join(dir, 'myvillage.json');
41
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
42
+ config.gameId = gameId;
43
+ config.slug = slug;
44
+ config.lastDeployed = new Date().toISOString();
45
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
46
+ }
47
+
28
48
  // Recursively collect file paths from a directory
29
49
  function collectFilePaths(dir) {
30
50
  const paths = [];
@@ -45,7 +65,7 @@ function collectFilePaths(dir) {
45
65
  return paths;
46
66
  }
47
67
 
48
- export async function deployCommand() {
68
+ export async function deployCommand(options = {}) {
49
69
  // Check authentication
50
70
  if (!isAuthenticated()) {
51
71
  console.log(chalk.red(' \u2717 Authentication required. Run \'myvillage login\' first.'));
@@ -53,15 +73,30 @@ export async function deployCommand() {
53
73
  }
54
74
 
55
75
  const projectDir = resolve(process.cwd());
76
+ const mvConfig = readMyVillageJson(projectDir);
56
77
  const pkg = readPackageJson(projectDir);
57
78
 
58
- // Verify this is a valid MyVillageOS game project
59
- if (!pkg || !pkg.myvillage) {
79
+ // Detect project type
80
+ const isUnity = mvConfig && mvConfig.engine === 'unity';
81
+ const isThreeJs = pkg && pkg.myvillage;
82
+
83
+ if (!isUnity && !isThreeJs) {
60
84
  console.log(chalk.red(' \u2717 Not a valid game project directory.'));
61
85
  console.log(brand.teal(' Make sure you are inside a game directory created with "myvillage create-game".'));
86
+ console.log(brand.teal(' Expected: package.json with myvillage block, or myvillage.json with engine: unity'));
62
87
  return;
63
88
  }
64
89
 
90
+ if (isUnity) {
91
+ return deployUnityGame(projectDir, mvConfig, options);
92
+ }
93
+
94
+ return deployThreeJsGame(projectDir, pkg, options);
95
+ }
96
+
97
+ // ── Three.js deploy (existing flow) ──
98
+
99
+ async function deployThreeJsGame(projectDir, pkg) {
65
100
  const spinner = villageSpinner('Building game...').start();
66
101
 
67
102
  try {
@@ -80,7 +115,6 @@ export async function deployCommand() {
80
115
 
81
116
  spinner.text = 'Packaging build artifacts...';
82
117
 
83
- // Collect build files
84
118
  const filePaths = collectFilePaths(distDir);
85
119
 
86
120
  if (filePaths.length === 0) {
@@ -88,10 +122,8 @@ export async function deployCommand() {
88
122
  return;
89
123
  }
90
124
 
91
- // Build multipart FormData
92
125
  const formData = new FormData();
93
126
 
94
- // Add metadata
95
127
  const metadata = {
96
128
  title: pkg.myvillage.name || pkg.name,
97
129
  description: pkg.description || '',
@@ -102,7 +134,6 @@ export async function deployCommand() {
102
134
  };
103
135
  formData.append('metadata', JSON.stringify(metadata));
104
136
 
105
- // Add each file with relative path as the filename
106
137
  for (const fullPath of filePaths) {
107
138
  const relPath = relative(distDir, fullPath);
108
139
  const fileBuffer = readFileSync(fullPath);
@@ -112,11 +143,9 @@ export async function deployCommand() {
112
143
 
113
144
  spinner.text = 'Deploying to MyVillageOS...';
114
145
 
115
- // Use existing gameId if this project was deployed before, otherwise "new"
116
146
  const gameIdOrNew = pkg.myvillage.gameId || 'new';
117
147
  const result = await deployGame(gameIdOrNew, formData);
118
148
 
119
- // Save gameId to package.json for subsequent deploys
120
149
  if (result.gameId && !pkg.myvillage.gameId) {
121
150
  saveGameIdToPackageJson(projectDir, result.gameId, result.slug);
122
151
  }
@@ -125,29 +154,120 @@ export async function deployCommand() {
125
154
  console.log();
126
155
  console.log(brand.green(` \u2713 Game "${metadata.title}" submitted for review`));
127
156
  console.log(` Status: ${formatStatus(result.status)}`);
157
+ if (result.slug) console.log(brand.teal(` Slug: ${result.slug}`));
158
+ if (result.gameId) console.log(brand.teal(` Game ID: ${result.gameId}`));
159
+ console.log();
160
+ console.log(brand.teal(' Run "myvillage status" to check the review status.'));
161
+ console.log();
162
+ } catch (err) {
163
+ if (err.response) {
164
+ const message = err.response.data?.error_description
165
+ || err.response.data?.error
166
+ || err.response.data?.message
167
+ || 'Unknown server error';
168
+ spinner.fail(`Deployment failed: ${message}`);
169
+ } else if (err.stderr) {
170
+ spinner.fail('Build failed.');
171
+ console.error(chalk.red(` ${err.stderr.toString().trim()}`));
172
+ } else {
173
+ spinner.fail(`Deployment failed: ${err.message}`);
174
+ }
175
+ }
176
+ }
177
+
178
+ // ── Unity deploy ──
179
+
180
+ async function deployUnityGame(projectDir, mvConfig, options) {
181
+ const platform = options.platform;
182
+
183
+ if (!platform) {
184
+ console.log(chalk.red(' \u2717 --platform is required for Unity games.'));
185
+ console.log(brand.teal(' Usage: myvillage deploy --platform <ios|android|webgl>'));
186
+ return;
187
+ }
188
+
189
+ const validPlatforms = ['ios', 'android', 'webgl'];
190
+ if (!validPlatforms.includes(platform)) {
191
+ console.log(chalk.red(` \u2717 Invalid platform "${platform}". Must be one of: ${validPlatforms.join(', ')}`));
192
+ return;
193
+ }
194
+
195
+ // Determine build directory
196
+ const buildDir = join(projectDir, 'builds', platform);
128
197
 
129
- if (result.slug) {
130
- console.log(brand.teal(` Slug: ${result.slug}`));
198
+ if (!existsSync(buildDir)) {
199
+ console.log(chalk.red(` \u2717 Build directory not found: builds/${platform}/`));
200
+ console.log(brand.teal(` Build your game in Unity and place the output in builds/${platform}/`));
201
+ return;
202
+ }
203
+
204
+ const filePaths = collectFilePaths(buildDir);
205
+
206
+ if (filePaths.length === 0) {
207
+ console.log(chalk.red(` \u2717 No files found in builds/${platform}/`));
208
+ return;
209
+ }
210
+
211
+ const spinner = villageSpinner(`Deploying ${platform} build to MyVillageOS...`).start();
212
+
213
+ try {
214
+ spinner.text = `Packaging ${filePaths.length} files...`;
215
+
216
+ const formData = new FormData();
217
+
218
+ // Determine gameType based on platform
219
+ const gameType = platform === 'webgl' ? 'UNITY_WEBGL' : 'UNITY_NATIVE';
220
+
221
+ const metadata = {
222
+ title: mvConfig.name,
223
+ description: mvConfig.description || '',
224
+ category: (mvConfig.category || 'OTHER').toUpperCase(),
225
+ gameType,
226
+ targetAge: mvConfig.targetAge || 'all',
227
+ buildPlatform: platform,
228
+ };
229
+
230
+ // WebGL builds have an index.html entry file
231
+ if (platform === 'webgl') {
232
+ metadata.entryFile = 'index.html';
233
+ }
234
+
235
+ formData.append('metadata', JSON.stringify(metadata));
236
+
237
+ for (const fullPath of filePaths) {
238
+ const relPath = relative(buildDir, fullPath);
239
+ const fileBuffer = readFileSync(fullPath);
240
+ const blob = new Blob([fileBuffer], { type: getContentType(relPath) });
241
+ formData.append('files', blob, relPath);
131
242
  }
132
- if (result.gameId) {
133
- console.log(brand.teal(` Game ID: ${result.gameId}`));
243
+
244
+ spinner.text = 'Uploading to MyVillageOS...';
245
+
246
+ const gameIdOrNew = mvConfig.gameId || 'new';
247
+ const result = await deployGame(gameIdOrNew, formData);
248
+
249
+ // Save gameId back to myvillage.json
250
+ if (result.gameId && !mvConfig.gameId) {
251
+ saveGameIdToMyVillageJson(projectDir, result.gameId, result.slug);
134
252
  }
135
253
 
254
+ spinner.succeed(`${platform} deployment complete!`);
255
+ console.log();
256
+ console.log(brand.green(` \u2713 Game "${metadata.title}" (${gameType}) submitted for review`));
257
+ console.log(` Status: ${formatStatus(result.status)}`);
258
+ console.log(` Platform: ${platform}`);
259
+ if (result.slug) console.log(brand.teal(` Slug: ${result.slug}`));
260
+ if (result.gameId) console.log(brand.teal(` Game ID: ${result.gameId}`));
136
261
  console.log();
137
262
  console.log(brand.teal(' Run "myvillage status" to check the review status.'));
138
263
  console.log();
139
264
  } catch (err) {
140
265
  if (err.response) {
141
- // API error
142
266
  const message = err.response.data?.error_description
143
267
  || err.response.data?.error
144
268
  || err.response.data?.message
145
269
  || 'Unknown server error';
146
270
  spinner.fail(`Deployment failed: ${message}`);
147
- } else if (err.stderr) {
148
- // Build error
149
- spinner.fail('Build failed.');
150
- console.error(chalk.red(` ${err.stderr.toString().trim()}`));
151
271
  } else {
152
272
  spinner.fail(`Deployment failed: ${err.message}`);
153
273
  }
@@ -193,6 +313,11 @@ function getContentType(filePath) {
193
313
  woff2: 'font/woff2',
194
314
  ttf: 'font/ttf',
195
315
  wasm: 'application/wasm',
316
+ // Unity-specific
317
+ data: 'application/octet-stream',
318
+ unityweb: 'application/octet-stream',
319
+ br: 'application/x-brotli',
320
+ gz: 'application/gzip',
196
321
  };
197
322
  return types[ext] || 'application/octet-stream';
198
323
  }