@myvillage/cli 1.64.6 → 1.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "1.64.6",
3
+ "version": "1.65.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,6 +39,7 @@
39
39
  "open": "^10.0.3",
40
40
  "ora": "^8.0.1",
41
41
  "p-limit": "^5.0.0",
42
+ "tar": "^7.5.16",
42
43
  "update-notifier": "^7.0.0",
43
44
  "yaml": "^2.3.0",
44
45
  "zod": "^3.22.0"
@@ -21,44 +21,53 @@ export async function agentLoop(agentName, { signal }) {
21
21
  const configPath = join(agentDir, 'agent.config.yaml');
22
22
  const config = parseYaml(readFileSync(configPath, 'utf-8'));
23
23
 
24
- // Determine provider and resolve API key
25
- const provider = config.brain?.provider || 'anthropic';
24
+ // Determine provider + model, honoring the app-wide default model in
25
+ // ~/.myvillage/config.json (written by MyVillage Studio's Settings). Precedence:
26
+ // per-agent brain.* > app-wide defaultModel > built-in default.
26
27
  const globalConfigPath = join(homedir(), '.myvillage', 'config.json');
27
- let apiKey;
28
+ let globalConfig = {};
29
+ if (existsSync(globalConfigPath)) {
30
+ try {
31
+ globalConfig = JSON.parse(readFileSync(globalConfigPath, 'utf-8'));
32
+ } catch { /* ignore */ }
33
+ }
34
+ const defaultModel = globalConfig.defaultModel || {};
35
+
36
+ const provider = config.brain?.provider || defaultModel.provider || 'anthropic';
37
+ const DEFAULT_MODEL_ID = {
38
+ anthropic: 'claude-sonnet-4-5-20250929',
39
+ openai: 'gpt-4o',
40
+ 'self-hosted': '',
41
+ };
42
+ const modelId =
43
+ config.brain?.model || defaultModel.model || DEFAULT_MODEL_ID[provider] || DEFAULT_MODEL_ID.anthropic;
28
44
 
45
+ // Create provider and model
46
+ let model;
29
47
  if (provider === 'openai') {
30
- apiKey = process.env.OPENAI_API_KEY;
31
- if (!apiKey && existsSync(globalConfigPath)) {
32
- try {
33
- const globalConfig = JSON.parse(readFileSync(globalConfigPath, 'utf-8'));
34
- apiKey = globalConfig.openaiApiKey;
35
- } catch { /* ignore */ }
36
- }
48
+ const apiKey = process.env.OPENAI_API_KEY || globalConfig.openaiApiKey;
37
49
  if (!apiKey) {
38
50
  logActivity(agentDir, { type: 'error', error: 'No OpenAI API key configured' });
39
- throw new Error('No OpenAI API key configured. Set OPENAI_API_KEY or run: myvillage agent start ' + agentName);
51
+ throw new Error('No OpenAI API key configured. Set OPENAI_API_KEY or configure a model in MyVillage Studio settings.');
40
52
  }
41
- } else {
42
- apiKey = process.env.ANTHROPIC_API_KEY;
43
- if (!apiKey && existsSync(globalConfigPath)) {
44
- try {
45
- const globalConfig = JSON.parse(readFileSync(globalConfigPath, 'utf-8'));
46
- apiKey = globalConfig.anthropicApiKey;
47
- } catch { /* ignore */ }
53
+ const openai = createOpenAI({ apiKey });
54
+ model = openai(modelId);
55
+ } else if (provider === 'self-hosted') {
56
+ // Self-hosted = any OpenAI-compatible endpoint.
57
+ const baseURL = config.brain?.baseURL || defaultModel.baseURL;
58
+ if (!baseURL) {
59
+ logActivity(agentDir, { type: 'error', error: 'No base URL configured for self-hosted model' });
60
+ throw new Error('No base URL configured for the self-hosted model. Set it in MyVillage Studio settings.');
48
61
  }
62
+ const apiKey = process.env.SELF_HOSTED_API_KEY || globalConfig.selfHostedApiKey || 'not-needed';
63
+ const openai = createOpenAI({ apiKey, baseURL });
64
+ model = openai(modelId);
65
+ } else {
66
+ const apiKey = process.env.ANTHROPIC_API_KEY || globalConfig.anthropicApiKey;
49
67
  if (!apiKey) {
50
68
  logActivity(agentDir, { type: 'error', error: 'No Anthropic API key configured' });
51
- throw new Error('No Anthropic API key configured. Set ANTHROPIC_API_KEY or run: myvillage agent start ' + agentName);
69
+ throw new Error('No Anthropic API key configured. Set ANTHROPIC_API_KEY or configure a model in MyVillage Studio settings.');
52
70
  }
53
- }
54
-
55
- // Create provider and model
56
- const modelId = config.brain?.model || (provider === 'openai' ? 'gpt-4o' : 'claude-sonnet-4-5-20250929');
57
- let model;
58
- if (provider === 'openai') {
59
- const openai = createOpenAI({ apiKey });
60
- model = openai(modelId);
61
- } else {
62
71
  const anthropic = createAnthropic({ apiKey });
63
72
  model = anthropic(modelId);
64
73
  }
@@ -8,54 +8,117 @@ import inquirer from 'inquirer';
8
8
  import { isAuthenticated } from '../utils/auth.js';
9
9
  import { createGameProject, createUnityGameProject } from '../utils/templates.js';
10
10
 
11
+ // ── Option normalizers (for non-interactive / Studio-driven creation) ────────
12
+ function normOrientation(v) {
13
+ if (!v) return null;
14
+ v = String(v).toLowerCase();
15
+ return v === 'portrait' || v === 'landscape' ? v : null;
16
+ }
17
+ function normEngine(v) {
18
+ if (!v) return null;
19
+ v = String(v).toLowerCase();
20
+ if (v === 'threejs' || v === 'three' || v === 'js') return 'threejs';
21
+ if (v === 'unity') return 'unity';
22
+ return null;
23
+ }
24
+ function normType(v) {
25
+ if (!v) return null;
26
+ v = String(v).toLowerCase();
27
+ return ['quiz', 'exploration', 'narrative', 'custom'].includes(v) ? v : null;
28
+ }
29
+ function normAge(v) {
30
+ if (!v) return null;
31
+ v = String(v).toLowerCase();
32
+ return ['under-5', '6-12', '13-and-over'].includes(v) ? v : null;
33
+ }
34
+ function normCategory(v) {
35
+ if (!v) return null;
36
+ v = String(v).toLowerCase();
37
+ return ['education', 'action', 'adventure', 'puzzle', 'other'].includes(v) ? v : null;
38
+ }
39
+ function fail(json, msg) {
40
+ if (json) console.log(JSON.stringify({ ok: false, error: msg }));
41
+ else console.log(chalk.red(`\n ✗ ${msg}`));
42
+ }
43
+
11
44
  export async function createGameCommand(options = {}) {
12
- // Check authentication
45
+ const json = !!options.json;
46
+ // In JSON/non-interactive mode we never prompt — missing required flags error out.
47
+ const nonInteractive = json || !!options.yes || !!options.nonInteractive;
48
+
13
49
  if (!isAuthenticated()) {
14
- console.log(chalk.red(' \u2717 Authentication required. Run \'myvillage login\' first.'));
15
- return;
50
+ return fail(json, "Authentication required. Run 'myvillage login' first.");
16
51
  }
17
52
 
18
53
  // Orientation first — it decides where the game can play.
19
- // Portrait → muni mobile app + web (mvp-video-games)
20
- // Landscape → web only (mvp-video-games)
21
- const { orientation } = await inquirer.prompt([
22
- {
23
- type: 'list',
24
- name: 'orientation',
25
- message: 'Where will this game play?',
26
- choices: [
27
- { name: 'Portrait — muni mobile app + web (plays everywhere)', value: 'portrait' },
28
- { name: 'Landscape — web only (mvp-video-games)', value: 'landscape' },
29
- ],
30
- },
31
- ]);
54
+ let orientation = normOrientation(options.orientation);
55
+ if (!orientation) {
56
+ if (nonInteractive) return fail(json, 'Missing --orientation (portrait|landscape)');
57
+ orientation = (
58
+ await inquirer.prompt([
59
+ {
60
+ type: 'list',
61
+ name: 'orientation',
62
+ message: 'Where will this game play?',
63
+ choices: [
64
+ { name: 'Portrait — muni mobile app + web (plays everywhere)', value: 'portrait' },
65
+ { name: 'Landscape — web only (mvp-video-games)', value: 'landscape' },
66
+ ],
67
+ },
68
+ ])
69
+ ).orientation;
70
+ }
32
71
 
33
72
  // Engine choice
34
- const { engine } = await inquirer.prompt([
35
- {
36
- type: 'list',
37
- name: 'engine',
38
- message: 'Which engine are you using?',
39
- choices: [
40
- { name: 'JS / Three.js — web build (runs in a browser / WebView)', value: 'threejs' },
73
+ let engine = normEngine(options.engine);
74
+ if (!engine) {
75
+ if (nonInteractive) return fail(json, 'Missing --engine (threejs|unity)');
76
+ engine = (
77
+ await inquirer.prompt([
41
78
  {
42
- name: orientation === 'landscape'
43
- ? 'Unity — WebGL build (runs in browser)'
44
- : 'Unity — native build for muni (can also build WebGL for web)',
45
- value: 'unity',
79
+ type: 'list',
80
+ name: 'engine',
81
+ message: 'Which engine are you using?',
82
+ choices: [
83
+ { name: 'JS / Three.js — web build (runs in a browser / WebView)', value: 'threejs' },
84
+ {
85
+ name:
86
+ orientation === 'landscape'
87
+ ? 'Unity — WebGL build (runs in browser)'
88
+ : 'Unity — native build for muni (can also build WebGL for web)',
89
+ value: 'unity',
90
+ },
91
+ ],
46
92
  },
47
- ],
48
- },
49
- ]);
93
+ ])
94
+ ).engine;
95
+ }
50
96
 
51
97
  if (engine === 'unity') {
52
98
  return createUnityGame({ ...options, orientation });
53
99
  }
54
100
 
55
- // ── Three.js path (existing flow) ──
101
+ // ── Three.js path ──
102
+ const provided = {
103
+ type: normType(options.type),
104
+ name: options.name && String(options.name).trim() ? String(options.name).trim() : null,
105
+ description: options.description,
106
+ ageGroup: normAge(options.targetAge || options.ageGroup),
107
+ };
56
108
 
57
- const answers = await inquirer.prompt([
58
- {
109
+ const needType = !provided.type;
110
+ const needName = !provided.name;
111
+ const needAge = !provided.ageGroup;
112
+ if (nonInteractive && (needType || needName || needAge)) {
113
+ const miss = [needType && '--type', needName && '--name', needAge && '--target-age']
114
+ .filter(Boolean)
115
+ .join(', ');
116
+ return fail(json, `Missing required option(s): ${miss}`);
117
+ }
118
+
119
+ const prompts = [];
120
+ if (needType)
121
+ prompts.push({
59
122
  type: 'list',
60
123
  name: 'type',
61
124
  message: 'What type of game would you like to create?',
@@ -65,24 +128,24 @@ export async function createGameCommand(options = {}) {
65
128
  { name: 'Narrative Game - Story-driven experience', value: 'narrative' },
66
129
  { name: 'Custom - Start with base Three.js template', value: 'custom' },
67
130
  ],
68
- },
69
- {
131
+ });
132
+ if (needName)
133
+ prompts.push({
70
134
  type: 'input',
71
135
  name: 'name',
72
136
  message: 'Game name:',
73
137
  default: 'My Game',
74
- validate: (input) => {
75
- if (!input.trim()) return 'Game name is required.';
76
- return true;
77
- },
78
- },
79
- {
138
+ validate: (input) => (input.trim() ? true : 'Game name is required.'),
139
+ });
140
+ if (provided.description === undefined && !nonInteractive)
141
+ prompts.push({
80
142
  type: 'input',
81
143
  name: 'description',
82
144
  message: 'Game description:',
83
145
  default: 'An educational game built with Three.js',
84
- },
85
- {
146
+ });
147
+ if (needAge)
148
+ prompts.push({
86
149
  type: 'list',
87
150
  name: 'ageGroup',
88
151
  message: 'Target age group:',
@@ -91,81 +154,113 @@ export async function createGameCommand(options = {}) {
91
154
  { name: '6-12', value: '6-12' },
92
155
  { name: '13 and Over', value: '13-and-over' },
93
156
  ],
94
- },
95
- ]);
157
+ });
96
158
 
97
- const slug = answers.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
98
- const targetDir = resolve(process.cwd(), slug);
159
+ const answers = prompts.length ? await inquirer.prompt(prompts) : {};
160
+ const game = {
161
+ type: provided.type || answers.type,
162
+ name: provided.name || answers.name,
163
+ description: provided.description ?? answers.description ?? 'An educational game built with Three.js',
164
+ ageGroup: provided.ageGroup || answers.ageGroup,
165
+ };
166
+
167
+ const slug = game.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
168
+ const targetDir = resolve(options.dir ? resolve(options.dir) : process.cwd(), slug);
99
169
 
100
170
  if (existsSync(targetDir)) {
101
- console.log(chalk.red(`\n \u2717 Directory "${slug}" already exists. Choose a different name or remove the existing directory.`));
102
- return;
171
+ return fail(json, `Directory "${slug}" already exists. Choose a different name or remove it.`);
103
172
  }
104
173
 
105
- const spinner = villageSpinner('Creating game project...').start();
174
+ const spinner = json ? null : villageSpinner('Creating game project...').start();
106
175
 
107
176
  try {
108
177
  createGameProject(targetDir, {
109
- name: answers.name,
110
- description: answers.description,
111
- type: answers.type,
112
- ageGroup: answers.ageGroup,
178
+ name: game.name,
179
+ description: game.description,
180
+ type: game.type,
181
+ ageGroup: game.ageGroup,
113
182
  orientation,
114
183
  });
115
184
 
116
- spinner.text = 'Installing dependencies...';
117
-
118
- execSync('npm install', {
119
- cwd: targetDir,
120
- stdio: 'pipe',
121
- });
185
+ if (spinner) spinner.text = 'Installing dependencies...';
186
+ execSync('npm install', { cwd: targetDir, stdio: 'pipe' });
187
+ if (spinner) spinner.succeed(`Game "${game.name}" created successfully!`);
122
188
 
123
- spinner.succeed(`Game "${answers.name}" created successfully!`);
189
+ if (json) {
190
+ console.log(
191
+ JSON.stringify({
192
+ ok: true,
193
+ slug,
194
+ dir: targetDir,
195
+ name: game.name,
196
+ type: game.type,
197
+ orientation,
198
+ ageGroup: game.ageGroup,
199
+ engine: 'threejs',
200
+ }),
201
+ );
202
+ return;
203
+ }
124
204
 
125
205
  console.log();
126
- console.log(brand.green(` \u2713 Game "${answers.name}" created successfully!`));
206
+ console.log(brand.green(` ✓ Game "${game.name}" created successfully!`));
127
207
  console.log();
128
208
  console.log(brand.teal(' Next steps:'));
129
209
  console.log();
130
210
  console.log(` ${brand.gold('cd')} ${slug}`);
131
- console.log(` ${brand.gold('npm install')} ${brand.teal('- Install dependencies (already run for you; re-run after cloning)')}`);
132
211
  console.log(` ${brand.gold('npm run dev')} ${brand.teal('- Start development server')}`);
133
212
  console.log(` ${brand.gold('npm run build')} ${brand.teal('- Build for production')}`);
134
213
  console.log(` ${brand.gold('myvillage deploy')} ${brand.teal('- Deploy to MyVillageOS')}`);
135
214
  console.log();
136
- console.log(brand.teal(` Game type: ${answers.type} | Orientation: ${orientation} | Age group: ${answers.ageGroup}`));
215
+ console.log(brand.teal(` Game type: ${game.type} | Orientation: ${orientation} | Age group: ${game.ageGroup}`));
137
216
  console.log(brand.teal(` Cover art: drop thumbnail.png / banner.png in ${slug}/assets/ before deploy.`));
138
217
  console.log();
139
218
  } catch (err) {
140
- spinner.fail('Failed to create game project.');
141
- console.error(chalk.red(` ${err.message}`));
219
+ if (spinner) spinner.fail('Failed to create game project.');
220
+ fail(json, err.message);
142
221
  }
143
222
  }
144
223
 
145
224
  // ── Unity path ──
146
-
147
225
  async function createUnityGame(options = {}) {
148
- // orientation is decided in the top-level wizard; default portrait for Unity.
149
- const orientation = options.orientation || 'portrait';
226
+ const json = !!options.json;
227
+ const nonInteractive = json || !!options.yes || !!options.nonInteractive;
228
+ const orientation = normOrientation(options.orientation) || 'portrait';
229
+
230
+ const provided = {
231
+ name: options.name && String(options.name).trim() ? String(options.name).trim() : null,
232
+ description: options.description,
233
+ category: normCategory(options.category),
234
+ ageGroup: normAge(options.targetAge || options.ageGroup),
235
+ };
236
+ const needName = !provided.name;
237
+ const needCategory = !provided.category;
238
+ const needAge = !provided.ageGroup;
239
+ if (nonInteractive && (needName || needCategory || needAge)) {
240
+ const miss = [needName && '--name', needCategory && '--category', needAge && '--target-age']
241
+ .filter(Boolean)
242
+ .join(', ');
243
+ return fail(json, `Missing required option(s): ${miss}`);
244
+ }
150
245
 
151
- const answers = await inquirer.prompt([
152
- {
246
+ const prompts = [];
247
+ if (needName)
248
+ prompts.push({
153
249
  type: 'input',
154
250
  name: 'name',
155
251
  message: 'Game name:',
156
252
  default: 'My Unity Game',
157
- validate: (input) => {
158
- if (!input.trim()) return 'Game name is required.';
159
- return true;
160
- },
161
- },
162
- {
253
+ validate: (input) => (input.trim() ? true : 'Game name is required.'),
254
+ });
255
+ if (provided.description === undefined && !nonInteractive)
256
+ prompts.push({
163
257
  type: 'input',
164
258
  name: 'description',
165
259
  message: 'Game description:',
166
260
  default: 'A Unity game for MyVillageOS',
167
- },
168
- {
261
+ });
262
+ if (needCategory)
263
+ prompts.push({
169
264
  type: 'list',
170
265
  name: 'category',
171
266
  message: 'Game category:',
@@ -176,8 +271,9 @@ async function createUnityGame(options = {}) {
176
271
  { name: 'Puzzle', value: 'puzzle' },
177
272
  { name: 'Other', value: 'other' },
178
273
  ],
179
- },
180
- {
274
+ });
275
+ if (needAge)
276
+ prompts.push({
181
277
  type: 'list',
182
278
  name: 'ageGroup',
183
279
  message: 'Target age group:',
@@ -186,59 +282,78 @@ async function createUnityGame(options = {}) {
186
282
  { name: '6-12', value: '6-12' },
187
283
  { name: '13 and Over', value: '13-and-over' },
188
284
  ],
189
- },
190
- ]);
285
+ });
286
+
287
+ const answers = prompts.length ? await inquirer.prompt(prompts) : {};
288
+ const game = {
289
+ name: provided.name || answers.name,
290
+ description: provided.description ?? answers.description ?? 'A Unity game for MyVillageOS',
291
+ category: provided.category || answers.category,
292
+ ageGroup: provided.ageGroup || answers.ageGroup,
293
+ };
191
294
 
192
- const slug = answers.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
193
- const targetDir = resolve(process.cwd(), slug);
295
+ const slug = game.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
296
+ const targetDir = resolve(options.dir ? resolve(options.dir) : process.cwd(), slug);
194
297
 
195
298
  if (existsSync(targetDir)) {
196
- console.log(chalk.red(`\n \u2717 Directory "${slug}" already exists.`));
197
- return;
299
+ return fail(json, `Directory "${slug}" already exists.`);
198
300
  }
199
301
 
200
- const spinner = villageSpinner('Creating Unity game project...').start();
302
+ const spinner = json ? null : villageSpinner('Creating Unity game project...').start();
201
303
 
202
304
  try {
203
305
  const scaffold = await createUnityGameProject(targetDir, {
204
- name: answers.name,
205
- description: answers.description,
206
- type: answers.category,
207
- ageGroup: answers.ageGroup,
306
+ name: game.name,
307
+ description: game.description,
308
+ type: game.category,
309
+ ageGroup: game.ageGroup,
208
310
  orientation,
209
311
  unityVersion: options.unityVersion,
210
312
  });
211
313
 
212
- spinner.succeed(`Unity game "${answers.name}" created!`);
314
+ if (spinner) spinner.succeed(`Unity game "${game.name}" created!`);
315
+
316
+ if (json) {
317
+ console.log(
318
+ JSON.stringify({
319
+ ok: true,
320
+ slug,
321
+ dir: targetDir,
322
+ name: game.name,
323
+ category: game.category,
324
+ orientation,
325
+ ageGroup: game.ageGroup,
326
+ engine: 'unity',
327
+ unityVersion: scaffold.unityVersion,
328
+ sdkVersion: scaffold.sdkVersion,
329
+ sdkEmbedded: scaffold.sdkEmbedded,
330
+ }),
331
+ );
332
+ return;
333
+ }
213
334
 
214
335
  console.log();
215
- console.log(brand.green(` \u2713 Unity game "${answers.name}" created!`));
336
+ console.log(brand.green(` ✓ Unity game "${game.name}" created!`));
216
337
  console.log(brand.teal(` Unity: ${scaffold.unityVersion}`));
217
- console.log(brand.teal(` GameKit SDK: ${scaffold.sdkVersion}`));
338
+ console.log(brand.teal(` GameKit SDK: ${scaffold.sdkVersion}${scaffold.sdkEmbedded ? ' (bundled — no git needed)' : ''}`));
218
339
  console.log(brand.teal(` Orientation: ${orientation} (${orientation === 'landscape' ? 'web only' : 'muni mobile + web'})`));
219
340
  console.log();
341
+ if (!scaffold.sdkEmbedded) {
342
+ console.log(chalk.yellow(' ⚠ Could not download the GameKit SDK (offline?). The project falls back to a'));
343
+ console.log(chalk.yellow(' git-URL dependency, so opening it in Unity will require git to be installed.'));
344
+ console.log();
345
+ }
220
346
  console.log(brand.teal(' Next steps:'));
221
347
  console.log();
222
- console.log(` 1. ${brand.gold('Open this project in Unity Hub')} (Add \u2192 Add project from disk)`);
223
- console.log(` 2. ${brand.teal('Click menu')} ${brand.gold('MyVillage \u2192 Create Starter Mission Scene')}`);
224
- console.log(` 3. ${brand.teal('Press')} ${brand.gold('Play')} ${brand.teal('\u2014 your mission runs end-to-end in the editor')}`);
225
- 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)')}`);
226
- console.log();
227
- console.log(brand.teal(' Ready to ship:'));
228
- if (orientation === 'landscape') {
229
- 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/')}`);
230
- console.log(` 6. ${brand.gold('cd ' + slug + ' && myvillage deploy --platform webgl')}`);
231
- } else {
232
- console.log(` 5. ${brand.teal('Build AssetBundle:')} ${brand.gold('MyVillage \u2192 Build Mission Bundle')} ${brand.teal('(into builds/ios or builds/android)')}`);
233
- console.log(` 6. ${brand.gold('cd ' + slug + ' && myvillage deploy')} ${brand.teal('\u2014 ships every platform found in builds/')}`);
234
- console.log(` ${brand.teal('Optional: also build WebGL into builds/webgl/ to offer it on the web too.')}`);
235
- }
348
+ console.log(` 1. ${brand.gold('Open this project in Unity Hub')} (Add → Add project from disk)`);
349
+ console.log(` 2. ${brand.teal('Click menu')} ${brand.gold('MyVillage → Create Starter Mission Scene')}`);
350
+ console.log(` 3. ${brand.teal('Press')} ${brand.gold('Play')} ${brand.teal('— your mission runs end-to-end in the editor')}`);
236
351
  console.log();
237
352
  console.log(brand.teal(` Cover art: drop thumbnail.png / banner.png in ${slug}/assets/ before deploy.`));
238
353
  console.log(brand.teal(` See ${slug}/README.md for the full developer guide.`));
239
354
  console.log();
240
355
  } catch (err) {
241
- spinner.fail('Failed to create Unity game project.');
242
- console.error(chalk.red(` ${err.message}`));
356
+ if (spinner) spinner.fail('Failed to create Unity game project.');
357
+ fail(json, err.message);
243
358
  }
244
359
  }
@@ -67,10 +67,23 @@ function saveVillages(dir, isUnity, villageIds) {
67
67
  }
68
68
  }
69
69
 
70
+ // Non-interactive when driven by Studio / scripts (--json, --yes).
71
+ function isNonInteractive(options = {}) {
72
+ return !!(options.json || options.yes || options.nonInteractive);
73
+ }
74
+
70
75
  // Gather the submission's village visibility once per deploy. Returns an array
71
76
  // of village ids (possibly empty), or null if the developer aborted/failed —
72
- // the caller should stop the deploy in that case.
73
- async function gatherVillages(existingIds = []) {
77
+ // the caller should stop the deploy in that case. A `--villages a,b,c` flag (or
78
+ // non-interactive mode) skips the prompt.
79
+ async function gatherVillages(existingIds = [], options = {}) {
80
+ if (options.villages !== undefined) {
81
+ return String(options.villages)
82
+ .split(',')
83
+ .map((s) => s.trim())
84
+ .filter(Boolean);
85
+ }
86
+ if (isNonInteractive(options)) return existingIds; // keep prior selection, no prompt
74
87
  console.log();
75
88
  console.log(brand.teal(' Village visibility — who can see & play this game:'));
76
89
  const villageIds = await selectGameVillages(existingIds);
@@ -80,7 +93,7 @@ async function gatherVillages(existingIds = []) {
80
93
  // If the target game is currently PUBLISHED, a redeploy takes it offline and
81
94
  // sends the new build back through admin review. Confirm with the developer and
82
95
  // move it to DRAFT first. Returns true to proceed with the deploy, false to abort.
83
- async function confirmRedeployIfPublished(gameId) {
96
+ async function confirmRedeployIfPublished(gameId, options = {}) {
84
97
  if (!gameId) return true; // brand-new game — nothing live to disturb
85
98
 
86
99
  let game;
@@ -93,23 +106,26 @@ async function confirmRedeployIfPublished(gameId) {
93
106
 
94
107
  if (!game || game.status !== 'PUBLISHED') return true;
95
108
 
96
- console.log();
97
- console.log(chalk.yellow(` ! "${game.title}" is currently PUBLISHED (live).`));
98
- console.log(brand.teal(' Redeploying takes it offline and sends the new build back through'));
99
- console.log(brand.teal(' admin review before it can go live again.'));
100
- console.log();
109
+ if (!isNonInteractive(options)) {
110
+ console.log();
111
+ console.log(chalk.yellow(` ! "${game.title}" is currently PUBLISHED (live).`));
112
+ console.log(brand.teal(' Redeploying takes it offline and sends the new build back through'));
113
+ console.log(brand.teal(' admin review before it can go live again.'));
114
+ console.log();
101
115
 
102
- const { confirm } = await inquirer.prompt([{
103
- type: 'confirm',
104
- name: 'confirm',
105
- message: 'Take the live game offline and redeploy?',
106
- default: false,
107
- }]);
116
+ const { confirm } = await inquirer.prompt([{
117
+ type: 'confirm',
118
+ name: 'confirm',
119
+ message: 'Take the live game offline and redeploy?',
120
+ default: false,
121
+ }]);
108
122
 
109
- if (!confirm) {
110
- console.log(brand.teal('\n Deploy cancelled — game is still live.\n'));
111
- return false;
123
+ if (!confirm) {
124
+ console.log(brand.teal('\n Deploy cancelled — game is still live.\n'));
125
+ return false;
126
+ }
112
127
  }
128
+ // Non-interactive (--yes/--json): proceed to unpublish without prompting.
113
129
 
114
130
  const spinner = villageSpinner('Taking game offline (returning to draft)...').start();
115
131
  try {
@@ -196,16 +212,22 @@ async function autoSubmitForReview(gameId, deployResult) {
196
212
 
197
213
  // ── Three.js deploy (existing flow) ──
198
214
 
199
- async function deployThreeJsGame(projectDir, pkg) {
215
+ async function deployThreeJsGame(projectDir, pkg, options = {}) {
216
+ const json = !!options.json;
200
217
  // If this game is already live, confirm + unpublish before we rebuild/upload.
201
- if (!(await confirmRedeployIfPublished(pkg.myvillage.gameId))) return;
218
+ if (!(await confirmRedeployIfPublished(pkg.myvillage.gameId, options))) return;
202
219
 
203
220
  // Village visibility is part of the submission — gather it up front (before
204
221
  // the build) so it travels in the deploy metadata.
205
- const villageIds = await gatherVillages(pkg.myvillage.villages || []);
222
+ const villageIds = await gatherVillages(pkg.myvillage.villages || [], options);
206
223
  if (villageIds === null) return; // could not resolve villages — abort
207
224
 
208
- const spinner = villageSpinner('Building game...').start();
225
+ const spinner = json ? null : villageSpinner('Building game...').start();
226
+
227
+ const failOut = (msg) => {
228
+ if (spinner) spinner.fail(msg);
229
+ else if (json) console.log(JSON.stringify({ ok: false, error: msg }));
230
+ };
209
231
 
210
232
  try {
211
233
  // Run production build
@@ -217,16 +239,16 @@ async function deployThreeJsGame(projectDir, pkg) {
217
239
  // Check that build output exists
218
240
  const distDir = join(projectDir, 'dist');
219
241
  if (!existsSync(distDir)) {
220
- spinner.fail('Build completed but no dist/ directory found.');
242
+ failOut('Build completed but no dist/ directory found.');
221
243
  return;
222
244
  }
223
245
 
224
- spinner.text = 'Packaging build artifacts...';
246
+ if (spinner) spinner.text = 'Packaging build artifacts...';
225
247
 
226
248
  const filePaths = collectFilePaths(distDir);
227
249
 
228
250
  if (filePaths.length === 0) {
229
- spinner.fail('No files found in dist/ directory.');
251
+ failOut('No files found in dist/ directory.');
230
252
  return;
231
253
  }
232
254
 
@@ -254,7 +276,7 @@ async function deployThreeJsGame(projectDir, pkg) {
254
276
  formData.append('files', blob, relPath);
255
277
  }
256
278
 
257
- spinner.text = 'Deploying to MyVillageOS...';
279
+ if (spinner) spinner.text = 'Deploying to MyVillageOS...';
258
280
 
259
281
  const gameIdOrNew = pkg.myvillage.gameId || 'new';
260
282
  const result = await deployGame(gameIdOrNew, formData);
@@ -265,7 +287,7 @@ async function deployThreeJsGame(projectDir, pkg) {
265
287
 
266
288
  const finalGameId = result.gameId || pkg.myvillage.gameId;
267
289
 
268
- spinner.succeed('Upload complete!');
290
+ if (spinner) spinner.succeed('Upload complete!');
269
291
 
270
292
  saveVillages(projectDir, false, villageIds);
271
293
 
@@ -274,13 +296,27 @@ async function deployThreeJsGame(projectDir, pkg) {
274
296
  // DRAFT/REJECTED/SUBMITTED (only APPROVED/PUBLISHED lock it), so this works
275
297
  // for non-admin developers too — not just admins.
276
298
  if (finalGameId) {
277
- await submitCoverArt(finalGameId, projectDir, pkg.myvillage);
299
+ await submitCoverArt(finalGameId, projectDir, pkg.myvillage, options);
278
300
  }
279
301
 
280
302
  // Safety net: promote to SUBMITTED if the deploy left the game in DRAFT.
281
303
  // (A normal deploy already submits, so this is usually a no-op.)
282
304
  const finalStatus = await autoSubmitForReview(finalGameId, result);
283
305
 
306
+ if (json) {
307
+ console.log(
308
+ JSON.stringify({
309
+ ok: true,
310
+ gameId: finalGameId || null,
311
+ slug: result.slug || null,
312
+ status: finalStatus,
313
+ title: metadata.title,
314
+ villageIds,
315
+ }),
316
+ );
317
+ return;
318
+ }
319
+
284
320
  console.log();
285
321
  if (finalStatus === 'SUBMITTED') {
286
322
  console.log(brand.green(` \u2713 Game "${metadata.title}" submitted for review`));
@@ -295,18 +331,15 @@ async function deployThreeJsGame(projectDir, pkg) {
295
331
  console.log(brand.teal(' Run "myvillage status" to check the review status.'));
296
332
  console.log();
297
333
  } catch (err) {
298
- if (err.response) {
299
- const message = err.response.data?.error_description
300
- || err.response.data?.error
301
- || err.response.data?.message
302
- || 'Unknown server error';
303
- spinner.fail(`Deployment failed: ${message}`);
304
- } else if (err.stderr) {
305
- spinner.fail('Build failed.');
306
- console.error(chalk.red(` ${err.stderr.toString().trim()}`));
307
- } else {
308
- spinner.fail(`Deployment failed: ${err.message}`);
309
- }
334
+ const message = err.response
335
+ ? err.response.data?.error_description ||
336
+ err.response.data?.error ||
337
+ err.response.data?.message ||
338
+ 'Unknown server error'
339
+ : err.stderr
340
+ ? `Build failed: ${err.stderr.toString().trim()}`
341
+ : err.message;
342
+ failOut(`Deployment failed: ${message}`);
310
343
  }
311
344
  }
312
345
 
@@ -357,11 +390,11 @@ async function deployUnityGame(projectDir, mvConfig, options) {
357
390
  console.log(brand.teal(` Deploying ${platformsToDeploy.length} platform(s): ${platformsToDeploy.join(', ')}`));
358
391
 
359
392
  // If this game is already live, confirm + unpublish before we upload new builds.
360
- if (!(await confirmRedeployIfPublished(mvConfig.gameId))) return;
393
+ if (!(await confirmRedeployIfPublished(mvConfig.gameId, options))) return;
361
394
 
362
395
  // Village visibility is part of the submission — gather once for the whole
363
396
  // multi-platform deploy and thread it into every platform's metadata.
364
- const villageIds = await gatherVillages(mvConfig.villages || []);
397
+ const villageIds = await gatherVillages(mvConfig.villages || [], options);
365
398
  if (villageIds === null) return; // could not resolve villages — abort
366
399
  console.log();
367
400
 
@@ -391,7 +424,7 @@ async function deployUnityGame(projectDir, mvConfig, options) {
391
424
  // DRAFT/REJECTED/SUBMITTED (only APPROVED/PUBLISHED lock it). Submit is kept
392
425
  // out of the per-platform loop so it runs once, after every platform is up.
393
426
  if (finalGameId) {
394
- await submitCoverArt(finalGameId, projectDir, mvConfig);
427
+ await submitCoverArt(finalGameId, projectDir, mvConfig, options);
395
428
  }
396
429
 
397
430
  // Safety net: promote to SUBMITTED if a platform deploy left it in DRAFT.
@@ -618,39 +651,53 @@ function warnOnAspectMismatch(filePath, assetType, orientation) {
618
651
  // Cover art shipped with the submission. Uploads whatever's declared in
619
652
  // myvillage.assets first; only prompts for what's missing. A thumbnail is
620
653
  // required — without one the game card renders blank in muni and on the web.
621
- async function submitCoverArt(gameId, projectDir, config) {
654
+ async function submitCoverArt(gameId, projectDir, config, options = {}) {
655
+ if (options.skipCoverArt) return;
622
656
  const orientation = config?.orientation || 'landscape';
623
657
  const declared = config?.assets || {};
658
+ const nonInteractive = isNonInteractive(options);
624
659
  console.log();
625
660
 
626
- // 1. Thumbnail — declared, else prompt (required).
661
+ // An explicit --thumbnail/--banner path overrides whatever's declared.
662
+ const thumbSource = options.thumbnail
663
+ ? resolve(String(options.thumbnail))
664
+ : declared.thumbnail
665
+ ? resolve(projectDir, declared.thumbnail)
666
+ : null;
667
+ const bannerSource = options.banner
668
+ ? resolve(String(options.banner))
669
+ : declared.banner
670
+ ? resolve(projectDir, declared.banner)
671
+ : null;
672
+
673
+ // 1. Thumbnail — explicit/declared, else prompt (required) when interactive.
627
674
  let thumbUploaded = false;
628
- if (declared.thumbnail) {
629
- const p = resolve(projectDir, declared.thumbnail);
630
- if (existsSync(p)) {
631
- warnOnAspectMismatch(p, 'THUMBNAIL', orientation);
632
- thumbUploaded = await uploadImageAsset(gameId, p, 'THUMBNAIL');
675
+ if (thumbSource) {
676
+ if (existsSync(thumbSource)) {
677
+ warnOnAspectMismatch(thumbSource, 'THUMBNAIL', orientation);
678
+ thumbUploaded = await uploadImageAsset(gameId, thumbSource, 'THUMBNAIL');
633
679
  } else {
634
- console.log(chalk.yellow(` ! Declared thumbnail not found: ${declared.thumbnail}`));
680
+ console.log(chalk.yellow(` ! Thumbnail not found: ${thumbSource}`));
635
681
  }
636
682
  }
637
- if (!thumbUploaded) {
683
+ if (!thumbUploaded && !nonInteractive) {
638
684
  thumbUploaded = await promptForImage(gameId, 'THUMBNAIL', orientation, { required: true });
685
+ } else if (!thumbUploaded && nonInteractive) {
686
+ console.log(chalk.yellow(' ! No thumbnail — add one with "myvillage game upload-thumbnail <file>".'));
639
687
  }
640
688
 
641
- // 2. Banner — declared, else optional prompt.
689
+ // 2. Banner — explicit/declared, else optional prompt when interactive.
642
690
  let bannerHandled = false;
643
- if (declared.banner) {
644
- const p = resolve(projectDir, declared.banner);
645
- if (existsSync(p)) {
646
- warnOnAspectMismatch(p, 'BANNER', orientation);
647
- await uploadImageAsset(gameId, p, 'BANNER');
691
+ if (bannerSource) {
692
+ if (existsSync(bannerSource)) {
693
+ warnOnAspectMismatch(bannerSource, 'BANNER', orientation);
694
+ await uploadImageAsset(gameId, bannerSource, 'BANNER');
648
695
  bannerHandled = true;
649
696
  } else {
650
- console.log(chalk.yellow(` ! Declared banner not found: ${declared.banner}`));
697
+ console.log(chalk.yellow(` ! Banner not found: ${bannerSource}`));
651
698
  }
652
699
  }
653
- if (!bannerHandled) {
700
+ if (!bannerHandled && !nonInteractive) {
654
701
  await promptForImage(gameId, 'BANNER', orientation, { required: false });
655
702
  }
656
703
  }
package/src/index.js CHANGED
@@ -179,6 +179,16 @@ export function run() {
179
179
  .command('create-game')
180
180
  .description('Create a new game project with interactive wizard')
181
181
  .option('--unity-version <version>', 'Override the Unity Editor version for Unity scaffolds (e.g. 6000.0.76f1)')
182
+ .option('--name <name>', 'Game name (skips the prompt)')
183
+ .option('--description <text>', 'Game description')
184
+ .option('--engine <engine>', 'Engine: threejs | unity')
185
+ .option('--orientation <orientation>', 'portrait | landscape')
186
+ .option('--type <type>', 'Three.js type: quiz | exploration | narrative | custom')
187
+ .option('--category <category>', 'Unity category: education | action | adventure | puzzle | other')
188
+ .option('--target-age <age>', 'Target age: under-5 | 6-12 | 13-and-over')
189
+ .option('--dir <path>', 'Parent directory to scaffold into (defaults to cwd)')
190
+ .option('-y, --yes', 'Non-interactive: fail instead of prompting for missing options')
191
+ .option('--json', 'Output the result as JSON (implies non-interactive)')
182
192
  .action(createGameCommand);
183
193
 
184
194
  program
@@ -190,6 +200,12 @@ export function run() {
190
200
  .command('deploy')
191
201
  .description('Build and deploy your game to MyVillageOS')
192
202
  .option('-p, --platform <platform>', 'Target Unity platform (ios, android, webgl). Optional — defaults to deploying every platform found in builds/.')
203
+ .option('--villages <ids>', 'Comma-separated village ids for visibility (skips the prompt)')
204
+ .option('--thumbnail <path>', 'Path to a thumbnail image (skips the prompt)')
205
+ .option('--banner <path>', 'Path to a banner image (skips the prompt)')
206
+ .option('--skip-cover-art', 'Skip cover-art upload entirely')
207
+ .option('-y, --yes', 'Non-interactive: assume yes (e.g. unpublish-to-redeploy) and skip prompts')
208
+ .option('--json', 'Output the result as JSON (implies non-interactive)')
193
209
  .action((options) => deployCommand(options));
194
210
 
195
211
  program
@@ -1,7 +1,9 @@
1
- import { mkdirSync, writeFileSync, copyFileSync, existsSync } from 'fs';
1
+ import { mkdirSync, writeFileSync, copyFileSync, existsSync, rmSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
+ import { tmpdir } from 'os';
3
4
  import { fileURLToPath } from 'url';
4
5
  import { randomUUID } from 'crypto';
6
+ import * as tar from 'tar';
5
7
 
6
8
  // Bundled starter cover art lives at src/assets/game-art/ (this file is at
7
9
  // src/utils/). Resolved from the module path so it also works once the CLI is
@@ -14,6 +16,14 @@ const GAMEKIT_SDK_VERSION = '1.1.0-alpha.4';
14
16
  const GAMEKIT_SDK_URL =
15
17
  `https://github.com/MyVillage-Project-Technologies/myvillage-gamekit.git#v${GAMEKIT_SDK_VERSION}`;
16
18
 
19
+ // Direct (no-auth, no-redirect) tarball of the pinned tag. We download and
20
+ // unpack this into Packages/com.myvillage.gamekit/ at scaffold time so the
21
+ // project opens as a *local embedded package* — Unity never has to shell out to
22
+ // `git` to resolve the SDK. This is the fix for the "No 'git' executable was
23
+ // found" Package Manager error developers hit with a git-URL dependency.
24
+ const GAMEKIT_SDK_TARBALL_URL =
25
+ `https://codeload.github.com/MyVillage-Project-Technologies/myvillage-gamekit/tar.gz/refs/tags/v${GAMEKIT_SDK_VERSION}`;
26
+
17
27
  // Fallback Unity Editor version used only if we can't fetch the SDK's
18
28
  // package.json at scaffold time. The SDK declares the recommended editor
19
29
  // version via `unity` + `unityRelease` fields — that's the source of truth.
@@ -41,6 +51,38 @@ async function resolveUnityVersion() {
41
51
  }
42
52
  }
43
53
 
54
+ // Download the pinned GameKit SDK tarball over HTTPS and unpack it into
55
+ // Packages/com.myvillage.gamekit/ so Unity treats it as a local embedded
56
+ // package — no `git` executable required to resolve it. Returns true on
57
+ // success; false (with the partial dir cleaned up) on any failure so the
58
+ // caller can fall back to the git-URL manifest entry and still scaffold.
59
+ async function embedGameKitSdk(targetDir) {
60
+ const pkgDir = join(targetDir, 'Packages', 'com.myvillage.gamekit');
61
+ const controller = new AbortController();
62
+ const timeout = setTimeout(() => controller.abort(), 20000);
63
+ let tmpFile;
64
+ try {
65
+ const res = await fetch(GAMEKIT_SDK_TARBALL_URL, { signal: controller.signal });
66
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
67
+ const buf = Buffer.from(await res.arrayBuffer());
68
+ tmpFile = join(tmpdir(), `gamekit-${randomUUID()}.tar.gz`);
69
+ writeFileSync(tmpFile, buf);
70
+ mkdirSync(pkgDir, { recursive: true });
71
+ // strip:1 drops GitHub's top-level "<owner>-<repo>-<sha>/" wrapper folder
72
+ // so package.json lands at the root of pkgDir.
73
+ await tar.extract({ file: tmpFile, cwd: pkgDir, strip: 1 });
74
+ // Sanity check: a valid UPM package has package.json at its root.
75
+ if (!existsSync(join(pkgDir, 'package.json'))) throw new Error('package.json missing after extract');
76
+ return true;
77
+ } catch {
78
+ rmSync(pkgDir, { recursive: true, force: true });
79
+ return false;
80
+ } finally {
81
+ clearTimeout(timeout);
82
+ if (tmpFile) rmSync(tmpFile, { force: true });
83
+ }
84
+ }
85
+
44
86
  // MyVillage brand colors
45
87
  const BRAND = {
46
88
  gold: '#FFD700',
@@ -1135,7 +1177,7 @@ function generateUnityAgents(name, className, orientation) {
1135
1177
  return [
1136
1178
  '# AGENTS.md — working on this MyVillage Unity game with an AI agent',
1137
1179
  '',
1138
- '**' + name + '** is a MyVillage Unity game built on the **MyVillage GameKit SDK** (a UPM package pinned in `Packages/manifest.json`). The mission is C#; it builds to AssetBundles the M-UNI host loads.',
1180
+ '**' + name + '** is a MyVillage Unity game built on the **MyVillage GameKit SDK** (bundled as a local UPM package in `Packages/com.myvillage.gamekit/` — no git needed to open the project). The mission is C#; it builds to AssetBundles the M-UNI host loads.',
1139
1181
  '',
1140
1182
  'When editing: change the mission logic, keep the **game contract** intact (`CompleteMission` / `FailMission`), and do not break the GameKit `MissionBase` lifecycle.',
1141
1183
  '',
@@ -1151,7 +1193,8 @@ function generateUnityAgents(name, className, orientation) {
1151
1193
  '|------|---------|',
1152
1194
  '| `Assets/Scripts/' + className + '.cs` | **Your mission** — the `MissionBase` subclass. Edit here. |',
1153
1195
  '| `Assets/Editor/MyVillageOrientation.cs` | Pins player orientation on first load. |',
1154
- '| `Packages/manifest.json` | Depends on the GameKit SDK (pinned version). Do not hand-edit the SDK. |',
1196
+ '| `Packages/com.myvillage.gamekit/` | The GameKit SDK, bundled as a local package. Do not hand-edit it. |',
1197
+ '| `Packages/manifest.json` | Unity registry packages (URP, TMP, Input System, …). |',
1155
1198
  '| `myvillage.json` | Game metadata the CLI/deploy reads (slug, gameType, orientation, assets). |',
1156
1199
  '| `assets/` | Cover art shipped on deploy (thumbnail/banner). |',
1157
1200
  '| `builds/{ios,android,webgl}/` | Drop-zone for built AssetBundles the CLI deploys. |',
@@ -1181,7 +1224,7 @@ function generateUnityAgents(name, className, orientation) {
1181
1224
  '',
1182
1225
  '## Gotchas',
1183
1226
  '',
1184
- '- Do not edit the GameKit SDK package — it is a pinned dependency. Use its `MissionBase` API.',
1227
+ '- Do not edit the GameKit SDK package — it is bundled in `Packages/com.myvillage.gamekit/`. Use its `MissionBase` API. To change SDK version, re-scaffold or replace that folder; do not patch it in place.',
1185
1228
  '- Visual Scripting reflection nodes (`InvokeMember`/`GetMember`/`SetMember`) are rejected by the deploy preflight. Write C# instead.',
1186
1229
  '',
1187
1230
  ].join('\n');
@@ -1199,12 +1242,14 @@ export async function createUnityGameProject(targetDir, options) {
1199
1242
  const isLandscape = orientation === 'landscape';
1200
1243
  const primaryGameType = isLandscape ? 'UNITY_WEBGL' : 'UNITY_NATIVE';
1201
1244
 
1202
- // Scaffold an openable Unity project that pulls com.myvillage.gamekit via UPM.
1245
+ // Scaffold an openable Unity project with com.myvillage.gamekit bundled in
1246
+ // as a local embedded package (downloaded over HTTPS, no git required).
1203
1247
  // Layout matches what Unity Hub expects:
1204
- // Packages/manifest.json - dependency on the GameKit SDK + core modules
1248
+ // Packages/com.myvillage.gamekit/ - the GameKit SDK, embedded locally
1249
+ // Packages/manifest.json - Unity registry deps (URP, TMP, …)
1205
1250
  // ProjectSettings/ProjectVersion.txt - tells Unity which editor version
1206
- // Assets/Scripts/<Name>.cs - starter MissionBase subclass
1207
- // builds/{ios,android,webgl}/ - drop-zone for builds the CLI deploys
1251
+ // Assets/Scripts/<Name>.cs - starter MissionBase subclass
1252
+ // builds/{ios,android,webgl}/ - drop-zone for builds the CLI deploys
1208
1253
 
1209
1254
  const dirs = [
1210
1255
  '',
@@ -1249,8 +1294,15 @@ export async function createUnityGameProject(targetDir, options) {
1249
1294
  lastDeployed: null,
1250
1295
  }, null, 2) + '\n');
1251
1296
 
1252
- // Unity project files.
1253
- writeFileSync(join(targetDir, 'Packages/manifest.json'), generateUnityPackagesManifest());
1297
+ // Bundle the GameKit SDK as a local package so the project opens without git.
1298
+ // If the download fails (e.g. offline), fall back to the git-URL dependency in
1299
+ // the manifest — that path still works for anyone who has git installed.
1300
+ const sdkEmbedded = await embedGameKitSdk(targetDir);
1301
+
1302
+ // Unity project files. When the SDK is embedded it must NOT also be listed as
1303
+ // a manifest dependency (Unity would try to re-resolve it); when it isn't, the
1304
+ // git-URL entry is the fallback resolution path.
1305
+ writeFileSync(join(targetDir, 'Packages/manifest.json'), generateUnityPackagesManifest(sdkEmbedded));
1254
1306
  writeFileSync(join(targetDir, 'ProjectSettings/ProjectVersion.txt'), generateUnityProjectVersion(unityVersion));
1255
1307
 
1256
1308
  // Starter mission. New devs subclass MissionBase here.
@@ -1271,7 +1323,7 @@ export async function createUnityGameProject(targetDir, options) {
1271
1323
  writeFileSync(join(targetDir, 'README.md'), generateUnityReadme(name, slug, className, orientation, unityVersion));
1272
1324
  writeFileSync(join(targetDir, 'AGENTS.md'), generateUnityAgents(name, className, orientation));
1273
1325
 
1274
- return { unityVersion, sdkVersion: GAMEKIT_SDK_VERSION, gameType: primaryGameType };
1326
+ return { unityVersion, sdkVersion: GAMEKIT_SDK_VERSION, gameType: primaryGameType, sdkEmbedded };
1275
1327
  }
1276
1328
 
1277
1329
  // Editor script: applies orientation to PlayerSettings on first load so a
@@ -1315,18 +1367,23 @@ public static class MyVillageOrientation
1315
1367
 
1316
1368
  // ── Unity (GameKit SDK) ─────────────────────────────────────────────
1317
1369
 
1318
- function generateUnityPackagesManifest() {
1319
- return JSON.stringify({
1320
- dependencies: {
1321
- 'com.myvillage.gamekit': GAMEKIT_SDK_URL,
1322
- 'com.unity.feature.development': '1.0.2',
1323
- 'com.unity.render-pipelines.universal': '17.2.0',
1324
- 'com.unity.textmeshpro': '3.0.6',
1325
- 'com.unity.inputsystem': '1.14.2',
1326
- 'com.unity.ide.rider': '3.0.38',
1327
- 'com.unity.ide.visualstudio': '2.0.22',
1328
- },
1329
- }, null, 2) + '\n';
1370
+ function generateUnityPackagesManifest(sdkEmbedded = true) {
1371
+ const dependencies = {};
1372
+ // Only declare the SDK as a git dependency when it could NOT be embedded
1373
+ // locally. When embedded under Packages/com.myvillage.gamekit/, Unity
1374
+ // auto-discovers it and listing it here would force a (git-based) re-resolve.
1375
+ if (!sdkEmbedded) {
1376
+ dependencies['com.myvillage.gamekit'] = GAMEKIT_SDK_URL;
1377
+ }
1378
+ Object.assign(dependencies, {
1379
+ 'com.unity.feature.development': '1.0.2',
1380
+ 'com.unity.render-pipelines.universal': '17.2.0',
1381
+ 'com.unity.textmeshpro': '3.0.6',
1382
+ 'com.unity.inputsystem': '1.14.2',
1383
+ 'com.unity.ide.rider': '3.0.38',
1384
+ 'com.unity.ide.visualstudio': '2.0.22',
1385
+ });
1386
+ return JSON.stringify({ dependencies }, null, 2) + '\n';
1330
1387
  }
1331
1388
 
1332
1389
  function generateUnityProjectVersion(unityVersion) {
@@ -1450,24 +1507,25 @@ A MyVillage game scaffolded with the MyVillage CLI and the MyVillage GameKit SDK
1450
1507
  - **Slug:** \`${slug}\`
1451
1508
  - **Game type:** \`${gameType}\`
1452
1509
  - **Orientation:** \`${orientation}\` — plays on ${reach}
1453
- - **SDK:** \`com.myvillage.gamekit\` (pinned in \`Packages/manifest.json\`)
1510
+ - **SDK:** \`com.myvillage.gamekit\` v${GAMEKIT_SDK_VERSION} (bundled in \`Packages/com.myvillage.gamekit/\` \u2014 no git required)
1454
1511
 
1455
1512
  ## What's in this project
1456
1513
 
1457
1514
  \`\`\`
1458
1515
  Assets/
1459
1516
  \u2514\u2500\u2500 Scripts/
1460
- \u2514\u2500\u2500 ${className}.cs \u2190 your mission, subclasses MissionBase
1517
+ \u2514\u2500\u2500 ${className}.cs \u2190 your mission, subclasses MissionBase
1461
1518
  Packages/
1462
- \u2514\u2500\u2500 manifest.json \u2190 fetches the GameKit SDK on first open
1519
+ \u251c\u2500\u2500 com.myvillage.gamekit/ \u2190 GameKit SDK, bundled locally
1520
+ \u2514\u2500\u2500 manifest.json \u2190 Unity registry packages (URP, TMP, \u2026)
1463
1521
  ProjectSettings/
1464
1522
  \u2514\u2500\u2500 ProjectVersion.txt
1465
- myvillage.json \u2190 CLI metadata (auto-updated by deploy)
1523
+ myvillage.json \u2190 CLI metadata (auto-updated by deploy)
1466
1524
  \`\`\`
1467
1525
 
1468
1526
  ## Quickstart \u2014 play your mission in 3 steps
1469
1527
 
1470
- 1. **Install Unity Hub** and Unity **${unityVersion}** if you don't have it. Open this folder in Unity Hub (Add \u2192 Add project from disk). Unity fetches the GameKit SDK automatically (check Package Manager \u2192 In Project).
1528
+ 1. **Install Unity Hub** and Unity **${unityVersion}** if you don't have it. Open this folder in Unity Hub (Add \u2192 Add project from disk). The GameKit SDK is already bundled in \`Packages/\`, so the project compiles on first open \u2014 you do **not** need git installed.
1471
1529
 
1472
1530
  2. **Click the menu \`MyVillage \u2192 Create Starter Mission Scene\`.** This creates a new scene with a "Mission" GameObject that already has \`MockMissionHost\` attached (the editor stand-in for the real M-UNI host) and your \`${className}\` script auto-attached. The scene is added to Build Settings and opened for you.
1473
1531