@myvillage/cli 1.51.0 → 1.60.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 +2 -1
- package/src/commands/create-game.js +18 -12
- package/src/commands/deploy.js +166 -53
- package/src/index.js +2 -1
- package/src/utils/api.js +10 -0
- package/src/utils/preflight.js +172 -0
- package/src/utils/templates.js +189 -517
- package/tools/preflight/README.md +75 -0
- package/tools/preflight/preflight.py +291 -0
- package/tools/preflight/requirements.txt +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myvillage/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.60.1",
|
|
4
4
|
"description": "MyVillageOS CLI for community developers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
"bin/",
|
|
14
14
|
"src/",
|
|
15
|
+
"tools/",
|
|
15
16
|
"README.md"
|
|
16
17
|
],
|
|
17
18
|
"keywords": [
|
|
@@ -8,7 +8,7 @@ import inquirer from 'inquirer';
|
|
|
8
8
|
import { isAuthenticated } from '../utils/auth.js';
|
|
9
9
|
import { createGameProject, createUnityGameProject } from '../utils/templates.js';
|
|
10
10
|
|
|
11
|
-
export async function createGameCommand() {
|
|
11
|
+
export async function createGameCommand(options = {}) {
|
|
12
12
|
// Check authentication
|
|
13
13
|
if (!isAuthenticated()) {
|
|
14
14
|
console.log(chalk.red(' \u2717 Authentication required. Run \'myvillage login\' first.'));
|
|
@@ -29,7 +29,7 @@ export async function createGameCommand() {
|
|
|
29
29
|
]);
|
|
30
30
|
|
|
31
31
|
if (engine === 'unity') {
|
|
32
|
-
return createUnityGame();
|
|
32
|
+
return createUnityGame(options);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
// ── Three.js path (existing flow) ──
|
|
@@ -122,7 +122,7 @@ export async function createGameCommand() {
|
|
|
122
122
|
|
|
123
123
|
// ── Unity path ──
|
|
124
124
|
|
|
125
|
-
async function createUnityGame() {
|
|
125
|
+
async function createUnityGame(options = {}) {
|
|
126
126
|
const answers = await inquirer.prompt([
|
|
127
127
|
{
|
|
128
128
|
type: 'input',
|
|
@@ -185,33 +185,39 @@ async function createUnityGame() {
|
|
|
185
185
|
const spinner = villageSpinner('Creating Unity game project...').start();
|
|
186
186
|
|
|
187
187
|
try {
|
|
188
|
-
createUnityGameProject(targetDir, {
|
|
188
|
+
const scaffold = await createUnityGameProject(targetDir, {
|
|
189
189
|
name: answers.name,
|
|
190
190
|
description: answers.description,
|
|
191
191
|
type: answers.category,
|
|
192
192
|
ageGroup: answers.ageGroup,
|
|
193
193
|
platform: answers.platform,
|
|
194
|
+
unityVersion: options.unityVersion,
|
|
194
195
|
});
|
|
195
196
|
|
|
196
197
|
spinner.succeed(`Unity game "${answers.name}" created!`);
|
|
197
198
|
|
|
198
199
|
console.log();
|
|
199
200
|
console.log(brand.green(` \u2713 Unity game "${answers.name}" created!`));
|
|
201
|
+
console.log(brand.teal(` Unity: ${scaffold.unityVersion}`));
|
|
202
|
+
console.log(brand.teal(` GameKit SDK: ${scaffold.sdkVersion}`));
|
|
200
203
|
console.log();
|
|
201
204
|
console.log(brand.teal(' Next steps:'));
|
|
202
205
|
console.log();
|
|
203
|
-
console.log(` 1. ${brand.gold('
|
|
204
|
-
console.log(` 2. ${brand.teal('
|
|
205
|
-
console.log(` 3. ${brand.teal('
|
|
206
|
+
console.log(` 1. ${brand.gold('Open this project in Unity Hub')} (Add \u2192 Add project from disk)`);
|
|
207
|
+
console.log(` 2. ${brand.teal('Click menu')} ${brand.gold('MyVillage \u2192 Create Starter Mission Scene')}`);
|
|
208
|
+
console.log(` 3. ${brand.teal('Press')} ${brand.gold('Play')} ${brand.teal('\u2014 your mission runs end-to-end in the editor')}`);
|
|
209
|
+
console.log(` 4. ${brand.teal('Edit')} ${brand.gold(`Assets/Scripts/${answers.name.replace(/[^a-zA-Z0-9]/g, '')}.cs`)} ${brand.teal('and iterate (hit Play again)')}`);
|
|
210
|
+
console.log();
|
|
211
|
+
console.log(brand.teal(' Ready to ship:'));
|
|
206
212
|
if (answers.platform === 'mobile') {
|
|
207
|
-
console.log(`
|
|
208
|
-
console.log(`
|
|
213
|
+
console.log(` 5. ${brand.teal('Build AssetBundle:')} ${brand.gold('MyVillage \u2192 Build Mission Bundle')}`);
|
|
214
|
+
console.log(` 6. ${brand.gold('cd ' + slug + ' && myvillage deploy --platform ios')} ${brand.teal('(or android)')}`);
|
|
209
215
|
} else {
|
|
210
|
-
console.log(`
|
|
211
|
-
console.log(`
|
|
216
|
+
console.log(` 5. ${brand.teal('Build WebGL via')} ${brand.gold('File \u2192 Build Settings \u2192 WebGL \u2192 Build')} ${brand.teal('to')} ${brand.gold('builds/webgl/')}`);
|
|
217
|
+
console.log(` 6. ${brand.gold('cd ' + slug + ' && myvillage deploy --platform webgl')}`);
|
|
212
218
|
}
|
|
213
219
|
console.log();
|
|
214
|
-
console.log(brand.teal(` See ${slug}/README.md for full
|
|
220
|
+
console.log(brand.teal(` See ${slug}/README.md for the full developer guide.`));
|
|
215
221
|
console.log();
|
|
216
222
|
} catch (err) {
|
|
217
223
|
spinner.fail('Failed to create Unity game project.');
|
package/src/commands/deploy.js
CHANGED
|
@@ -7,7 +7,8 @@ import axios from 'axios';
|
|
|
7
7
|
import inquirer from 'inquirer';
|
|
8
8
|
import { villageSpinner, brand } from '../utils/brand.js';
|
|
9
9
|
import { isAuthenticated } from '../utils/auth.js';
|
|
10
|
-
import { deployGame, getGameUploadUrl, confirmGameUpload } from '../utils/api.js';
|
|
10
|
+
import { deployGame, getGameUploadUrl, confirmGameUpload, submitGameForReview } from '../utils/api.js';
|
|
11
|
+
import { preflightBundles } from '../utils/preflight.js';
|
|
11
12
|
|
|
12
13
|
function readPackageJson(dir) {
|
|
13
14
|
const pkgPath = join(dir, 'package.json');
|
|
@@ -96,6 +97,28 @@ export async function deployCommand(options = {}) {
|
|
|
96
97
|
return deployThreeJsGame(projectDir, pkg, options);
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
// Auto-promote a freshly-deployed DRAFT game to SUBMITTED so the developer
|
|
101
|
+
// doesn't have to run a second command. Re-deploys of games that are already
|
|
102
|
+
// past DRAFT (UNDER_REVIEW, APPROVED, PUBLISHED) keep their existing status.
|
|
103
|
+
async function autoSubmitForReview(gameId, deployResult) {
|
|
104
|
+
if (deployResult.status !== 'DRAFT') {
|
|
105
|
+
return deployResult.status;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const submitted = await submitGameForReview(gameId);
|
|
109
|
+
return submitted.game?.status || submitted.status || 'SUBMITTED';
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const message = err.response?.data?.error_description
|
|
112
|
+
|| err.response?.data?.error
|
|
113
|
+
|| err.response?.data?.message
|
|
114
|
+
|| err.message;
|
|
115
|
+
console.log();
|
|
116
|
+
console.log(chalk.yellow(` ! Game uploaded but auto-submit failed: ${message}`));
|
|
117
|
+
console.log(brand.teal(' Run "myvillage game submit" to submit for review manually.'));
|
|
118
|
+
return deployResult.status;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
99
122
|
// ── Three.js deploy (existing flow) ──
|
|
100
123
|
|
|
101
124
|
async function deployThreeJsGame(projectDir, pkg) {
|
|
@@ -152,15 +175,23 @@ async function deployThreeJsGame(projectDir, pkg) {
|
|
|
152
175
|
saveGameIdToPackageJson(projectDir, result.gameId, result.slug);
|
|
153
176
|
}
|
|
154
177
|
|
|
178
|
+
const finalGameId = result.gameId || pkg.myvillage.gameId;
|
|
179
|
+
|
|
180
|
+
spinner.text = 'Submitting for review...';
|
|
181
|
+
const finalStatus = await autoSubmitForReview(finalGameId, result);
|
|
182
|
+
|
|
155
183
|
spinner.succeed('Deployment complete!');
|
|
156
184
|
console.log();
|
|
157
|
-
|
|
158
|
-
|
|
185
|
+
if (finalStatus === 'SUBMITTED') {
|
|
186
|
+
console.log(brand.green(` \u2713 Game "${metadata.title}" submitted for review`));
|
|
187
|
+
} else {
|
|
188
|
+
console.log(brand.green(` \u2713 Game "${metadata.title}" uploaded`));
|
|
189
|
+
}
|
|
190
|
+
console.log(` Status: ${formatStatus(finalStatus)}`);
|
|
159
191
|
if (result.slug) console.log(brand.teal(` Slug: ${result.slug}`));
|
|
160
192
|
if (result.gameId) console.log(brand.teal(` Game ID: ${result.gameId}`));
|
|
161
193
|
|
|
162
194
|
// Prompt for thumbnail/banner images
|
|
163
|
-
const finalGameId = result.gameId || pkg.myvillage.gameId;
|
|
164
195
|
if (finalGameId) {
|
|
165
196
|
await promptForImages(finalGameId);
|
|
166
197
|
}
|
|
@@ -186,45 +217,133 @@ async function deployThreeJsGame(projectDir, pkg) {
|
|
|
186
217
|
|
|
187
218
|
// ── Unity deploy ──
|
|
188
219
|
|
|
189
|
-
|
|
190
|
-
const platform = options.platform;
|
|
220
|
+
const VALID_UNITY_PLATFORMS = ['ios', 'android', 'webgl'];
|
|
191
221
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
222
|
+
/**
|
|
223
|
+
* Deploy ALL platforms found in builds/, or just the one specified via
|
|
224
|
+
* --platform. The host's EdPlatformConfig.BundleUrl already constructs
|
|
225
|
+
* platform-specific URLs (games/<slug>/bundles/<platform>/<bundle>) and the
|
|
226
|
+
* backend now stores artifacts per-platform — so deploying iOS, Android, and
|
|
227
|
+
* WebGL coexists under the same game record without overwriting each other.
|
|
228
|
+
*/
|
|
229
|
+
async function deployUnityGame(projectDir, mvConfig, options) {
|
|
230
|
+
// Validate any explicit --platform up front so the user fails fast.
|
|
231
|
+
if (options.platform && !VALID_UNITY_PLATFORMS.includes(options.platform)) {
|
|
232
|
+
console.log(chalk.red(` \u2717 Invalid platform "${options.platform}". Must be one of: ${VALID_UNITY_PLATFORMS.join(', ')}`));
|
|
195
233
|
return;
|
|
196
234
|
}
|
|
197
235
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
236
|
+
// Discover which platforms actually have a build sitting in builds/.
|
|
237
|
+
const buildsRoot = join(projectDir, 'builds');
|
|
238
|
+
const available = VALID_UNITY_PLATFORMS.filter((p) => {
|
|
239
|
+
const dir = join(buildsRoot, p);
|
|
240
|
+
if (!existsSync(dir)) return false;
|
|
241
|
+
return collectFilePaths(dir).length > 0;
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Which platforms are we deploying this run?
|
|
245
|
+
let platformsToDeploy;
|
|
246
|
+
if (options.platform) {
|
|
247
|
+
if (!available.includes(options.platform)) {
|
|
248
|
+
console.log(chalk.red(` \u2717 Build directory empty or missing: builds/${options.platform}/`));
|
|
249
|
+
console.log(brand.teal(` Build your game in Unity for ${options.platform} first.`));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
platformsToDeploy = [options.platform];
|
|
253
|
+
} else {
|
|
254
|
+
if (available.length === 0) {
|
|
255
|
+
console.log(chalk.red(` \u2717 No builds found under builds/.`));
|
|
256
|
+
console.log(brand.teal(' In Unity, run MyVillage → Build All Platforms (or build one platform manually into builds/<platform>/).'));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
platformsToDeploy = available;
|
|
202
260
|
}
|
|
203
261
|
|
|
204
|
-
|
|
205
|
-
|
|
262
|
+
console.log(brand.teal(` Deploying ${platformsToDeploy.length} platform(s): ${platformsToDeploy.join(', ')}`));
|
|
263
|
+
console.log();
|
|
206
264
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
265
|
+
// Track final outcome across all platforms.
|
|
266
|
+
let finalGameId = mvConfig.gameId || null;
|
|
267
|
+
let finalSlug = null;
|
|
268
|
+
let finalStatus = null;
|
|
269
|
+
let finalTitle = null;
|
|
270
|
+
|
|
271
|
+
for (const platform of platformsToDeploy) {
|
|
272
|
+
const outcome = await deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverride: finalGameId });
|
|
273
|
+
if (!outcome.ok) return; // bail on first platform failure; nothing else makes sense to ship
|
|
274
|
+
finalGameId = outcome.gameId || finalGameId;
|
|
275
|
+
finalSlug = outcome.slug || finalSlug;
|
|
276
|
+
finalStatus = outcome.status || finalStatus;
|
|
277
|
+
finalTitle = outcome.title || finalTitle;
|
|
278
|
+
// Reload mvConfig in case the first deploy stamped a new gameId into myvillage.json
|
|
279
|
+
if (outcome.gameId && !mvConfig.gameId) {
|
|
280
|
+
mvConfig.gameId = outcome.gameId;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Single combined summary + one image prompt for the whole multi-platform deploy.
|
|
285
|
+
console.log();
|
|
286
|
+
console.log(brand.green(` \u2713 Game "${finalTitle}" deployed across ${platformsToDeploy.length} platform(s)`));
|
|
287
|
+
console.log(` Status: ${formatStatus(finalStatus)}`);
|
|
288
|
+
console.log(` Platforms: ${platformsToDeploy.join(', ')}`);
|
|
289
|
+
if (finalSlug) console.log(brand.teal(` Slug: ${finalSlug}`));
|
|
290
|
+
if (finalGameId) console.log(brand.teal(` Game ID: ${finalGameId}`));
|
|
291
|
+
|
|
292
|
+
if (finalGameId) {
|
|
293
|
+
await promptForImages(finalGameId);
|
|
211
294
|
}
|
|
212
295
|
|
|
296
|
+
console.log();
|
|
297
|
+
console.log(brand.teal(' Run "myvillage status" to check the review status.'));
|
|
298
|
+
console.log();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Deploy one specific platform. Returns { ok, gameId, slug, status, title } or
|
|
303
|
+
* { ok: false } and writes a user-readable error to the console.
|
|
304
|
+
*/
|
|
305
|
+
async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverride }) {
|
|
306
|
+
const buildDir = join(projectDir, 'builds', platform);
|
|
213
307
|
const filePaths = collectFilePaths(buildDir);
|
|
214
308
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
309
|
+
// GameKit SDK preflight: only runs on iOS/Android (the SDK-based AssetBundle
|
|
310
|
+
// path) and only when the project declares an sdkVersion in myvillage.json.
|
|
311
|
+
if ((platform === 'ios' || platform === 'android') && mvConfig.sdkVersion) {
|
|
312
|
+
const preflightSpinner = villageSpinner(`[${platform}] Running GameKit preflight...`).start();
|
|
313
|
+
const preflightResult = await preflightBundles({
|
|
314
|
+
buildDir,
|
|
315
|
+
sdkVersion: mvConfig.sdkVersion,
|
|
316
|
+
platform,
|
|
317
|
+
});
|
|
318
|
+
if (preflightResult.skipped) {
|
|
319
|
+
if (preflightResult.fatal) {
|
|
320
|
+
preflightSpinner.fail(`[${platform}] Preflight could not run: ${preflightResult.reason}`);
|
|
321
|
+
return { ok: false };
|
|
322
|
+
}
|
|
323
|
+
preflightSpinner.warn(`[${platform}] Preflight skipped: ${preflightResult.reason}`);
|
|
324
|
+
} else if (!preflightResult.ok) {
|
|
325
|
+
preflightSpinner.fail(`[${platform}] Preflight found ${preflightResult.violations.length} violation(s).`);
|
|
326
|
+
console.log();
|
|
327
|
+
for (const v of preflightResult.violations) {
|
|
328
|
+
console.log(chalk.red(` \u2717 ${v.kind}`));
|
|
329
|
+
if (v.bundle) console.log(brand.teal(` bundle: ${v.bundle}`));
|
|
330
|
+
if (v.assembly) console.log(brand.teal(` assembly: ${v.assembly}`));
|
|
331
|
+
if (v.className) console.log(brand.teal(` class: ${v.namespace ? v.namespace + '.' : ''}${v.className}`));
|
|
332
|
+
if (v.reason) console.log(` ${v.reason}`);
|
|
333
|
+
console.log();
|
|
334
|
+
}
|
|
335
|
+
console.log(brand.teal(' Fix these scripts to extend MissionBase / use SDK templates, rebuild, and retry.'));
|
|
336
|
+
console.log();
|
|
337
|
+
return { ok: false };
|
|
338
|
+
} else {
|
|
339
|
+
preflightSpinner.succeed(`[${platform}] Preflight passed (${preflightResult.bundlesChecked} bundle(s) checked).`);
|
|
340
|
+
}
|
|
218
341
|
}
|
|
219
342
|
|
|
220
|
-
const spinner = villageSpinner(`
|
|
343
|
+
const spinner = villageSpinner(`[${platform}] Packaging ${filePaths.length} files...`).start();
|
|
221
344
|
|
|
222
345
|
try {
|
|
223
|
-
spinner.text = `Packaging ${filePaths.length} files...`;
|
|
224
|
-
|
|
225
346
|
const formData = new FormData();
|
|
226
|
-
|
|
227
|
-
// Determine gameType based on platform
|
|
228
347
|
const gameType = platform === 'webgl' ? 'UNITY_WEBGL' : 'UNITY_NATIVE';
|
|
229
348
|
|
|
230
349
|
const metadata = {
|
|
@@ -235,11 +354,8 @@ async function deployUnityGame(projectDir, mvConfig, options) {
|
|
|
235
354
|
targetAge: mvConfig.targetAge || 'all',
|
|
236
355
|
buildPlatform: platform,
|
|
237
356
|
};
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (platform === 'webgl') {
|
|
241
|
-
metadata.entryFile = 'index.html';
|
|
242
|
-
}
|
|
357
|
+
if (mvConfig.sdkVersion) metadata.sdkVersion = mvConfig.sdkVersion;
|
|
358
|
+
if (platform === 'webgl') metadata.entryFile = 'index.html';
|
|
243
359
|
|
|
244
360
|
formData.append('metadata', JSON.stringify(metadata));
|
|
245
361
|
|
|
@@ -250,43 +366,40 @@ async function deployUnityGame(projectDir, mvConfig, options) {
|
|
|
250
366
|
formData.append('files', blob, relPath);
|
|
251
367
|
}
|
|
252
368
|
|
|
253
|
-
spinner.text =
|
|
369
|
+
spinner.text = `[${platform}] Uploading to MyVillageOS...`;
|
|
254
370
|
|
|
255
|
-
const gameIdOrNew = mvConfig.gameId || 'new';
|
|
371
|
+
const gameIdOrNew = gameIdOverride || mvConfig.gameId || 'new';
|
|
256
372
|
const result = await deployGame(gameIdOrNew, formData);
|
|
257
373
|
|
|
258
|
-
|
|
259
|
-
if (result.gameId && !mvConfig.gameId) {
|
|
374
|
+
if (result.gameId && !mvConfig.gameId && !gameIdOverride) {
|
|
260
375
|
saveGameIdToMyVillageJson(projectDir, result.gameId, result.slug);
|
|
261
376
|
}
|
|
262
377
|
|
|
263
|
-
|
|
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}`));
|
|
378
|
+
const finalGameId = result.gameId || gameIdOverride || mvConfig.gameId;
|
|
270
379
|
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
if (finalGameId) {
|
|
274
|
-
await promptForImages(finalGameId);
|
|
275
|
-
}
|
|
380
|
+
spinner.text = `[${platform}] Submitting for review...`;
|
|
381
|
+
const finalStatus = await autoSubmitForReview(finalGameId, result);
|
|
276
382
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
383
|
+
spinner.succeed(`[${platform}] deployed (${gameType})`);
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
ok: true,
|
|
387
|
+
gameId: finalGameId,
|
|
388
|
+
slug: result.slug,
|
|
389
|
+
status: finalStatus,
|
|
390
|
+
title: metadata.title,
|
|
391
|
+
};
|
|
280
392
|
} catch (err) {
|
|
281
393
|
if (err.response) {
|
|
282
394
|
const message = err.response.data?.error_description
|
|
283
395
|
|| err.response.data?.error
|
|
284
396
|
|| err.response.data?.message
|
|
285
397
|
|| 'Unknown server error';
|
|
286
|
-
spinner.fail(`Deployment failed: ${message}`);
|
|
398
|
+
spinner.fail(`[${platform}] Deployment failed: ${message}`);
|
|
287
399
|
} else {
|
|
288
|
-
spinner.fail(`Deployment failed: ${err.message}`);
|
|
400
|
+
spinner.fail(`[${platform}] Deployment failed: ${err.message}`);
|
|
289
401
|
}
|
|
402
|
+
return { ok: false };
|
|
290
403
|
}
|
|
291
404
|
}
|
|
292
405
|
|
package/src/index.js
CHANGED
|
@@ -178,6 +178,7 @@ export function run() {
|
|
|
178
178
|
program
|
|
179
179
|
.command('create-game')
|
|
180
180
|
.description('Create a new game project with interactive wizard')
|
|
181
|
+
.option('--unity-version <version>', 'Override the Unity Editor version for Unity scaffolds (e.g. 6000.0.76f1)')
|
|
181
182
|
.action(createGameCommand);
|
|
182
183
|
|
|
183
184
|
program
|
|
@@ -188,7 +189,7 @@ export function run() {
|
|
|
188
189
|
program
|
|
189
190
|
.command('deploy')
|
|
190
191
|
.description('Build and deploy your game to MyVillageOS')
|
|
191
|
-
.option('-p, --platform <platform>', 'Target platform (ios, android, webgl) —
|
|
192
|
+
.option('-p, --platform <platform>', 'Target Unity platform (ios, android, webgl). Optional — defaults to deploying every platform found in builds/.')
|
|
192
193
|
.action((options) => deployCommand(options));
|
|
193
194
|
|
|
194
195
|
program
|
package/src/utils/api.js
CHANGED
|
@@ -579,6 +579,16 @@ export async function submitGameForReview(gameId) {
|
|
|
579
579
|
return response.data;
|
|
580
580
|
}
|
|
581
581
|
|
|
582
|
+
// ── GameKit SDK preflight ──────────────────────────────
|
|
583
|
+
|
|
584
|
+
// Fetch the GUID allowlist for a specific SDK version. Public endpoint —
|
|
585
|
+
// used by deploy preflight before uploading a Unity bundle.
|
|
586
|
+
export async function getGameKitAllowlist(version) {
|
|
587
|
+
const client = getApiClient();
|
|
588
|
+
const response = await client.get(`/gamekit/versions/${encodeURIComponent(version)}/allowlist`);
|
|
589
|
+
return response.data;
|
|
590
|
+
}
|
|
591
|
+
|
|
582
592
|
// ── Village Agents (developer agents) ───────────────────
|
|
583
593
|
|
|
584
594
|
// AgentProfiles owned by the caller that aren't yet linked to a VillageAgent.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { existsSync, mkdtempSync, readdirSync, statSync, writeFileSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { dirname, join, resolve } from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { getGameKitAllowlist } from './api.js';
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
|
|
10
|
+
// Path to preflight.py — bundled inside the CLI package at tools/preflight/.
|
|
11
|
+
// Resolves relative to this file so it works whether the CLI is run from
|
|
12
|
+
// `node bin/myvillage.js` locally or from a globally-installed `myvillage`.
|
|
13
|
+
const PREFLIGHT_SCRIPT = resolve(__dirname, '../../tools/preflight/preflight.py');
|
|
14
|
+
|
|
15
|
+
const PYTHON_CANDIDATES = ['python3', 'python'];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Find the first usable Python interpreter on PATH.
|
|
19
|
+
* Returns the command name (e.g. "python3") or null if none works.
|
|
20
|
+
*/
|
|
21
|
+
async function findPython() {
|
|
22
|
+
for (const cmd of PYTHON_CANDIDATES) {
|
|
23
|
+
try {
|
|
24
|
+
const ok = await new Promise((resolveCheck) => {
|
|
25
|
+
const proc = spawn(cmd, ['--version'], { stdio: 'ignore' });
|
|
26
|
+
proc.on('error', () => resolveCheck(false));
|
|
27
|
+
proc.on('exit', (code) => resolveCheck(code === 0));
|
|
28
|
+
});
|
|
29
|
+
if (ok) return cmd;
|
|
30
|
+
} catch {
|
|
31
|
+
/* try next */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Find every Unity AssetBundle file under a directory (recursive). Bundles
|
|
39
|
+
* don't have a single canonical extension — Unity sometimes uses `.bundle`,
|
|
40
|
+
* sometimes no extension at all, sometimes `.unity3d`. We include files
|
|
41
|
+
* matching common patterns and exclude obvious non-bundles.
|
|
42
|
+
*/
|
|
43
|
+
function findBundleFiles(dir) {
|
|
44
|
+
const SKIP_EXT = new Set(['.manifest', '.meta', '.txt', '.json', '.md', '.png', '.jpg', '.jpeg', '.webp']);
|
|
45
|
+
const results = [];
|
|
46
|
+
function walk(d) {
|
|
47
|
+
for (const entry of readdirSync(d)) {
|
|
48
|
+
const full = join(d, entry);
|
|
49
|
+
const st = statSync(full);
|
|
50
|
+
if (st.isDirectory()) {
|
|
51
|
+
walk(full);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const lower = entry.toLowerCase();
|
|
55
|
+
const dotIdx = lower.lastIndexOf('.');
|
|
56
|
+
const ext = dotIdx >= 0 ? lower.slice(dotIdx) : '';
|
|
57
|
+
if (SKIP_EXT.has(ext)) continue;
|
|
58
|
+
if (lower.endsWith('.bundle') || lower.endsWith('.unity3d') || ext === '') {
|
|
59
|
+
results.push(full);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
walk(dir);
|
|
64
|
+
return results;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run preflight.py against one bundle. Returns the parsed JSON result.
|
|
69
|
+
*/
|
|
70
|
+
async function runPreflight(python, bundlePath, allowlistPath) {
|
|
71
|
+
return new Promise((resolveCall, rejectCall) => {
|
|
72
|
+
const proc = spawn(python, [PREFLIGHT_SCRIPT, bundlePath, allowlistPath], {
|
|
73
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
74
|
+
});
|
|
75
|
+
let stdout = '';
|
|
76
|
+
let stderr = '';
|
|
77
|
+
proc.stdout.on('data', (b) => { stdout += b.toString(); });
|
|
78
|
+
proc.stderr.on('data', (b) => { stderr += b.toString(); });
|
|
79
|
+
proc.on('error', rejectCall);
|
|
80
|
+
proc.on('exit', (code) => {
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(stdout);
|
|
84
|
+
} catch {
|
|
85
|
+
rejectCall(new Error(`preflight produced non-JSON output (exit ${code}): ${stderr || stdout}`));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
resolveCall({ exitCode: code, ...parsed });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Run preflight against all AssetBundles in a build directory.
|
|
95
|
+
*
|
|
96
|
+
* Returns:
|
|
97
|
+
* { skipped: true, reason } — bundles couldn't be checked (no SDK, no Python, etc.)
|
|
98
|
+
* { ok: true, bundlesChecked } — all bundles passed
|
|
99
|
+
* { ok: false, violations, bundle } — at least one violation found
|
|
100
|
+
*/
|
|
101
|
+
export async function preflightBundles({ buildDir, sdkVersion, platform }) {
|
|
102
|
+
if (!sdkVersion) {
|
|
103
|
+
return { skipped: true, reason: 'No sdkVersion in myvillage.json — preflight only runs for SDK-based games.' };
|
|
104
|
+
}
|
|
105
|
+
if (platform !== 'ios' && platform !== 'android') {
|
|
106
|
+
return { skipped: true, reason: `Preflight skipped for platform "${platform}" (only runs on iOS/Android AssetBundles).` };
|
|
107
|
+
}
|
|
108
|
+
if (!existsSync(PREFLIGHT_SCRIPT)) {
|
|
109
|
+
return { skipped: true, reason: `preflight.py not found at ${PREFLIGHT_SCRIPT} — CLI install may be incomplete.` };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const python = await findPython();
|
|
113
|
+
if (!python) {
|
|
114
|
+
return {
|
|
115
|
+
skipped: true,
|
|
116
|
+
reason: 'Python 3 not found on PATH. Install Python 3.9+ and then run: pip install UnityPy==1.25.0',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let allowlistResponse;
|
|
121
|
+
try {
|
|
122
|
+
allowlistResponse = await getGameKitAllowlist(sdkVersion);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return {
|
|
125
|
+
skipped: true,
|
|
126
|
+
reason: `Failed to fetch GameKit allowlist for SDK ${sdkVersion}: ${err.message}. Upload aborted — re-run with network access.`,
|
|
127
|
+
fatal: true,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'myvillage-preflight-'));
|
|
132
|
+
const allowlistPath = join(tmpDir, 'allowlist.json');
|
|
133
|
+
writeFileSync(allowlistPath, JSON.stringify(allowlistResponse.allowlist));
|
|
134
|
+
|
|
135
|
+
const bundles = findBundleFiles(buildDir);
|
|
136
|
+
if (bundles.length === 0) {
|
|
137
|
+
return { skipped: true, reason: `No AssetBundle files found in ${buildDir}. Did the Unity build run?` };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const allViolations = [];
|
|
141
|
+
let bundlesChecked = 0;
|
|
142
|
+
for (const bundle of bundles) {
|
|
143
|
+
try {
|
|
144
|
+
const result = await runPreflight(python, bundle, allowlistPath);
|
|
145
|
+
bundlesChecked++;
|
|
146
|
+
if (result.violations && result.violations.length > 0) {
|
|
147
|
+
for (const v of result.violations) allViolations.push({ ...v, bundle });
|
|
148
|
+
}
|
|
149
|
+
if (result.status === 'error') {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
fatal: true,
|
|
153
|
+
violations: [{ kind: 'preflight_error', bundle, reason: result.error || 'unknown' }],
|
|
154
|
+
bundlesChecked,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
fatal: true,
|
|
161
|
+
violations: [{ kind: 'preflight_crash', bundle, reason: err.message }],
|
|
162
|
+
bundlesChecked,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
ok: allViolations.length === 0,
|
|
169
|
+
violations: allViolations,
|
|
170
|
+
bundlesChecked,
|
|
171
|
+
};
|
|
172
|
+
}
|