@myvillage/cli 2.2.0 → 2.4.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": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -103,7 +103,7 @@ export async function datasetAppendCommand(options) {
103
103
  rows = readRowsFromFile(options.file);
104
104
  } else if (options.text !== undefined || options.features !== undefined) {
105
105
  const row = { split: options.split || 'train' };
106
- row.features = options.features !== undefined ? parseFeatures(options.features) : options.text;
106
+ row.features = options.features !== undefined ? parseFeatures(options.features, manifest.modality) : options.text;
107
107
  if (options.label !== undefined) row.label = options.label;
108
108
  if (options.target !== undefined) row.target = Number(options.target);
109
109
  rows = [row];
@@ -111,15 +111,27 @@ export async function datasetAppendCommand(options) {
111
111
  throw new Error('Provide --image <path> (image datasets), --file <jsonl>, or --text/--features (with --label or --target).');
112
112
  }
113
113
 
114
+ if (options.replace && !options.file) {
115
+ throw new Error('--replace only works with --file (it swaps in the whole working set).');
116
+ }
117
+ const featureNames = options.featureNames !== undefined ? parseNameList(options.featureNames) : undefined;
118
+
114
119
  const spinner = options.json ? null : villageSpinner('Appending rows...').start();
115
- const result = appendRows(slug, rows, { source: 'cli' });
116
- if (spinner) spinner.stop();
120
+ let result;
121
+ try {
122
+ result = appendRows(slug, rows, { source: options.source || 'cli' }, {
123
+ replace: Boolean(options.replace),
124
+ featureNames,
125
+ });
126
+ } finally {
127
+ if (spinner) spinner.stop();
128
+ }
117
129
 
118
130
  if (options.json) {
119
131
  console.log(JSON.stringify(result, null, 2));
120
132
  return;
121
133
  }
122
- success(`Appended ${result.appended} row(s) to ${slug}`);
134
+ success(`${result.replaced ? 'Replaced the working set with' : 'Appended'} ${result.appended} row(s) ${result.replaced ? 'in' : 'to'} ${slug}`);
123
135
  info(` total: ${result.stats.rowCount} (train ${result.stats.bySplit.train} / val ${result.stats.bySplit.val})`);
124
136
  } catch (e) {
125
137
  error(e.message);
@@ -133,8 +145,13 @@ export async function datasetShowCommand(slug, options) {
133
145
  try {
134
146
  const manifest = readManifest(slug);
135
147
  if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
136
- const limit = options.limit ? parseInt(options.limit, 10) : 5;
137
- const sample = readRows(slug, { limit });
148
+ // --limit 0 returns every row (used by Studio's export).
149
+ const limit = options.limit !== undefined ? parseInt(options.limit, 10) : 5;
150
+ const version = options.frozen !== undefined ? parseInt(options.frozen, 10) : undefined;
151
+ if (version !== undefined && !manifest.versions.some((v) => v.version === version)) {
152
+ throw new Error(`Dataset "${slug}" has no frozen v${options.frozen}.`);
153
+ }
154
+ const sample = readRows(slug, { limit, version });
138
155
  if (options.json) {
139
156
  console.log(JSON.stringify({ manifest, sample }, null, 2));
140
157
  return;
@@ -206,7 +223,9 @@ export async function datasetRowUpdateCommand(options) {
206
223
  if (!slug) throw new Error('--dataset <slug> is required.');
207
224
  if (!options.id) throw new Error('--id <rowId> is required.');
208
225
  const updates = {};
209
- if (options.features !== undefined) updates.features = parseFeatures(options.features);
226
+ if (options.features !== undefined) {
227
+ updates.features = parseFeatures(options.features, readManifest(slug)?.modality);
228
+ }
210
229
  else if (options.text !== undefined) updates.features = options.text;
211
230
  if (options.label !== undefined) updates.label = options.label;
212
231
  if (options.target !== undefined) updates.target = Number(options.target);
@@ -244,8 +263,22 @@ function readRowsFromFile(path) {
244
263
  return rows;
245
264
  }
246
265
 
247
- function parseFeatures(raw) {
248
- // tabular features may be passed as a comma/JSON numeric vector
266
+ /** A JSON array of names, or a comma-separated list. */
267
+ function parseNameList(raw) {
268
+ const trimmed = String(raw).trim();
269
+ if (trimmed.startsWith('[')) {
270
+ const parsed = JSON.parse(trimmed);
271
+ if (!Array.isArray(parsed)) throw new Error('--feature-names must be a list.');
272
+ return parsed.map(String);
273
+ }
274
+ return trimmed.split(',').map((s) => s.trim());
275
+ }
276
+
277
+ function parseFeatures(raw, modality) {
278
+ // Tabular features are a numeric vector, passed as JSON ("[12, 45]") or a
279
+ // comma list ("12, 45" or a single "12"). Anything that isn't a number stays
280
+ // NaN so row validation rejects it rather than storing it as text.
281
+ if (modality !== 'tabular') return raw;
249
282
  const trimmed = raw.trim();
250
283
  if (trimmed.startsWith('[')) {
251
284
  try {
@@ -254,10 +287,7 @@ function parseFeatures(raw) {
254
287
  /* fall through */
255
288
  }
256
289
  }
257
- if (/^-?\d.*,/.test(trimmed)) {
258
- return trimmed.split(',').map((n) => Number(n.trim()));
259
- }
260
- return raw;
290
+ return trimmed.split(',').map((n) => (n.trim() === '' ? NaN : Number(n.trim())));
261
291
  }
262
292
 
263
293
  function truncate(s, n) {
@@ -463,6 +463,13 @@ async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverr
463
463
  buildDir,
464
464
  sdkVersion: mvConfig.sdkVersion,
465
465
  platform,
466
+ onStatus: (text) => {
467
+ preflightSpinner.text = `[${platform}] ${text}`;
468
+ // Studio streams our stdout into its publish log and isn't a TTY, so
469
+ // the spinner's text changes never reach the developer there. Print a
470
+ // plain line too, otherwise the one-time UnityPy install looks like a hang.
471
+ if (!process.stderr.isTTY) console.log(brand.teal(` [${platform}] ${text}`));
472
+ },
466
473
  });
467
474
  if (preflightResult.skipped) {
468
475
  if (preflightResult.fatal) {
@@ -471,7 +478,18 @@ async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverr
471
478
  }
472
479
  preflightSpinner.warn(`[${platform}] Preflight skipped: ${preflightResult.reason}`);
473
480
  } else if (!preflightResult.ok) {
474
- preflightSpinner.fail(`[${platform}] Preflight found ${preflightResult.violations.length} violation(s).`);
481
+ // "preflight_error" / "preflight_crash" mean the scanner itself failed
482
+ // (couldn't parse the bundle, script crashed). That's on us, not on the
483
+ // developer's scripts — so don't tell them to rewrite their game.
484
+ const toolingFailures = preflightResult.violations.filter(
485
+ (v) => v.kind === 'preflight_error' || v.kind === 'preflight_crash',
486
+ );
487
+ const codeViolations = preflightResult.violations.filter((v) => !toolingFailures.includes(v));
488
+ if (codeViolations.length > 0) {
489
+ preflightSpinner.fail(`[${platform}] Preflight found ${codeViolations.length} violation(s).`);
490
+ } else {
491
+ preflightSpinner.fail(`[${platform}] Preflight could not scan the bundle.`);
492
+ }
475
493
  console.log();
476
494
  for (const v of preflightResult.violations) {
477
495
  console.log(chalk.red(` \u2717 ${v.kind}`));
@@ -481,7 +499,12 @@ async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverr
481
499
  if (v.reason) console.log(` ${v.reason}`);
482
500
  console.log();
483
501
  }
484
- console.log(brand.teal(' Fix these scripts to extend MissionBase / use SDK templates, rebuild, and retry.'));
502
+ if (codeViolations.length > 0) {
503
+ console.log(brand.teal(' Fix these scripts to extend MissionBase / use SDK templates, rebuild, and retry.'));
504
+ } else {
505
+ console.log(brand.teal(' This is a problem with the bundle scanner, not with your game.'));
506
+ console.log(brand.teal(' Rebuild the bundle in Unity and publish again. If it keeps happening, send the message above to the MyVillage team.'));
507
+ }
485
508
  console.log();
486
509
  return { ok: false };
487
510
  } else {
package/src/index.js CHANGED
@@ -1010,13 +1010,17 @@ export function run() {
1010
1010
  .option('--label <label>', 'Class label (classification)')
1011
1011
  .option('--target <number>', 'Numeric target (regression)')
1012
1012
  .option('--split <split>', 'train | val', 'train')
1013
+ .option('--replace', 'With --file: replace the working set instead of adding to it')
1014
+ .option('--source <source>', 'Provenance source stamped on each row (e.g. import:csv)')
1015
+ .option('--feature-names <list>', 'Tabular column names (JSON array or comma-separated)')
1013
1016
  .option('--json', 'Output raw JSON')
1014
1017
  .action(datasetAppendCommand);
1015
1018
 
1016
1019
  datasetCmd
1017
1020
  .command('show <slug>')
1018
1021
  .description('Show a dataset manifest and a sample of rows')
1019
- .option('--limit <n>', 'Number of sample rows to show', '5')
1022
+ .option('--limit <n>', 'Number of sample rows to show (0 = all)', '5')
1023
+ .option('--frozen <n>', 'Read frozen version n instead of the working set')
1020
1024
  .option('--json', 'Output raw JSON')
1021
1025
  .action(datasetShowCommand);
1022
1026
 
@@ -202,23 +202,50 @@ export function createDataset({ name, modality, taskType = 'classification', lab
202
202
  * Append rows to a dataset's working set, stamping provenance, validating
203
203
  * against the dataset's modality/task, and updating manifest stats.
204
204
  *
205
+ * Every row is validated before anything is written, so a bad row leaves the
206
+ * dataset untouched. With `replace`, the working set is overwritten instead of
207
+ * extended (frozen versions are never touched) and the label list is rebuilt
208
+ * from the new rows. `featureNames` names the columns of a tabular vector
209
+ * (e.g. from a spreadsheet header); once set, every tabular row must have
210
+ * exactly that many numbers.
211
+ *
205
212
  * @param {string} slug
206
213
  * @param {Array<{features:any, label?:string, target?:number, split?:string}>} rows
207
214
  * @param {object} provenance partial provenance ({ source, agentId, villagerId, villageId })
208
- * @returns {{datasetId:string, appended:number, stats:object}}
215
+ * @param {{replace?:boolean, featureNames?:string[]}} opts
216
+ * @returns {{datasetId:string, appended:number, replaced:boolean, stats:object}}
209
217
  */
210
- export function appendRows(slug, rows, provenance = {}) {
218
+ export function appendRows(slug, rows, provenance = {}, { replace = false, featureNames } = {}) {
211
219
  const manifest = readManifest(slug);
212
220
  if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
213
221
  if (!Array.isArray(rows) || rows.length === 0) {
214
222
  throw new Error('No rows to append.');
215
223
  }
216
- const source = provenance.source || 'cli';
217
- const lines = [];
218
- let appended = 0;
224
+ if (featureNames !== undefined) {
225
+ applyFeatureNames(manifest, featureNames, replace);
226
+ } else if (replace) {
227
+ delete manifest.featureNames;
228
+ }
219
229
 
220
- for (const raw of rows) {
221
- const row = normalizeRow(raw, manifest);
230
+ const existing = replace ? [] : readRows(slug);
231
+ // Without named columns, hold tabular rows to the width already on disk
232
+ // (the trainer needs every vector the same length).
233
+ let width = manifest.featureNames?.length;
234
+ if (width == null && manifest.modality === 'tabular') {
235
+ const first = existing.find((r) => Array.isArray(r.features));
236
+ if (first) width = first.features.length;
237
+ }
238
+
239
+ const source = provenance.source || 'cli';
240
+ const newRows = [];
241
+ rows.forEach((raw, i) => {
242
+ let row;
243
+ try {
244
+ row = normalizeRow(raw, manifest, width);
245
+ } catch (e) {
246
+ throw new Error(rows.length > 1 ? `Row ${i + 1}: ${e.message}` : e.message);
247
+ }
248
+ if (width == null && Array.isArray(row.features)) width = row.features.length;
222
249
  row.id = randomUUID();
223
250
  row.provenance = {
224
251
  source,
@@ -227,24 +254,47 @@ export function appendRows(slug, rows, provenance = {}) {
227
254
  ...(provenance.villageId ? { villageId: provenance.villageId } : {}),
228
255
  ts: nowISO(),
229
256
  };
230
- lines.push(JSON.stringify(row));
257
+ newRows.push(row);
258
+ });
231
259
 
232
- // stats
233
- manifest.stats.rowCount += 1;
234
- manifest.stats.bySplit[row.split] += 1;
235
- if (row.label != null) {
236
- manifest.stats.byLabel[row.label] = (manifest.stats.byLabel[row.label] || 0) + 1;
237
- if (!manifest.labels.includes(row.label)) manifest.labels.push(row.label);
238
- }
239
- appended += 1;
260
+ if (replace) manifest.labels = [];
261
+ for (const row of newRows) {
262
+ if (row.label != null && !manifest.labels.includes(row.label)) manifest.labels.push(row.label);
263
+ }
264
+ if (replace) {
265
+ writeWorkingSet(slug, manifest, newRows);
266
+ } else {
267
+ appendFileSync(getCurrentDataFile(slug), newRows.map((r) => JSON.stringify(r)).join('\n') + '\n');
268
+ manifest.stats = recomputeStats([...existing, ...newRows]);
269
+ writeManifest(slug, manifest);
240
270
  }
271
+ return { datasetId: slug, appended: newRows.length, replaced: replace, stats: manifest.stats };
272
+ }
241
273
 
242
- appendFileSync(getCurrentDataFile(slug), lines.join('\n') + '\n');
243
- writeManifest(slug, manifest);
244
- return { datasetId: slug, appended, stats: manifest.stats };
274
+ /** Swap the whole working set for `rows` (e.g. a spreadsheet import). */
275
+ export function replaceRows(slug, rows, provenance = {}, { featureNames } = {}) {
276
+ return appendRows(slug, rows, provenance, { replace: true, featureNames });
277
+ }
278
+
279
+ /** Set a tabular dataset's column names, refusing a mismatch on append. */
280
+ function applyFeatureNames(manifest, featureNames, replace) {
281
+ if (manifest.modality !== 'tabular') {
282
+ throw new Error('Feature names only apply to tabular datasets.');
283
+ }
284
+ const names = featureNames.map((n) => String(n).trim());
285
+ if (names.length === 0 || names.some((n) => !n)) {
286
+ throw new Error('Feature names must be non-empty.');
287
+ }
288
+ const current = manifest.featureNames;
289
+ if (!replace && current?.length && current.join('\u0000') !== names.join('\u0000')) {
290
+ throw new Error(
291
+ `These columns don't match the dataset. It expects: ${current.join(', ')}.`,
292
+ );
293
+ }
294
+ manifest.featureNames = names;
245
295
  }
246
296
 
247
- function normalizeRow(raw, manifest) {
297
+ function normalizeRow(raw, manifest, width) {
248
298
  if (raw == null || typeof raw !== 'object') {
249
299
  throw new Error('Each row must be an object.');
250
300
  }
@@ -266,6 +316,18 @@ function normalizeRow(raw, manifest) {
266
316
  );
267
317
  }
268
318
  }
319
+ if (manifest.modality === 'tabular') {
320
+ const f = raw.features;
321
+ if (!Array.isArray(f) || f.length === 0 || f.some((n) => typeof n !== 'number' || !Number.isFinite(n))) {
322
+ throw new Error('Tabular rows need features that are all numbers (e.g. [12, 45, 8]).');
323
+ }
324
+ if (width != null && f.length !== width) {
325
+ throw new Error(`Expected ${width} number(s) in each row, got ${f.length}.`);
326
+ }
327
+ }
328
+ if (manifest.modality === 'text' && (typeof raw.features !== 'string' || !raw.features.trim())) {
329
+ throw new Error('Text rows need some text.');
330
+ }
269
331
  const row = { features: raw.features, split };
270
332
  if (manifest.taskType === 'classification') {
271
333
  if (raw.label == null || String(raw.label).length === 0) {
@@ -273,7 +335,7 @@ function normalizeRow(raw, manifest) {
273
335
  }
274
336
  row.label = String(raw.label);
275
337
  } else {
276
- if (raw.target == null || Number.isNaN(Number(raw.target))) {
338
+ if (raw.target == null || raw.target === '' || !Number.isFinite(Number(raw.target))) {
277
339
  throw new Error('Regression rows require a numeric "target".');
278
340
  }
279
341
  row.target = Number(raw.target);
@@ -376,6 +438,7 @@ export function updateRow(slug, rowId, updates = {}) {
376
438
  if ((manifest.modality === 'image' || manifest.modality === 'audio') && updates.features !== undefined) {
377
439
  throw new Error(`Can't edit the asset of an ${manifest.modality} row — delete it and re-add.`);
378
440
  }
441
+ const sibling = rows.find((r, i) => i !== idx && Array.isArray(r.features));
379
442
  const merged = normalizeRow(
380
443
  {
381
444
  features: updates.features !== undefined ? updates.features : existing.features,
@@ -384,6 +447,7 @@ export function updateRow(slug, rowId, updates = {}) {
384
447
  target: updates.target !== undefined ? updates.target : existing.target,
385
448
  },
386
449
  manifest,
450
+ manifest.featureNames?.length ?? sibling?.features.length,
387
451
  );
388
452
  merged.id = existing.id;
389
453
  merged.provenance = { ...(existing.provenance || {}), editedAt: nowISO() };
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'child_process';
2
- import { existsSync, mkdtempSync, readdirSync, statSync, writeFileSync } from 'fs';
3
- import { tmpdir } from 'os';
2
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, statSync, writeFileSync } from 'fs';
3
+ import { homedir, tmpdir } from 'os';
4
4
  import { dirname, join, resolve } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { getGameKitAllowlist } from './api.js';
@@ -14,6 +14,91 @@ const PREFLIGHT_SCRIPT = resolve(__dirname, '../../tools/preflight/preflight.py'
14
14
 
15
15
  const PYTHON_CANDIDATES = ['python3', 'python'];
16
16
 
17
+ // The bundle scanner needs UnityPy. Rather than asking every game developer to
18
+ // pip-install it by hand (the #1 reason deploys failed with "preflight_error"),
19
+ // we install it once into a private venv under ~/.myvillage and reuse it.
20
+ const UNITYPY_SPEC = 'UnityPy==1.25.0';
21
+ const PREFLIGHT_VENV_DIR = join(homedir(), '.myvillage', 'tools', 'preflight-venv');
22
+
23
+ function venvPython(dir) {
24
+ return process.platform === 'win32' ? join(dir, 'Scripts', 'python.exe') : join(dir, 'bin', 'python');
25
+ }
26
+
27
+ /** Run a command, capture output, never throw. */
28
+ function runCapture(cmd, args) {
29
+ return new Promise((resolveRun) => {
30
+ let stdout = '';
31
+ let stderr = '';
32
+ let proc;
33
+ try {
34
+ proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
35
+ } catch (err) {
36
+ resolveRun({ code: -1, stdout, stderr: err.message });
37
+ return;
38
+ }
39
+ proc.stdout.on('data', (b) => { stdout += b.toString(); });
40
+ proc.stderr.on('data', (b) => { stderr += b.toString(); });
41
+ proc.on('error', (err) => resolveRun({ code: -1, stdout, stderr: err.message }));
42
+ proc.on('exit', (code) => resolveRun({ code, stdout, stderr }));
43
+ });
44
+ }
45
+
46
+ async function hasUnityPy(python) {
47
+ const r = await runCapture(python, ['-c', 'import UnityPy']);
48
+ return r.code === 0;
49
+ }
50
+
51
+ function lastLine(text) {
52
+ const lines = (text || '').trim().split(/\r?\n/).filter(Boolean);
53
+ return lines.length ? lines[lines.length - 1] : '';
54
+ }
55
+
56
+ /**
57
+ * Find a Python that can import UnityPy, installing it if needed.
58
+ *
59
+ * Order: our managed venv → a system Python that already has UnityPy →
60
+ * create the managed venv with the system Python and pip-install UnityPy.
61
+ * `onStatus(text)` is called with progress copy for the caller's spinner.
62
+ *
63
+ * Returns { python } on success, or { python: null, reason } when there's no
64
+ * usable interpreter — the caller decides whether that skips or blocks.
65
+ */
66
+ export async function ensurePreflightPython({ onStatus } = {}) {
67
+ const managed = venvPython(PREFLIGHT_VENV_DIR);
68
+ if (existsSync(managed) && await hasUnityPy(managed)) return { python: managed };
69
+
70
+ const system = await findPython();
71
+ if (!system) {
72
+ return {
73
+ python: null,
74
+ reason: 'Python 3 was not found on your PATH. Install Python 3.9+ from python.org (on Windows, tick "Add to PATH"), then publish again.',
75
+ };
76
+ }
77
+ if (await hasUnityPy(system)) return { python: system };
78
+
79
+ const manualHint = `${system} -m pip install "${UNITYPY_SPEC}"`;
80
+
81
+ onStatus?.('First publish from this computer: setting up the bundle scanner (one-time, about a minute)...');
82
+ mkdirSync(PREFLIGHT_VENV_DIR, { recursive: true });
83
+ const venv = await runCapture(system, ['-m', 'venv', PREFLIGHT_VENV_DIR]);
84
+ if (venv.code !== 0 || !existsSync(managed)) {
85
+ return {
86
+ python: null,
87
+ reason: `Could not create a Python environment at ${PREFLIGHT_VENV_DIR} (${lastLine(venv.stderr) || `exit ${venv.code}`}). Repair your Python install or run: ${manualHint}`,
88
+ };
89
+ }
90
+
91
+ onStatus?.('Installing the bundle scanner (UnityPy)...');
92
+ const pip = await runCapture(managed, ['-m', 'pip', 'install', '--disable-pip-version-check', '--quiet', UNITYPY_SPEC]);
93
+ if (pip.code !== 0 || !(await hasUnityPy(managed))) {
94
+ return {
95
+ python: null,
96
+ reason: `Could not install ${UNITYPY_SPEC} (${lastLine(pip.stderr) || `exit ${pip.code}`}). Check your network connection and retry, or run: ${manualHint}`,
97
+ };
98
+ }
99
+ return { python: managed };
100
+ }
101
+
17
102
  /**
18
103
  * Find the first usable Python interpreter on PATH.
19
104
  * Returns the command name (e.g. "python3") or null if none works.
@@ -94,11 +179,11 @@ async function runPreflight(python, bundlePath, allowlistPath) {
94
179
  * Run preflight against all AssetBundles in a build directory.
95
180
  *
96
181
  * Returns:
97
- * { skipped: true, reason } — bundles couldn't be checked (no SDK, no Python, etc.)
182
+ * { skipped: true, reason } — bundles couldn't be checked (no SDK, no usable Python, etc.)
98
183
  * { ok: true, bundlesChecked } — all bundles passed
99
184
  * { ok: false, violations, bundle } — at least one violation found
100
185
  */
101
- export async function preflightBundles({ buildDir, sdkVersion, platform }) {
186
+ export async function preflightBundles({ buildDir, sdkVersion, platform, onStatus }) {
102
187
  if (!sdkVersion) {
103
188
  return { skipped: true, reason: 'No sdkVersion in myvillage.json — preflight only runs for SDK-based games.' };
104
189
  }
@@ -109,12 +194,13 @@ export async function preflightBundles({ buildDir, sdkVersion, platform }) {
109
194
  return { skipped: true, reason: `preflight.py not found at ${PREFLIGHT_SCRIPT} — CLI install may be incomplete.` };
110
195
  }
111
196
 
112
- const python = await findPython();
197
+ // No usable Python (or UnityPy couldn't be installed) is a *tooling* gap on
198
+ // the developer's machine, never a problem with their game. Skip with a clear
199
+ // warning instead of blocking — the same outcome for "no Python at all" and
200
+ // "Python but no UnityPy", which used to diverge (one skipped, one failed).
201
+ const { python, reason: pythonReason } = await ensurePreflightPython({ onStatus });
113
202
  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
- };
203
+ return { skipped: true, reason: `${pythonReason} (Bundle scan skipped; the platform re-checks on upload.)` };
118
204
  }
119
205
 
120
206
  let allowlistResponse;
@@ -12,7 +12,7 @@ const GAME_ART_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'assets
12
12
 
13
13
  // GameKit SDK version that scaffolded Unity projects pin to.
14
14
  // Bump alongside CLI minor releases as the SDK contract evolves.
15
- const GAMEKIT_SDK_VERSION = '1.1.0-alpha.4';
15
+ const GAMEKIT_SDK_VERSION = '1.1.0-alpha.5';
16
16
  const GAMEKIT_SDK_URL =
17
17
  `https://github.com/MyVillage-Project-Technologies/myvillage-gamekit.git#v${GAMEKIT_SDK_VERSION}`;
18
18
 
@@ -1302,7 +1302,7 @@ export async function createUnityGameProject(targetDir, options) {
1302
1302
  // Unity project files. When the SDK is embedded it must NOT also be listed as
1303
1303
  // a manifest dependency (Unity would try to re-resolve it); when it isn't, the
1304
1304
  // git-URL entry is the fallback resolution path.
1305
- writeFileSync(join(targetDir, 'Packages/manifest.json'), generateUnityPackagesManifest(sdkEmbedded));
1305
+ writeFileSync(join(targetDir, 'Packages/manifest.json'), generateUnityPackagesManifest(sdkEmbedded, unityVersion));
1306
1306
  writeFileSync(join(targetDir, 'ProjectSettings/ProjectVersion.txt'), generateUnityProjectVersion(unityVersion));
1307
1307
 
1308
1308
  // Starter mission. New devs subclass MissionBase here.
@@ -1367,7 +1367,59 @@ public static class MyVillageOrientation
1367
1367
 
1368
1368
  // ── Unity (GameKit SDK) ─────────────────────────────────────────────
1369
1369
 
1370
- function generateUnityPackagesManifest(sdkEmbedded = true) {
1370
+ // Unity's built-in engine modules. A project created through Unity Hub lists
1371
+ // every one of these in Packages/manifest.json; a manifest that omits them
1372
+ // DISABLES them. An earlier version of this scaffold wrote only the registry
1373
+ // packages, so fresh projects had no Particle System, Terrain, Animation,
1374
+ // Tilemap, Physics2D, Video, ... and developers had to hand-restore them.
1375
+ const UNITY_BUILTIN_MODULES = [
1376
+ 'accessibility',
1377
+ 'ai',
1378
+ 'androidjni',
1379
+ 'animation',
1380
+ 'assetbundle',
1381
+ 'audio',
1382
+ 'cloth',
1383
+ 'director',
1384
+ 'imageconversion',
1385
+ 'imgui',
1386
+ 'jsonserialize',
1387
+ 'particlesystem',
1388
+ 'physics',
1389
+ 'physics2d',
1390
+ 'screencapture',
1391
+ 'terrain',
1392
+ 'terrainphysics',
1393
+ 'tilemap',
1394
+ 'ui',
1395
+ 'uielements',
1396
+ 'umbra',
1397
+ 'unityanalytics',
1398
+ 'unitywebrequest',
1399
+ 'unitywebrequestassetbundle',
1400
+ 'unitywebrequestaudio',
1401
+ 'unitywebrequesttexture',
1402
+ 'unitywebrequestwww',
1403
+ 'vehicles',
1404
+ 'video',
1405
+ 'vr',
1406
+ 'wind',
1407
+ 'xr',
1408
+ ];
1409
+
1410
+ // Visual Scripting version pinned in the scaffold manifest. Keep in sync with
1411
+ // the `com.unity.visualscripting` dependency in the GameKit's package.json —
1412
+ // GameKit's MyVillage.GameKit.VisualScripting assembly references it, and an
1413
+ // embedded package's own dependency isn't enough on its own for Unity to keep
1414
+ // it resolved, so the manifest lists it explicitly.
1415
+ const UNITY_VISUAL_SCRIPTING_VERSION = '1.9.4';
1416
+
1417
+ function unityMajor(unityVersion) {
1418
+ const m = /^(\d+)\./.exec(unityVersion || '');
1419
+ return m ? Number(m[1]) : 6000;
1420
+ }
1421
+
1422
+ function generateUnityPackagesManifest(sdkEmbedded = true, unityVersion = UNITY_EDITOR_VERSION_FALLBACK) {
1371
1423
  const dependencies = {};
1372
1424
  // Only declare the SDK as a git dependency when it could NOT be embedded
1373
1425
  // locally. When embedded under Packages/com.myvillage.gamekit/, Unity
@@ -1378,11 +1430,25 @@ function generateUnityPackagesManifest(sdkEmbedded = true) {
1378
1430
  Object.assign(dependencies, {
1379
1431
  'com.unity.feature.development': '1.0.2',
1380
1432
  'com.unity.render-pipelines.universal': '17.2.0',
1381
- 'com.unity.textmeshpro': '3.0.6',
1382
1433
  'com.unity.inputsystem': '1.14.2',
1434
+ // GameKit ships Visual Scripting units (CompleteMission, ReadConfig, ...).
1435
+ // Without this package the SDK's VisualScripting assembly can't compile.
1436
+ 'com.unity.visualscripting': UNITY_VISUAL_SCRIPTING_VERSION,
1383
1437
  'com.unity.ide.rider': '3.0.38',
1384
1438
  'com.unity.ide.visualstudio': '2.0.22',
1385
1439
  });
1440
+ // Unity 6 folded TextMesh Pro into com.unity.ugui 2.x; declaring the old
1441
+ // standalone com.unity.textmeshpro there triggers a deprecation conflict.
1442
+ // Older editors still need the standalone package.
1443
+ if (unityMajor(unityVersion) >= 6000) {
1444
+ dependencies['com.unity.ugui'] = '2.0.0';
1445
+ } else {
1446
+ dependencies['com.unity.ugui'] = '1.0.0';
1447
+ dependencies['com.unity.textmeshpro'] = '3.0.6';
1448
+ }
1449
+ for (const mod of UNITY_BUILTIN_MODULES) {
1450
+ dependencies[`com.unity.modules.${mod}`] = '1.0.0';
1451
+ }
1386
1452
  return JSON.stringify({ dependencies }, null, 2) + '\n';
1387
1453
  }
1388
1454
 
@@ -16,7 +16,7 @@ Requires **Python 3.9+** and **UnityPy 1.25.0** (specifically — newer versions
16
16
  pip install -r requirements.txt
17
17
  ```
18
18
 
19
- The CLI checks for `python3` and `UnityPy` automatically and prints install hints if either is missing.
19
+ The CLI finds `python3`/`python` on PATH and, if UnityPy isn't importable, installs it once into a private venv at `~/.myvillage/tools/preflight-venv/` (see `ensurePreflightPython` in `src/utils/preflight.js`). If Python is missing or the install fails, preflight is skipped with a warning and the upload proceeds; the ed-platform re-checks on upload.
20
20
 
21
21
  ## Run directly (for debugging)
22
22