@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 +1 -1
- package/src/commands/create-game.js +119 -6
- package/src/commands/deploy.js +143 -18
- package/src/commands/game.js +631 -0
- package/src/index.js +72 -47
- package/src/utils/api.js +44 -0
- package/src/utils/templates.js +604 -0
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
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
|
+
}
|
package/src/commands/deploy.js
CHANGED
|
@@ -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
|
-
//
|
|
59
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
}
|