@myvillage/cli 1.64.7 → 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 +1 -1
- package/src/agent-runtime/loop.js +37 -28
- package/src/commands/create-game.js +220 -111
- package/src/commands/deploy.js +106 -59
- package/src/index.js +16 -0
package/package.json
CHANGED
|
@@ -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
|
+
};
|
|
108
|
+
|
|
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
|
+
}
|
|
56
118
|
|
|
57
|
-
const
|
|
58
|
-
|
|
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
|
+
});
|
|
158
|
+
|
|
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
|
+
};
|
|
96
166
|
|
|
97
|
-
const slug =
|
|
98
|
-
const targetDir = resolve(process.cwd(), slug);
|
|
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...';
|
|
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!`);
|
|
117
188
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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,33 +282,58 @@ 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
|
+
});
|
|
191
286
|
|
|
192
|
-
const
|
|
193
|
-
const
|
|
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
|
+
};
|
|
294
|
+
|
|
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
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'})`));
|
|
@@ -220,31 +341,19 @@ async function createUnityGame(options = {}) {
|
|
|
220
341
|
if (!scaffold.sdkEmbedded) {
|
|
221
342
|
console.log(chalk.yellow(' ⚠ Could not download the GameKit SDK (offline?). The project falls back to a'));
|
|
222
343
|
console.log(chalk.yellow(' git-URL dependency, so opening it in Unity will require git to be installed.'));
|
|
223
|
-
console.log(chalk.yellow(' Re-run this command with a network connection to bundle the SDK locally instead.'));
|
|
224
344
|
console.log();
|
|
225
345
|
}
|
|
226
346
|
console.log(brand.teal(' Next steps:'));
|
|
227
347
|
console.log();
|
|
228
|
-
console.log(` 1. ${brand.gold('Open this project in Unity Hub')} (Add
|
|
229
|
-
console.log(` 2. ${brand.teal('Click menu')} ${brand.gold('MyVillage
|
|
230
|
-
console.log(` 3. ${brand.teal('Press')} ${brand.gold('Play')} ${brand.teal('
|
|
231
|
-
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)')}`);
|
|
232
|
-
console.log();
|
|
233
|
-
console.log(brand.teal(' Ready to ship:'));
|
|
234
|
-
if (orientation === 'landscape') {
|
|
235
|
-
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/')}`);
|
|
236
|
-
console.log(` 6. ${brand.gold('cd ' + slug + ' && myvillage deploy --platform webgl')}`);
|
|
237
|
-
} else {
|
|
238
|
-
console.log(` 5. ${brand.teal('Build AssetBundle:')} ${brand.gold('MyVillage \u2192 Build Mission Bundle')} ${brand.teal('(into builds/ios or builds/android)')}`);
|
|
239
|
-
console.log(` 6. ${brand.gold('cd ' + slug + ' && myvillage deploy')} ${brand.teal('\u2014 ships every platform found in builds/')}`);
|
|
240
|
-
console.log(` ${brand.teal('Optional: also build WebGL into builds/webgl/ to offer it on the web too.')}`);
|
|
241
|
-
}
|
|
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')}`);
|
|
242
351
|
console.log();
|
|
243
352
|
console.log(brand.teal(` Cover art: drop thumbnail.png / banner.png in ${slug}/assets/ before deploy.`));
|
|
244
353
|
console.log(brand.teal(` See ${slug}/README.md for the full developer guide.`));
|
|
245
354
|
console.log();
|
|
246
355
|
} catch (err) {
|
|
247
|
-
spinner.fail('Failed to create Unity game project.');
|
|
248
|
-
|
|
356
|
+
if (spinner) spinner.fail('Failed to create Unity game project.');
|
|
357
|
+
fail(json, err.message);
|
|
249
358
|
}
|
|
250
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
|