@myvillage/cli 1.0.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/LICENSE +21 -0
- package/README.md +195 -0
- package/bin/myvillage.js +5 -0
- package/package.json +37 -0
- package/src/commands/create-game.js +106 -0
- package/src/commands/deploy.js +120 -0
- package/src/commands/login.js +200 -0
- package/src/commands/logout.js +12 -0
- package/src/commands/status.js +126 -0
- package/src/index.js +50 -0
- package/src/utils/api.js +121 -0
- package/src/utils/auth.js +104 -0
- package/src/utils/config.js +48 -0
- package/src/utils/templates.js +1301 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join, resolve } from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
import { isAuthenticated } from '../utils/auth.js';
|
|
6
|
+
import { getMyGames, getGameStats } from '../utils/api.js';
|
|
7
|
+
|
|
8
|
+
function readPackageJson(dir) {
|
|
9
|
+
const pkgPath = join(dir, 'package.json');
|
|
10
|
+
if (!existsSync(pkgPath)) return null;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatDate(dateStr) {
|
|
20
|
+
if (!dateStr) return 'N/A';
|
|
21
|
+
return new Date(dateStr).toLocaleDateString('en-US', {
|
|
22
|
+
year: 'numeric',
|
|
23
|
+
month: 'short',
|
|
24
|
+
day: 'numeric',
|
|
25
|
+
hour: '2-digit',
|
|
26
|
+
minute: '2-digit',
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function statusCommand() {
|
|
31
|
+
// Check authentication
|
|
32
|
+
if (!isAuthenticated()) {
|
|
33
|
+
console.log(chalk.red(' \u2717 Authentication required. Run \'myvillage login\' first.'));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const projectDir = resolve(process.cwd());
|
|
38
|
+
const pkg = readPackageJson(projectDir);
|
|
39
|
+
|
|
40
|
+
// If in a game project directory with a gameId, show that game's status
|
|
41
|
+
if (pkg?.myvillage?.gameId) {
|
|
42
|
+
await showGameStatus(pkg.myvillage.gameId);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Otherwise, list all deployed games
|
|
47
|
+
await showAllGames();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function showGameStatus(gameId) {
|
|
51
|
+
const spinner = ora('Fetching game status...').start();
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const stats = await getGameStats(gameId);
|
|
55
|
+
|
|
56
|
+
spinner.stop();
|
|
57
|
+
|
|
58
|
+
console.log();
|
|
59
|
+
console.log(chalk.bold(` ${stats.name || gameId}`));
|
|
60
|
+
console.log();
|
|
61
|
+
console.log(` Status: ${formatStatus(stats.status)}`);
|
|
62
|
+
console.log(` Play Count: ${chalk.cyan(stats.playCount ?? 0)}`);
|
|
63
|
+
console.log(` MVT Earned: ${chalk.yellow(stats.mvtEarned ?? 0)} tokens`);
|
|
64
|
+
console.log(` Last Updated: ${chalk.dim(formatDate(stats.lastUpdated))}`);
|
|
65
|
+
|
|
66
|
+
if (stats.url) {
|
|
67
|
+
console.log(` URL: ${chalk.cyan(stats.url)}`);
|
|
68
|
+
}
|
|
69
|
+
console.log();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
const message = err.response?.data?.message || err.message;
|
|
72
|
+
spinner.fail(`Failed to fetch game status: ${message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function showAllGames() {
|
|
77
|
+
const spinner = ora('Fetching your games...').start();
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const data = await getMyGames();
|
|
81
|
+
const games = data.games || data || [];
|
|
82
|
+
|
|
83
|
+
spinner.stop();
|
|
84
|
+
|
|
85
|
+
if (!games.length) {
|
|
86
|
+
console.log();
|
|
87
|
+
console.log(chalk.dim(' No deployed games found.'));
|
|
88
|
+
console.log(chalk.dim(' Create a game with "myvillage create-game" and deploy it!'));
|
|
89
|
+
console.log();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log();
|
|
94
|
+
console.log(chalk.bold(` Your Games (${games.length})`));
|
|
95
|
+
console.log();
|
|
96
|
+
|
|
97
|
+
for (const game of games) {
|
|
98
|
+
console.log(` ${chalk.yellow(game.name || game.gameId)}`);
|
|
99
|
+
console.log(` Status: ${formatStatus(game.status)} | Plays: ${chalk.cyan(game.playCount ?? 0)} | MVT: ${chalk.yellow(game.mvtEarned ?? 0)}`);
|
|
100
|
+
|
|
101
|
+
if (game.url) {
|
|
102
|
+
console.log(` ${chalk.dim(game.url)}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.log();
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
const message = err.response?.data?.message || err.message;
|
|
109
|
+
spinner.fail(`Failed to fetch games: ${message}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function formatStatus(status) {
|
|
114
|
+
switch (status) {
|
|
115
|
+
case 'live':
|
|
116
|
+
return chalk.green('Live');
|
|
117
|
+
case 'building':
|
|
118
|
+
return chalk.yellow('Building');
|
|
119
|
+
case 'failed':
|
|
120
|
+
return chalk.red('Failed');
|
|
121
|
+
case 'pending':
|
|
122
|
+
return chalk.dim('Pending');
|
|
123
|
+
default:
|
|
124
|
+
return chalk.dim(status || 'Unknown');
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { createRequire } from 'module';
|
|
3
|
+
import updateNotifier from 'update-notifier';
|
|
4
|
+
import { loginCommand } from './commands/login.js';
|
|
5
|
+
import { logoutCommand } from './commands/logout.js';
|
|
6
|
+
import { createGameCommand } from './commands/create-game.js';
|
|
7
|
+
import { deployCommand } from './commands/deploy.js';
|
|
8
|
+
import { statusCommand } from './commands/status.js';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const pkg = require('../package.json');
|
|
12
|
+
|
|
13
|
+
export function run() {
|
|
14
|
+
// Check for CLI updates
|
|
15
|
+
updateNotifier({ pkg }).notify();
|
|
16
|
+
|
|
17
|
+
const program = new Command();
|
|
18
|
+
|
|
19
|
+
program
|
|
20
|
+
.name('myvillage')
|
|
21
|
+
.description('MyVillageOS CLI for student game developers')
|
|
22
|
+
.version(pkg.version);
|
|
23
|
+
|
|
24
|
+
program
|
|
25
|
+
.command('login')
|
|
26
|
+
.description('Authenticate with MyVillageOS')
|
|
27
|
+
.action(loginCommand);
|
|
28
|
+
|
|
29
|
+
program
|
|
30
|
+
.command('logout')
|
|
31
|
+
.description('Clear stored credentials')
|
|
32
|
+
.action(logoutCommand);
|
|
33
|
+
|
|
34
|
+
program
|
|
35
|
+
.command('create-game')
|
|
36
|
+
.description('Create a new game project with interactive wizard')
|
|
37
|
+
.action(createGameCommand);
|
|
38
|
+
|
|
39
|
+
program
|
|
40
|
+
.command('deploy')
|
|
41
|
+
.description('Deploy your game to MyVillageOS')
|
|
42
|
+
.action(deployCommand);
|
|
43
|
+
|
|
44
|
+
program
|
|
45
|
+
.command('status')
|
|
46
|
+
.description('Check deployment status and game analytics')
|
|
47
|
+
.action(statusCommand);
|
|
48
|
+
|
|
49
|
+
program.parse();
|
|
50
|
+
}
|
package/src/utils/api.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { getConfig } from './config.js';
|
|
3
|
+
import { loadCredentials, saveCredentials, getAccessToken } from './auth.js';
|
|
4
|
+
|
|
5
|
+
const USER_AGENT = 'MyVillageOS-CLI/1.0.0';
|
|
6
|
+
|
|
7
|
+
function createClient() {
|
|
8
|
+
const config = getConfig();
|
|
9
|
+
|
|
10
|
+
const client = axios.create({
|
|
11
|
+
baseURL: config.apiBaseUrl,
|
|
12
|
+
headers: {
|
|
13
|
+
'User-Agent': USER_AGENT,
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// Add auth token to requests
|
|
18
|
+
client.interceptors.request.use((reqConfig) => {
|
|
19
|
+
const token = getAccessToken();
|
|
20
|
+
if (token) {
|
|
21
|
+
reqConfig.headers.Authorization = `Bearer ${token}`;
|
|
22
|
+
}
|
|
23
|
+
return reqConfig;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Handle 401 with token refresh
|
|
27
|
+
client.interceptors.response.use(
|
|
28
|
+
(response) => response,
|
|
29
|
+
async (error) => {
|
|
30
|
+
const originalRequest = error.config;
|
|
31
|
+
|
|
32
|
+
if (error.response?.status === 401 && !originalRequest._retry) {
|
|
33
|
+
originalRequest._retry = true;
|
|
34
|
+
|
|
35
|
+
const refreshed = await refreshAccessToken();
|
|
36
|
+
if (refreshed) {
|
|
37
|
+
originalRequest.headers.Authorization = `Bearer ${getAccessToken()}`;
|
|
38
|
+
return client(originalRequest);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return Promise.reject(error);
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return client;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function refreshAccessToken() {
|
|
50
|
+
const config = getConfig();
|
|
51
|
+
const creds = loadCredentials();
|
|
52
|
+
|
|
53
|
+
if (!creds?.refresh_token) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const response = await axios.post(
|
|
59
|
+
`${config.oauthBaseUrl}/token`,
|
|
60
|
+
new URLSearchParams({
|
|
61
|
+
grant_type: 'refresh_token',
|
|
62
|
+
refresh_token: creds.refresh_token,
|
|
63
|
+
client_id: config.clientId,
|
|
64
|
+
}),
|
|
65
|
+
{
|
|
66
|
+
headers: {
|
|
67
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
68
|
+
'User-Agent': USER_AGENT,
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const { access_token, refresh_token, expires_in } = response.data;
|
|
74
|
+
|
|
75
|
+
saveCredentials({
|
|
76
|
+
...creds,
|
|
77
|
+
access_token,
|
|
78
|
+
refresh_token: refresh_token || creds.refresh_token,
|
|
79
|
+
expires_at: Date.now() + expires_in * 1000,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getApiClient() {
|
|
89
|
+
return createClient();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function getUserInfo(accessToken) {
|
|
93
|
+
const config = getConfig();
|
|
94
|
+
|
|
95
|
+
const response = await axios.get(`${config.oauthBaseUrl}/userinfo`, {
|
|
96
|
+
headers: {
|
|
97
|
+
Authorization: `Bearer ${accessToken}`,
|
|
98
|
+
'User-Agent': USER_AGENT,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return response.data;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function deployGame(gameData) {
|
|
106
|
+
const client = getApiClient();
|
|
107
|
+
const response = await client.post('/games/deploy', gameData);
|
|
108
|
+
return response.data;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function getMyGames() {
|
|
112
|
+
const client = getApiClient();
|
|
113
|
+
const response = await client.get('/games/my-games');
|
|
114
|
+
return response.data;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function getGameStats(gameId) {
|
|
118
|
+
const client = getApiClient();
|
|
119
|
+
const response = await client.get(`/games/${gameId}/stats`);
|
|
120
|
+
return response.data;
|
|
121
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, chmodSync } from 'fs';
|
|
2
|
+
import { homedir, hostname, userInfo } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { createCipheriv, createDecipheriv, scryptSync, randomBytes } from 'crypto';
|
|
5
|
+
import { getConfigDir } from './config.js';
|
|
6
|
+
|
|
7
|
+
const CREDENTIALS_FILE = join(homedir(), '.myvillage', 'credentials.json');
|
|
8
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
9
|
+
|
|
10
|
+
// Derive an encryption key from machine-specific data
|
|
11
|
+
function getEncryptionKey() {
|
|
12
|
+
const machineId = `${hostname()}-${userInfo().username}-myvillage-cli`;
|
|
13
|
+
return scryptSync(machineId, 'myvillageos-salt', 32);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function encrypt(text) {
|
|
17
|
+
const key = getEncryptionKey();
|
|
18
|
+
const iv = randomBytes(16);
|
|
19
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
20
|
+
|
|
21
|
+
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
22
|
+
encrypted += cipher.final('hex');
|
|
23
|
+
|
|
24
|
+
const authTag = cipher.getAuthTag().toString('hex');
|
|
25
|
+
|
|
26
|
+
return JSON.stringify({
|
|
27
|
+
iv: iv.toString('hex'),
|
|
28
|
+
encrypted,
|
|
29
|
+
authTag,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function decrypt(payload) {
|
|
34
|
+
const key = getEncryptionKey();
|
|
35
|
+
const { iv, encrypted, authTag } = JSON.parse(payload);
|
|
36
|
+
|
|
37
|
+
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex'));
|
|
38
|
+
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
|
|
39
|
+
|
|
40
|
+
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
41
|
+
decrypted += decipher.final('utf8');
|
|
42
|
+
|
|
43
|
+
return decrypted;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function saveCredentials(credentials) {
|
|
47
|
+
getConfigDir(); // Ensure directory exists
|
|
48
|
+
const encrypted = encrypt(JSON.stringify(credentials));
|
|
49
|
+
writeFileSync(CREDENTIALS_FILE, encrypted, { mode: 0o600 });
|
|
50
|
+
|
|
51
|
+
// Ensure restrictive permissions
|
|
52
|
+
try {
|
|
53
|
+
chmodSync(CREDENTIALS_FILE, 0o600);
|
|
54
|
+
} catch {
|
|
55
|
+
// Permissions may not be supported on all platforms
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function loadCredentials() {
|
|
60
|
+
if (!existsSync(CREDENTIALS_FILE)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const data = readFileSync(CREDENTIALS_FILE, 'utf-8');
|
|
66
|
+
const decrypted = decrypt(data);
|
|
67
|
+
return JSON.parse(decrypted);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function clearCredentials() {
|
|
74
|
+
if (existsSync(CREDENTIALS_FILE)) {
|
|
75
|
+
unlinkSync(CREDENTIALS_FILE);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isAuthenticated() {
|
|
82
|
+
const creds = loadCredentials();
|
|
83
|
+
if (!creds || !creds.access_token) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check if token is expired
|
|
88
|
+
if (creds.expires_at && Date.now() >= creds.expires_at) {
|
|
89
|
+
// Token expired, but we might have a refresh token
|
|
90
|
+
return !!creds.refresh_token;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function getAccessToken() {
|
|
97
|
+
const creds = loadCredentials();
|
|
98
|
+
return creds?.access_token || null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getRefreshToken() {
|
|
102
|
+
const creds = loadCredentials();
|
|
103
|
+
return creds?.refresh_token || null;
|
|
104
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = join(homedir(), '.myvillage');
|
|
6
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CONFIG = {
|
|
9
|
+
apiBaseUrl: 'https://portal.myvillageproject.ai/api/v1',
|
|
10
|
+
oauthBaseUrl: 'https://portal.myvillageproject.ai/api/oauth',
|
|
11
|
+
clientId: 'myvillage-cli',
|
|
12
|
+
callbackPort: 3737,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function ensureConfigDir() {
|
|
16
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
17
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getConfig() {
|
|
22
|
+
ensureConfigDir();
|
|
23
|
+
|
|
24
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
25
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(DEFAULT_CONFIG, null, 2));
|
|
26
|
+
return { ...DEFAULT_CONFIG };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const data = readFileSync(CONFIG_FILE, 'utf-8');
|
|
31
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(data) };
|
|
32
|
+
} catch {
|
|
33
|
+
return { ...DEFAULT_CONFIG };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function setConfig(updates) {
|
|
38
|
+
ensureConfigDir();
|
|
39
|
+
const current = getConfig();
|
|
40
|
+
const merged = { ...current, ...updates };
|
|
41
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
|
|
42
|
+
return merged;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getConfigDir() {
|
|
46
|
+
ensureConfigDir();
|
|
47
|
+
return CONFIG_DIR;
|
|
48
|
+
}
|