@myvillage/cli 1.9.0 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/create-game.js +119 -6
- package/src/commands/deploy.js +267 -19
- package/src/commands/game.js +712 -0
- package/src/index.js +78 -47
- package/src/utils/api.js +44 -0
- package/src/utils/templates.js +608 -2
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@ import ora from 'ora';
|
|
|
6
6
|
import { villageSpinner, brand } from '../utils/brand.js';
|
|
7
7
|
import inquirer from 'inquirer';
|
|
8
8
|
import { isAuthenticated } from '../utils/auth.js';
|
|
9
|
-
import { createGameProject } from '../utils/templates.js';
|
|
9
|
+
import { createGameProject, createUnityGameProject } from '../utils/templates.js';
|
|
10
10
|
|
|
11
11
|
export async function createGameCommand() {
|
|
12
12
|
// Check authentication
|
|
@@ -15,7 +15,25 @@ export async function createGameCommand() {
|
|
|
15
15
|
return;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
//
|
|
18
|
+
// Engine choice
|
|
19
|
+
const { engine } = await inquirer.prompt([
|
|
20
|
+
{
|
|
21
|
+
type: 'list',
|
|
22
|
+
name: 'engine',
|
|
23
|
+
message: 'Which engine are you using?',
|
|
24
|
+
choices: [
|
|
25
|
+
{ name: 'Three.js — Web-based 3D game (runs in browser)', value: 'threejs' },
|
|
26
|
+
{ name: 'Unity — Mobile or WebGL game (built in Unity Editor)', value: 'unity' },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
if (engine === 'unity') {
|
|
32
|
+
return createUnityGame();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Three.js path (existing flow) ──
|
|
36
|
+
|
|
19
37
|
const answers = await inquirer.prompt([
|
|
20
38
|
{
|
|
21
39
|
type: 'list',
|
|
@@ -57,7 +75,6 @@ export async function createGameCommand() {
|
|
|
57
75
|
},
|
|
58
76
|
]);
|
|
59
77
|
|
|
60
|
-
// Create project directory
|
|
61
78
|
const slug = answers.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
62
79
|
const targetDir = resolve(process.cwd(), slug);
|
|
63
80
|
|
|
@@ -69,7 +86,6 @@ export async function createGameCommand() {
|
|
|
69
86
|
const spinner = villageSpinner('Creating game project...').start();
|
|
70
87
|
|
|
71
88
|
try {
|
|
72
|
-
// Generate project from template
|
|
73
89
|
createGameProject(targetDir, {
|
|
74
90
|
name: answers.name,
|
|
75
91
|
description: answers.description,
|
|
@@ -79,7 +95,6 @@ export async function createGameCommand() {
|
|
|
79
95
|
|
|
80
96
|
spinner.text = 'Installing dependencies...';
|
|
81
97
|
|
|
82
|
-
// Run npm install in the new project directory
|
|
83
98
|
execSync('npm install', {
|
|
84
99
|
cwd: targetDir,
|
|
85
100
|
stdio: 'pipe',
|
|
@@ -87,7 +102,6 @@ export async function createGameCommand() {
|
|
|
87
102
|
|
|
88
103
|
spinner.succeed(`Game "${answers.name}" created successfully!`);
|
|
89
104
|
|
|
90
|
-
// Display next steps
|
|
91
105
|
console.log();
|
|
92
106
|
console.log(brand.green(` \u2713 Game "${answers.name}" created successfully!`));
|
|
93
107
|
console.log();
|
|
@@ -105,3 +119,102 @@ export async function createGameCommand() {
|
|
|
105
119
|
console.error(chalk.red(` ${err.message}`));
|
|
106
120
|
}
|
|
107
121
|
}
|
|
122
|
+
|
|
123
|
+
// ── Unity path ──
|
|
124
|
+
|
|
125
|
+
async function createUnityGame() {
|
|
126
|
+
const answers = await inquirer.prompt([
|
|
127
|
+
{
|
|
128
|
+
type: 'input',
|
|
129
|
+
name: 'name',
|
|
130
|
+
message: 'Game name:',
|
|
131
|
+
default: 'My Unity Game',
|
|
132
|
+
validate: (input) => {
|
|
133
|
+
if (!input.trim()) return 'Game name is required.';
|
|
134
|
+
return true;
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
type: 'input',
|
|
139
|
+
name: 'description',
|
|
140
|
+
message: 'Game description:',
|
|
141
|
+
default: 'A Unity game for MyVillageOS',
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
type: 'list',
|
|
145
|
+
name: 'category',
|
|
146
|
+
message: 'Game category:',
|
|
147
|
+
choices: [
|
|
148
|
+
{ name: 'Education', value: 'education' },
|
|
149
|
+
{ name: 'Action', value: 'action' },
|
|
150
|
+
{ name: 'Adventure', value: 'adventure' },
|
|
151
|
+
{ name: 'Puzzle', value: 'puzzle' },
|
|
152
|
+
{ name: 'Other', value: 'other' },
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
type: 'list',
|
|
157
|
+
name: 'platform',
|
|
158
|
+
message: 'Target platform:',
|
|
159
|
+
choices: [
|
|
160
|
+
{ name: 'Mobile (iOS / Android) — AssetBundle loaded by Izi Graham', value: 'mobile' },
|
|
161
|
+
{ name: 'WebGL — Runs in browser on M-UNI', value: 'webgl' },
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
type: 'list',
|
|
166
|
+
name: 'ageGroup',
|
|
167
|
+
message: 'Target age group:',
|
|
168
|
+
choices: [
|
|
169
|
+
{ name: '5-8 years', value: '5-8' },
|
|
170
|
+
{ name: '9-12 years', value: '9-12' },
|
|
171
|
+
{ name: '13-15 years', value: '13-15' },
|
|
172
|
+
{ name: '16-18 years', value: '16-18' },
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
const slug = answers.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
178
|
+
const targetDir = resolve(process.cwd(), slug);
|
|
179
|
+
|
|
180
|
+
if (existsSync(targetDir)) {
|
|
181
|
+
console.log(chalk.red(`\n \u2717 Directory "${slug}" already exists.`));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const spinner = villageSpinner('Creating Unity game project...').start();
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
createUnityGameProject(targetDir, {
|
|
189
|
+
name: answers.name,
|
|
190
|
+
description: answers.description,
|
|
191
|
+
type: answers.category,
|
|
192
|
+
ageGroup: answers.ageGroup,
|
|
193
|
+
platform: answers.platform,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
spinner.succeed(`Unity game "${answers.name}" created!`);
|
|
197
|
+
|
|
198
|
+
console.log();
|
|
199
|
+
console.log(brand.green(` \u2713 Unity game "${answers.name}" created!`));
|
|
200
|
+
console.log();
|
|
201
|
+
console.log(brand.teal(' Next steps:'));
|
|
202
|
+
console.log();
|
|
203
|
+
console.log(` 1. ${brand.gold('Copy Scripts/')} into your Unity project's Assets/ folder`);
|
|
204
|
+
console.log(` 2. ${brand.teal('Attach GameSessionReporter and MyVillageAuth to a GameObject')}`);
|
|
205
|
+
console.log(` 3. ${brand.teal('Build your game in Unity Editor')}`);
|
|
206
|
+
if (answers.platform === 'mobile') {
|
|
207
|
+
console.log(` 4. ${brand.teal('Place built AssetBundles in')} ${brand.gold('builds/ios/')} ${brand.teal('or')} ${brand.gold('builds/android/')}`);
|
|
208
|
+
console.log(` 5. ${brand.gold('cd ' + slug + ' && myvillage deploy --platform ios')}`);
|
|
209
|
+
} else {
|
|
210
|
+
console.log(` 4. ${brand.teal('Build WebGL to')} ${brand.gold('builds/webgl/')}`);
|
|
211
|
+
console.log(` 5. ${brand.gold('cd ' + slug + ' && myvillage deploy --platform webgl')}`);
|
|
212
|
+
}
|
|
213
|
+
console.log();
|
|
214
|
+
console.log(brand.teal(` See ${slug}/README.md for full integration guide.`));
|
|
215
|
+
console.log();
|
|
216
|
+
} catch (err) {
|
|
217
|
+
spinner.fail('Failed to create Unity game project.');
|
|
218
|
+
console.error(chalk.red(` ${err.message}`));
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/commands/deploy.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
|
|
2
|
-
import { join, resolve, relative } from 'path';
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { join, resolve, relative, extname } from 'path';
|
|
3
3
|
import { execSync } from 'child_process';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import ora from 'ora';
|
|
6
|
+
import axios from 'axios';
|
|
7
|
+
import inquirer from 'inquirer';
|
|
6
8
|
import { villageSpinner, brand } from '../utils/brand.js';
|
|
7
9
|
import { isAuthenticated } from '../utils/auth.js';
|
|
8
|
-
import { deployGame } from '../utils/api.js';
|
|
10
|
+
import { deployGame, getGameUploadUrl, confirmGameUpload } from '../utils/api.js';
|
|
9
11
|
|
|
10
12
|
function readPackageJson(dir) {
|
|
11
13
|
const pkgPath = join(dir, 'package.json');
|
|
@@ -18,6 +20,17 @@ function readPackageJson(dir) {
|
|
|
18
20
|
}
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
function readMyVillageJson(dir) {
|
|
24
|
+
const configPath = join(dir, 'myvillage.json');
|
|
25
|
+
if (!existsSync(configPath)) return null;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
21
34
|
function saveGameIdToPackageJson(dir, gameId, slug) {
|
|
22
35
|
const pkgPath = join(dir, 'package.json');
|
|
23
36
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
@@ -25,6 +38,15 @@ function saveGameIdToPackageJson(dir, gameId, slug) {
|
|
|
25
38
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
26
39
|
}
|
|
27
40
|
|
|
41
|
+
function saveGameIdToMyVillageJson(dir, gameId, slug) {
|
|
42
|
+
const configPath = join(dir, 'myvillage.json');
|
|
43
|
+
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
44
|
+
config.gameId = gameId;
|
|
45
|
+
config.slug = slug;
|
|
46
|
+
config.lastDeployed = new Date().toISOString();
|
|
47
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
28
50
|
// Recursively collect file paths from a directory
|
|
29
51
|
function collectFilePaths(dir) {
|
|
30
52
|
const paths = [];
|
|
@@ -45,7 +67,7 @@ function collectFilePaths(dir) {
|
|
|
45
67
|
return paths;
|
|
46
68
|
}
|
|
47
69
|
|
|
48
|
-
export async function deployCommand() {
|
|
70
|
+
export async function deployCommand(options = {}) {
|
|
49
71
|
// Check authentication
|
|
50
72
|
if (!isAuthenticated()) {
|
|
51
73
|
console.log(chalk.red(' \u2717 Authentication required. Run \'myvillage login\' first.'));
|
|
@@ -53,15 +75,30 @@ export async function deployCommand() {
|
|
|
53
75
|
}
|
|
54
76
|
|
|
55
77
|
const projectDir = resolve(process.cwd());
|
|
78
|
+
const mvConfig = readMyVillageJson(projectDir);
|
|
56
79
|
const pkg = readPackageJson(projectDir);
|
|
57
80
|
|
|
58
|
-
//
|
|
59
|
-
|
|
81
|
+
// Detect project type
|
|
82
|
+
const isUnity = mvConfig && mvConfig.engine === 'unity';
|
|
83
|
+
const isThreeJs = pkg && pkg.myvillage;
|
|
84
|
+
|
|
85
|
+
if (!isUnity && !isThreeJs) {
|
|
60
86
|
console.log(chalk.red(' \u2717 Not a valid game project directory.'));
|
|
61
87
|
console.log(brand.teal(' Make sure you are inside a game directory created with "myvillage create-game".'));
|
|
88
|
+
console.log(brand.teal(' Expected: package.json with myvillage block, or myvillage.json with engine: unity'));
|
|
62
89
|
return;
|
|
63
90
|
}
|
|
64
91
|
|
|
92
|
+
if (isUnity) {
|
|
93
|
+
return deployUnityGame(projectDir, mvConfig, options);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return deployThreeJsGame(projectDir, pkg, options);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Three.js deploy (existing flow) ──
|
|
100
|
+
|
|
101
|
+
async function deployThreeJsGame(projectDir, pkg) {
|
|
65
102
|
const spinner = villageSpinner('Building game...').start();
|
|
66
103
|
|
|
67
104
|
try {
|
|
@@ -80,7 +117,6 @@ export async function deployCommand() {
|
|
|
80
117
|
|
|
81
118
|
spinner.text = 'Packaging build artifacts...';
|
|
82
119
|
|
|
83
|
-
// Collect build files
|
|
84
120
|
const filePaths = collectFilePaths(distDir);
|
|
85
121
|
|
|
86
122
|
if (filePaths.length === 0) {
|
|
@@ -88,10 +124,8 @@ export async function deployCommand() {
|
|
|
88
124
|
return;
|
|
89
125
|
}
|
|
90
126
|
|
|
91
|
-
// Build multipart FormData
|
|
92
127
|
const formData = new FormData();
|
|
93
128
|
|
|
94
|
-
// Add metadata
|
|
95
129
|
const metadata = {
|
|
96
130
|
title: pkg.myvillage.name || pkg.name,
|
|
97
131
|
description: pkg.description || '',
|
|
@@ -102,7 +136,6 @@ export async function deployCommand() {
|
|
|
102
136
|
};
|
|
103
137
|
formData.append('metadata', JSON.stringify(metadata));
|
|
104
138
|
|
|
105
|
-
// Add each file with relative path as the filename
|
|
106
139
|
for (const fullPath of filePaths) {
|
|
107
140
|
const relPath = relative(distDir, fullPath);
|
|
108
141
|
const fileBuffer = readFileSync(fullPath);
|
|
@@ -112,11 +145,9 @@ export async function deployCommand() {
|
|
|
112
145
|
|
|
113
146
|
spinner.text = 'Deploying to MyVillageOS...';
|
|
114
147
|
|
|
115
|
-
// Use existing gameId if this project was deployed before, otherwise "new"
|
|
116
148
|
const gameIdOrNew = pkg.myvillage.gameId || 'new';
|
|
117
149
|
const result = await deployGame(gameIdOrNew, formData);
|
|
118
150
|
|
|
119
|
-
// Save gameId to package.json for subsequent deploys
|
|
120
151
|
if (result.gameId && !pkg.myvillage.gameId) {
|
|
121
152
|
saveGameIdToPackageJson(projectDir, result.gameId, result.slug);
|
|
122
153
|
}
|
|
@@ -125,12 +156,13 @@ export async function deployCommand() {
|
|
|
125
156
|
console.log();
|
|
126
157
|
console.log(brand.green(` \u2713 Game "${metadata.title}" submitted for review`));
|
|
127
158
|
console.log(` Status: ${formatStatus(result.status)}`);
|
|
159
|
+
if (result.slug) console.log(brand.teal(` Slug: ${result.slug}`));
|
|
160
|
+
if (result.gameId) console.log(brand.teal(` Game ID: ${result.gameId}`));
|
|
128
161
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
console.log(brand.teal(` Game ID: ${result.gameId}`));
|
|
162
|
+
// Prompt for thumbnail/banner images
|
|
163
|
+
const finalGameId = result.gameId || pkg.myvillage.gameId;
|
|
164
|
+
if (finalGameId) {
|
|
165
|
+
await promptForImages(finalGameId);
|
|
134
166
|
}
|
|
135
167
|
|
|
136
168
|
console.log();
|
|
@@ -138,14 +170,12 @@ export async function deployCommand() {
|
|
|
138
170
|
console.log();
|
|
139
171
|
} catch (err) {
|
|
140
172
|
if (err.response) {
|
|
141
|
-
// API error
|
|
142
173
|
const message = err.response.data?.error_description
|
|
143
174
|
|| err.response.data?.error
|
|
144
175
|
|| err.response.data?.message
|
|
145
176
|
|| 'Unknown server error';
|
|
146
177
|
spinner.fail(`Deployment failed: ${message}`);
|
|
147
178
|
} else if (err.stderr) {
|
|
148
|
-
// Build error
|
|
149
179
|
spinner.fail('Build failed.');
|
|
150
180
|
console.error(chalk.red(` ${err.stderr.toString().trim()}`));
|
|
151
181
|
} else {
|
|
@@ -154,6 +184,219 @@ export async function deployCommand() {
|
|
|
154
184
|
}
|
|
155
185
|
}
|
|
156
186
|
|
|
187
|
+
// ── Unity deploy ──
|
|
188
|
+
|
|
189
|
+
async function deployUnityGame(projectDir, mvConfig, options) {
|
|
190
|
+
const platform = options.platform;
|
|
191
|
+
|
|
192
|
+
if (!platform) {
|
|
193
|
+
console.log(chalk.red(' \u2717 --platform is required for Unity games.'));
|
|
194
|
+
console.log(brand.teal(' Usage: myvillage deploy --platform <ios|android|webgl>'));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const validPlatforms = ['ios', 'android', 'webgl'];
|
|
199
|
+
if (!validPlatforms.includes(platform)) {
|
|
200
|
+
console.log(chalk.red(` \u2717 Invalid platform "${platform}". Must be one of: ${validPlatforms.join(', ')}`));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Determine build directory
|
|
205
|
+
const buildDir = join(projectDir, 'builds', platform);
|
|
206
|
+
|
|
207
|
+
if (!existsSync(buildDir)) {
|
|
208
|
+
console.log(chalk.red(` \u2717 Build directory not found: builds/${platform}/`));
|
|
209
|
+
console.log(brand.teal(` Build your game in Unity and place the output in builds/${platform}/`));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const filePaths = collectFilePaths(buildDir);
|
|
214
|
+
|
|
215
|
+
if (filePaths.length === 0) {
|
|
216
|
+
console.log(chalk.red(` \u2717 No files found in builds/${platform}/`));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const spinner = villageSpinner(`Deploying ${platform} build to MyVillageOS...`).start();
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
spinner.text = `Packaging ${filePaths.length} files...`;
|
|
224
|
+
|
|
225
|
+
const formData = new FormData();
|
|
226
|
+
|
|
227
|
+
// Determine gameType based on platform
|
|
228
|
+
const gameType = platform === 'webgl' ? 'UNITY_WEBGL' : 'UNITY_NATIVE';
|
|
229
|
+
|
|
230
|
+
const metadata = {
|
|
231
|
+
title: mvConfig.name,
|
|
232
|
+
description: mvConfig.description || '',
|
|
233
|
+
category: (mvConfig.category || 'OTHER').toUpperCase(),
|
|
234
|
+
gameType,
|
|
235
|
+
targetAge: mvConfig.targetAge || 'all',
|
|
236
|
+
buildPlatform: platform,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// WebGL builds have an index.html entry file
|
|
240
|
+
if (platform === 'webgl') {
|
|
241
|
+
metadata.entryFile = 'index.html';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
formData.append('metadata', JSON.stringify(metadata));
|
|
245
|
+
|
|
246
|
+
for (const fullPath of filePaths) {
|
|
247
|
+
const relPath = relative(buildDir, fullPath);
|
|
248
|
+
const fileBuffer = readFileSync(fullPath);
|
|
249
|
+
const blob = new Blob([fileBuffer], { type: getContentType(relPath) });
|
|
250
|
+
formData.append('files', blob, relPath);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
spinner.text = 'Uploading to MyVillageOS...';
|
|
254
|
+
|
|
255
|
+
const gameIdOrNew = mvConfig.gameId || 'new';
|
|
256
|
+
const result = await deployGame(gameIdOrNew, formData);
|
|
257
|
+
|
|
258
|
+
// Save gameId back to myvillage.json
|
|
259
|
+
if (result.gameId && !mvConfig.gameId) {
|
|
260
|
+
saveGameIdToMyVillageJson(projectDir, result.gameId, result.slug);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
spinner.succeed(`${platform} deployment complete!`);
|
|
264
|
+
console.log();
|
|
265
|
+
console.log(brand.green(` \u2713 Game "${metadata.title}" (${gameType}) submitted for review`));
|
|
266
|
+
console.log(` Status: ${formatStatus(result.status)}`);
|
|
267
|
+
console.log(` Platform: ${platform}`);
|
|
268
|
+
if (result.slug) console.log(brand.teal(` Slug: ${result.slug}`));
|
|
269
|
+
if (result.gameId) console.log(brand.teal(` Game ID: ${result.gameId}`));
|
|
270
|
+
|
|
271
|
+
// Prompt for thumbnail/banner images
|
|
272
|
+
const finalGameId = result.gameId || mvConfig.gameId;
|
|
273
|
+
if (finalGameId) {
|
|
274
|
+
await promptForImages(finalGameId);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
console.log();
|
|
278
|
+
console.log(brand.teal(' Run "myvillage status" to check the review status.'));
|
|
279
|
+
console.log();
|
|
280
|
+
} catch (err) {
|
|
281
|
+
if (err.response) {
|
|
282
|
+
const message = err.response.data?.error_description
|
|
283
|
+
|| err.response.data?.error
|
|
284
|
+
|| err.response.data?.message
|
|
285
|
+
|| 'Unknown server error';
|
|
286
|
+
spinner.fail(`Deployment failed: ${message}`);
|
|
287
|
+
} else {
|
|
288
|
+
spinner.fail(`Deployment failed: ${err.message}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Image upload helpers ──
|
|
294
|
+
|
|
295
|
+
const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.webp', '.gif'];
|
|
296
|
+
const IMAGE_CONTENT_TYPES = {
|
|
297
|
+
'.png': 'image/png',
|
|
298
|
+
'.jpg': 'image/jpeg',
|
|
299
|
+
'.jpeg': 'image/jpeg',
|
|
300
|
+
'.webp': 'image/webp',
|
|
301
|
+
'.gif': 'image/gif',
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
async function uploadImageAsset(gameId, filePath, assetType) {
|
|
305
|
+
const resolved = resolve(filePath);
|
|
306
|
+
if (!existsSync(resolved)) {
|
|
307
|
+
console.log(chalk.red(` ✗ File not found: ${resolved}`));
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const ext = extname(resolved).toLowerCase();
|
|
312
|
+
if (!IMAGE_EXTS.includes(ext)) {
|
|
313
|
+
console.log(chalk.red(` ✗ Unsupported format "${ext}". Use: ${IMAGE_EXTS.join(', ')}`));
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const contentType = IMAGE_CONTENT_TYPES[ext];
|
|
318
|
+
const fileSize = statSync(resolved).size;
|
|
319
|
+
const fileName = `${assetType.toLowerCase()}${ext}`;
|
|
320
|
+
const label = assetType.toLowerCase();
|
|
321
|
+
|
|
322
|
+
const spinner = villageSpinner(`Uploading ${label}...`).start();
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const urlResult = await getGameUploadUrl(gameId, {
|
|
326
|
+
fileName,
|
|
327
|
+
assetType,
|
|
328
|
+
contentType,
|
|
329
|
+
fileSize,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const fileBuffer = readFileSync(resolved);
|
|
333
|
+
await axios.put(urlResult.uploadUrl, fileBuffer, {
|
|
334
|
+
headers: { 'Content-Type': contentType },
|
|
335
|
+
maxBodyLength: Infinity,
|
|
336
|
+
maxContentLength: Infinity,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await confirmGameUpload(gameId, {
|
|
340
|
+
s3Key: urlResult.s3Key,
|
|
341
|
+
fileName,
|
|
342
|
+
assetType,
|
|
343
|
+
contentType,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
spinner.succeed(`${label.charAt(0).toUpperCase() + label.slice(1)} uploaded!`);
|
|
347
|
+
return true;
|
|
348
|
+
} catch (err) {
|
|
349
|
+
const message = err.response?.data?.error || err.message;
|
|
350
|
+
spinner.fail(`${label} upload failed: ${message}`);
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function promptForImages(gameId) {
|
|
356
|
+
console.log();
|
|
357
|
+
const { addThumbnail } = await inquirer.prompt([{
|
|
358
|
+
type: 'confirm',
|
|
359
|
+
name: 'addThumbnail',
|
|
360
|
+
message: 'Upload a thumbnail image? (shown on game cards)',
|
|
361
|
+
default: false,
|
|
362
|
+
}]);
|
|
363
|
+
|
|
364
|
+
if (addThumbnail) {
|
|
365
|
+
const { thumbnailPath } = await inquirer.prompt([{
|
|
366
|
+
type: 'input',
|
|
367
|
+
name: 'thumbnailPath',
|
|
368
|
+
message: 'Path to thumbnail image:',
|
|
369
|
+
validate: (input) => {
|
|
370
|
+
if (!input.trim()) return 'Path is required.';
|
|
371
|
+
if (!existsSync(resolve(input.trim()))) return 'File not found.';
|
|
372
|
+
return true;
|
|
373
|
+
},
|
|
374
|
+
}]);
|
|
375
|
+
await uploadImageAsset(gameId, thumbnailPath.trim(), 'THUMBNAIL');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const { addBanner } = await inquirer.prompt([{
|
|
379
|
+
type: 'confirm',
|
|
380
|
+
name: 'addBanner',
|
|
381
|
+
message: 'Upload a banner image? (shown on featured displays)',
|
|
382
|
+
default: false,
|
|
383
|
+
}]);
|
|
384
|
+
|
|
385
|
+
if (addBanner) {
|
|
386
|
+
const { bannerPath } = await inquirer.prompt([{
|
|
387
|
+
type: 'input',
|
|
388
|
+
name: 'bannerPath',
|
|
389
|
+
message: 'Path to banner image:',
|
|
390
|
+
validate: (input) => {
|
|
391
|
+
if (!input.trim()) return 'Path is required.';
|
|
392
|
+
if (!existsSync(resolve(input.trim()))) return 'File not found.';
|
|
393
|
+
return true;
|
|
394
|
+
},
|
|
395
|
+
}]);
|
|
396
|
+
await uploadImageAsset(gameId, bannerPath.trim(), 'BANNER');
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
157
400
|
function formatStatus(status) {
|
|
158
401
|
switch (status) {
|
|
159
402
|
case 'SUBMITTED':
|
|
@@ -193,6 +436,11 @@ function getContentType(filePath) {
|
|
|
193
436
|
woff2: 'font/woff2',
|
|
194
437
|
ttf: 'font/ttf',
|
|
195
438
|
wasm: 'application/wasm',
|
|
439
|
+
// Unity-specific
|
|
440
|
+
data: 'application/octet-stream',
|
|
441
|
+
unityweb: 'application/octet-stream',
|
|
442
|
+
br: 'application/x-brotli',
|
|
443
|
+
gz: 'application/gzip',
|
|
196
444
|
};
|
|
197
445
|
return types[ext] || 'application/octet-stream';
|
|
198
446
|
}
|