@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 +2 -1
- package/src/agent-runtime/loop.js +37 -28
- package/src/commands/create-game.js +226 -111
- package/src/commands/deploy.js +106 -59
- package/src/index.js +16 -0
- package/src/utils/templates.js +86 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myvillage/cli",
|
|
3
|
-
"version": "1.
|
|
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
|
|
25
|
-
|
|
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
|
|
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
|
|
51
|
+
throw new Error('No OpenAI API key configured. Set OPENAI_API_KEY or configure a model in MyVillage Studio settings.');
|
|
40
52
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
98
|
-
const
|
|
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
|
-
|
|
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:
|
|
110
|
-
description:
|
|
111
|
-
type:
|
|
112
|
-
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
|
-
|
|
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
|
-
|
|
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(`
|
|
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: ${
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
const
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
205
|
-
description:
|
|
206
|
-
type:
|
|
207
|
-
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 "${
|
|
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(`
|
|
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
|
|
223
|
-
console.log(` 2. ${brand.teal('Click menu')} ${brand.gold('MyVillage
|
|
224
|
-
console.log(` 3. ${brand.teal('Press')} ${brand.gold('Play')} ${brand.teal('
|
|
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
|
-
|
|
356
|
+
if (spinner) spinner.fail('Failed to create Unity game project.');
|
|
357
|
+
fail(json, err.message);
|
|
243
358
|
}
|
|
244
359
|
}
|
package/src/commands/deploy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
//
|
|
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 (
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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(` !
|
|
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 (
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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(` !
|
|
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
|
package/src/utils/templates.js
CHANGED
|
@@ -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
|
|
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/
|
|
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
|
|
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
|
|
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/
|
|
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
|
|
1207
|
-
// builds/{ios,android,webgl}/
|
|
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
|
-
//
|
|
1253
|
-
|
|
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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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\` (
|
|
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
|
|
1517
|
+
\u2514\u2500\u2500 ${className}.cs \u2190 your mission, subclasses MissionBase
|
|
1461
1518
|
Packages/
|
|
1462
|
-
\
|
|
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
|
|
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).
|
|
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
|
|