@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "1.51.0",
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('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
+ 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(` 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')}`);
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(` 4. ${brand.teal('Build WebGL to')} ${brand.gold('builds/webgl/')}`);
211
- console.log(` 5. ${brand.gold('cd ' + slug + ' && myvillage deploy --platform webgl')}`);
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 integration guide.`));
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.');
@@ -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
- console.log(brand.green(` \u2713 Game "${metadata.title}" submitted for review`));
158
- console.log(` Status: ${formatStatus(result.status)}`);
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
- async function deployUnityGame(projectDir, mvConfig, options) {
190
- const platform = options.platform;
220
+ const VALID_UNITY_PLATFORMS = ['ios', 'android', 'webgl'];
191
221
 
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>'));
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
- 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;
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
- // Determine build directory
205
- const buildDir = join(projectDir, 'builds', platform);
262
+ console.log(brand.teal(` Deploying ${platformsToDeploy.length} platform(s): ${platformsToDeploy.join(', ')}`));
263
+ console.log();
206
264
 
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;
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
- if (filePaths.length === 0) {
216
- console.log(chalk.red(` \u2717 No files found in builds/${platform}/`));
217
- return;
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(`Deploying ${platform} build to MyVillageOS...`).start();
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
- // WebGL builds have an index.html entry file
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 = 'Uploading to MyVillageOS...';
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
- // Save gameId back to myvillage.json
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
- 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}`));
378
+ const finalGameId = result.gameId || gameIdOverride || mvConfig.gameId;
270
379
 
271
- // Prompt for thumbnail/banner images
272
- const finalGameId = result.gameId || mvConfig.gameId;
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
- console.log();
278
- console.log(brand.teal(' Run "myvillage status" to check the review status.'));
279
- console.log();
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) — required for Unity games')
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
+ }