@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MyVillage Project
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# MyVillageOS CLI
|
|
2
|
+
|
|
3
|
+
A command-line interface for MyVillage Project student developers to create, manage, and deploy educational games built with Three.js for the M-UNI platform.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Requires Node.js 18 or higher.
|
|
8
|
+
|
|
9
|
+
### From npm (once published)
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g @myvillage/cli
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
> **Note:** The package is not yet published to npm. Use the development install below.
|
|
16
|
+
|
|
17
|
+
### Development install (from source)
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Clone the repository
|
|
21
|
+
git clone https://github.com/MyVillage-Project-Technologies/MyVillageOS-CLI.git
|
|
22
|
+
cd MyVillageOS-CLI
|
|
23
|
+
|
|
24
|
+
# Install dependencies
|
|
25
|
+
npm install
|
|
26
|
+
|
|
27
|
+
# Link the CLI globally so "myvillage" command works anywhere
|
|
28
|
+
npm link
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
After linking, the `myvillage` command is available system-wide. To unlink later, run `npm unlink -g @myvillage/cli`.
|
|
32
|
+
|
|
33
|
+
You can also run commands directly without linking:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
node bin/myvillage.js --help
|
|
37
|
+
node bin/myvillage.js create-game
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# 1. Log in with your MyVillageOS account
|
|
44
|
+
myvillage login
|
|
45
|
+
|
|
46
|
+
# 2. Create a new game project
|
|
47
|
+
myvillage create-game
|
|
48
|
+
|
|
49
|
+
# 3. Develop your game
|
|
50
|
+
cd my-game
|
|
51
|
+
npm run dev
|
|
52
|
+
|
|
53
|
+
# 4. Deploy to MyVillageOS
|
|
54
|
+
myvillage deploy
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Commands
|
|
58
|
+
|
|
59
|
+
### `myvillage login`
|
|
60
|
+
|
|
61
|
+
Authenticate with your MyVillageOS account via OAuth. Opens your browser for secure login, then stores credentials locally.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
myvillage login
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### `myvillage logout`
|
|
68
|
+
|
|
69
|
+
Clear stored credentials.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
myvillage logout
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### `myvillage create-game`
|
|
76
|
+
|
|
77
|
+
Interactive wizard to scaffold a new game project. Choose from four templates:
|
|
78
|
+
|
|
79
|
+
| Template | Description |
|
|
80
|
+
|----------|-------------|
|
|
81
|
+
| **Quiz Game** | Test knowledge with interactive questions, scoring, and celebrations |
|
|
82
|
+
| **Exploration Game** | Navigate with WASD, collect items, minimap, and inventory |
|
|
83
|
+
| **Narrative Game** | Story-driven with dialogue, branching choices, and scene transitions |
|
|
84
|
+
| **Custom** | Base Three.js template to build anything from scratch |
|
|
85
|
+
|
|
86
|
+
All templates include:
|
|
87
|
+
- Responsive Three.js canvas (desktop + mobile)
|
|
88
|
+
- Camera and lighting setup
|
|
89
|
+
- Input handling (keyboard, mouse, touch)
|
|
90
|
+
- MyVillage brand styling
|
|
91
|
+
- UI overlay system
|
|
92
|
+
- Audio manager
|
|
93
|
+
- Vite dev server with hot reload
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
myvillage create-game
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### `myvillage deploy`
|
|
100
|
+
|
|
101
|
+
Build and deploy your game to the MyVillageOS platform. Run this from inside a game project directory.
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
cd my-game
|
|
105
|
+
myvillage deploy
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
You earn MVT tokens for each successful deployment.
|
|
109
|
+
|
|
110
|
+
### `myvillage status`
|
|
111
|
+
|
|
112
|
+
Check deployment status and analytics. If run inside a game project directory, shows status for that game. Otherwise, lists all your deployed games.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
myvillage status
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Game Project Structure
|
|
119
|
+
|
|
120
|
+
Games created with the CLI follow this structure:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
my-game/
|
|
124
|
+
├── public/
|
|
125
|
+
│ ├── index.html # HTML entry point
|
|
126
|
+
│ └── assets/ # Static assets
|
|
127
|
+
│ ├── models/ # 3D models
|
|
128
|
+
│ ├── textures/ # Textures and images
|
|
129
|
+
│ └── audio/ # Sound effects and music
|
|
130
|
+
├── src/
|
|
131
|
+
│ ├── main.js # Game entry point
|
|
132
|
+
│ ├── scenes/
|
|
133
|
+
│ │ └── MainScene.js # Three.js scene setup
|
|
134
|
+
│ ├── components/
|
|
135
|
+
│ │ ├── UI.js # UI overlay (HUD, menus)
|
|
136
|
+
│ │ └── GameLogic.js # Game-specific logic
|
|
137
|
+
│ └── utils/
|
|
138
|
+
│ ├── InputManager.js # Keyboard/mouse/touch input
|
|
139
|
+
│ └── AudioManager.js # Sound effects and music
|
|
140
|
+
├── package.json
|
|
141
|
+
├── vite.config.js
|
|
142
|
+
└── README.md
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Development Workflow
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
# Start the dev server with hot reload
|
|
149
|
+
npm run dev
|
|
150
|
+
|
|
151
|
+
# Build for production
|
|
152
|
+
npm run build
|
|
153
|
+
|
|
154
|
+
# Preview the production build
|
|
155
|
+
npm run preview
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Configuration
|
|
159
|
+
|
|
160
|
+
CLI configuration is stored in `~/.myvillage/config.json`:
|
|
161
|
+
|
|
162
|
+
```json
|
|
163
|
+
{
|
|
164
|
+
"apiBaseUrl": "https://portal.myvillageproject.ai/api/v1",
|
|
165
|
+
"oauthBaseUrl": "https://portal.myvillageproject.ai/api/oauth",
|
|
166
|
+
"clientId": "myvillage-cli",
|
|
167
|
+
"callbackPort": 3737
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Credentials are stored encrypted in `~/.myvillage/credentials.json` with restrictive file permissions.
|
|
172
|
+
|
|
173
|
+
## Troubleshooting
|
|
174
|
+
|
|
175
|
+
**"Authentication required" error:**
|
|
176
|
+
Run `myvillage login` to authenticate before using other commands.
|
|
177
|
+
|
|
178
|
+
**"Not a valid game project directory" error:**
|
|
179
|
+
Make sure you're inside a game directory created with `myvillage create-game`. The `package.json` must contain a `myvillage` configuration block.
|
|
180
|
+
|
|
181
|
+
**Browser doesn't open during login:**
|
|
182
|
+
Copy the URL printed in the terminal and open it manually in your browser.
|
|
183
|
+
|
|
184
|
+
**Port 3737 in use during login:**
|
|
185
|
+
Close other applications using port 3737, or update `callbackPort` in `~/.myvillage/config.json`.
|
|
186
|
+
|
|
187
|
+
## Support
|
|
188
|
+
|
|
189
|
+
- **Technical Support**: dev@myvillageproject.ai
|
|
190
|
+
- **General Inquiries**: info@myvillageproject.ai
|
|
191
|
+
- **MyVillageOS Portal**: https://portal.myvillageproject.ai
|
|
192
|
+
|
|
193
|
+
## License
|
|
194
|
+
|
|
195
|
+
MIT
|
package/bin/myvillage.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@myvillage/cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MyVillageOS CLI for student game developers",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"myvillage": "./bin/myvillage.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18.0.0"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/",
|
|
14
|
+
"src/"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"myvillage",
|
|
18
|
+
"myvillageos",
|
|
19
|
+
"cli",
|
|
20
|
+
"game",
|
|
21
|
+
"threejs",
|
|
22
|
+
"education",
|
|
23
|
+
"m-uni"
|
|
24
|
+
],
|
|
25
|
+
"author": "MyVillage Project",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"commander": "^11.1.0",
|
|
29
|
+
"inquirer": "^9.2.12",
|
|
30
|
+
"axios": "^1.6.2",
|
|
31
|
+
"chalk": "^5.3.0",
|
|
32
|
+
"ora": "^8.0.1",
|
|
33
|
+
"open": "^10.0.3",
|
|
34
|
+
"conf": "^12.0.0",
|
|
35
|
+
"update-notifier": "^7.0.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { join, resolve } from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import inquirer from 'inquirer';
|
|
7
|
+
import { isAuthenticated } from '../utils/auth.js';
|
|
8
|
+
import { createGameProject } from '../utils/templates.js';
|
|
9
|
+
|
|
10
|
+
export async function createGameCommand() {
|
|
11
|
+
// Check authentication
|
|
12
|
+
if (!isAuthenticated()) {
|
|
13
|
+
console.log(chalk.red(' \u2717 Authentication required. Run \'myvillage login\' first.'));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Interactive prompts
|
|
18
|
+
const answers = await inquirer.prompt([
|
|
19
|
+
{
|
|
20
|
+
type: 'list',
|
|
21
|
+
name: 'type',
|
|
22
|
+
message: 'What type of game would you like to create?',
|
|
23
|
+
choices: [
|
|
24
|
+
{ name: 'Quiz Game - Test knowledge with interactive questions', value: 'quiz' },
|
|
25
|
+
{ name: 'Exploration Game - Navigate and discover', value: 'exploration' },
|
|
26
|
+
{ name: 'Narrative Game - Story-driven experience', value: 'narrative' },
|
|
27
|
+
{ name: 'Custom - Start with base Three.js template', value: 'custom' },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
type: 'input',
|
|
32
|
+
name: 'name',
|
|
33
|
+
message: 'Game name:',
|
|
34
|
+
default: 'My Game',
|
|
35
|
+
validate: (input) => {
|
|
36
|
+
if (!input.trim()) return 'Game name is required.';
|
|
37
|
+
return true;
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
type: 'input',
|
|
42
|
+
name: 'description',
|
|
43
|
+
message: 'Game description:',
|
|
44
|
+
default: 'An educational game built with Three.js',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: 'list',
|
|
48
|
+
name: 'ageGroup',
|
|
49
|
+
message: 'Target age group:',
|
|
50
|
+
choices: [
|
|
51
|
+
{ name: '5-8 years', value: '5-8' },
|
|
52
|
+
{ name: '9-12 years', value: '9-12' },
|
|
53
|
+
{ name: '13-15 years', value: '13-15' },
|
|
54
|
+
{ name: '16-18 years', value: '16-18' },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// Create project directory
|
|
60
|
+
const slug = answers.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
61
|
+
const targetDir = resolve(process.cwd(), slug);
|
|
62
|
+
|
|
63
|
+
if (existsSync(targetDir)) {
|
|
64
|
+
console.log(chalk.red(`\n \u2717 Directory "${slug}" already exists. Choose a different name or remove the existing directory.`));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const spinner = ora('Creating game project...').start();
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
// Generate project from template
|
|
72
|
+
createGameProject(targetDir, {
|
|
73
|
+
name: answers.name,
|
|
74
|
+
description: answers.description,
|
|
75
|
+
type: answers.type,
|
|
76
|
+
ageGroup: answers.ageGroup,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
spinner.text = 'Installing dependencies...';
|
|
80
|
+
|
|
81
|
+
// Run npm install in the new project directory
|
|
82
|
+
execSync('npm install', {
|
|
83
|
+
cwd: targetDir,
|
|
84
|
+
stdio: 'pipe',
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
spinner.succeed(`Game "${answers.name}" created successfully!`);
|
|
88
|
+
|
|
89
|
+
// Display next steps
|
|
90
|
+
console.log();
|
|
91
|
+
console.log(chalk.green(` \u2713 Game "${answers.name}" created successfully!`));
|
|
92
|
+
console.log();
|
|
93
|
+
console.log(chalk.dim(' Next steps:'));
|
|
94
|
+
console.log();
|
|
95
|
+
console.log(` ${chalk.cyan('cd')} ${slug}`);
|
|
96
|
+
console.log(` ${chalk.cyan('npm run dev')} ${chalk.dim('- Start development server')}`);
|
|
97
|
+
console.log(` ${chalk.cyan('npm run build')} ${chalk.dim('- Build for production')}`);
|
|
98
|
+
console.log(` ${chalk.cyan('myvillage deploy')} ${chalk.dim('- Deploy to MyVillageOS')}`);
|
|
99
|
+
console.log();
|
|
100
|
+
console.log(chalk.dim(` Game type: ${answers.type} | Age group: ${answers.ageGroup}`));
|
|
101
|
+
console.log();
|
|
102
|
+
} catch (err) {
|
|
103
|
+
spinner.fail('Failed to create game project.');
|
|
104
|
+
console.error(chalk.red(` ${err.message}`));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { join, resolve, relative } from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import { isAuthenticated } from '../utils/auth.js';
|
|
7
|
+
import { deployGame } from '../utils/api.js';
|
|
8
|
+
|
|
9
|
+
function readPackageJson(dir) {
|
|
10
|
+
const pkgPath = join(dir, 'package.json');
|
|
11
|
+
if (!existsSync(pkgPath)) return null;
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Recursively collect files from a directory, returning { relativePath: base64Content }
|
|
21
|
+
function collectBuildFiles(dir, baseDir) {
|
|
22
|
+
const files = {};
|
|
23
|
+
|
|
24
|
+
function walk(currentDir) {
|
|
25
|
+
const entries = readdirSync(currentDir, { withFileTypes: true });
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
const fullPath = join(currentDir, entry.name);
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
walk(fullPath);
|
|
30
|
+
} else {
|
|
31
|
+
const relPath = relative(baseDir, fullPath);
|
|
32
|
+
const content = readFileSync(fullPath);
|
|
33
|
+
files[relPath] = content.toString('base64');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
walk(dir);
|
|
39
|
+
return files;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function deployCommand() {
|
|
43
|
+
// Check authentication
|
|
44
|
+
if (!isAuthenticated()) {
|
|
45
|
+
console.log(chalk.red(' \u2717 Authentication required. Run \'myvillage login\' first.'));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const projectDir = resolve(process.cwd());
|
|
50
|
+
const pkg = readPackageJson(projectDir);
|
|
51
|
+
|
|
52
|
+
// Verify this is a valid MyVillageOS game project
|
|
53
|
+
if (!pkg || !pkg.myvillage) {
|
|
54
|
+
console.log(chalk.red(' \u2717 Not a valid game project directory.'));
|
|
55
|
+
console.log(chalk.dim(' Make sure you are inside a game directory created with "myvillage create-game".'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const spinner = ora('Building game...').start();
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
// Run production build
|
|
63
|
+
execSync('npm run build', {
|
|
64
|
+
cwd: projectDir,
|
|
65
|
+
stdio: 'pipe',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Check that build output exists
|
|
69
|
+
const distDir = join(projectDir, 'dist');
|
|
70
|
+
if (!existsSync(distDir)) {
|
|
71
|
+
spinner.fail('Build completed but no dist/ directory found.');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
spinner.text = 'Packaging build artifacts...';
|
|
76
|
+
|
|
77
|
+
// Collect build files
|
|
78
|
+
const files = collectBuildFiles(distDir, distDir);
|
|
79
|
+
|
|
80
|
+
spinner.text = 'Deploying to MyVillageOS...';
|
|
81
|
+
|
|
82
|
+
// Upload to MyVillageOS API
|
|
83
|
+
const result = await deployGame({
|
|
84
|
+
name: pkg.name,
|
|
85
|
+
description: pkg.description || '',
|
|
86
|
+
category: pkg.myvillage.gameType || 'custom',
|
|
87
|
+
targetAge: pkg.myvillage.targetAge || 'all',
|
|
88
|
+
files,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
spinner.succeed('Deployment complete!');
|
|
92
|
+
console.log();
|
|
93
|
+
console.log(chalk.green(` \u2713 Deployed to ${result.url}`));
|
|
94
|
+
|
|
95
|
+
if (result.mvtAwarded) {
|
|
96
|
+
console.log(chalk.green(` \u2713 You earned ${result.mvtAwarded} MVT tokens!`));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (result.gameId) {
|
|
100
|
+
console.log(chalk.dim(` Game ID: ${result.gameId}`));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log();
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (err.response) {
|
|
106
|
+
// API error
|
|
107
|
+
const message = err.response.data?.error_description
|
|
108
|
+
|| err.response.data?.error
|
|
109
|
+
|| err.response.data?.message
|
|
110
|
+
|| 'Unknown server error';
|
|
111
|
+
spinner.fail(`Deployment failed: ${message}`);
|
|
112
|
+
} else if (err.stderr) {
|
|
113
|
+
// Build error
|
|
114
|
+
spinner.fail('Build failed.');
|
|
115
|
+
console.error(chalk.red(` ${err.stderr.toString().trim()}`));
|
|
116
|
+
} else {
|
|
117
|
+
spinner.fail(`Deployment failed: ${err.message}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { createServer } from 'http';
|
|
2
|
+
import { randomBytes, createHash } from 'crypto';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
import open from 'open';
|
|
6
|
+
import axios from 'axios';
|
|
7
|
+
import { getConfig } from '../utils/config.js';
|
|
8
|
+
import { saveCredentials, loadCredentials } from '../utils/auth.js';
|
|
9
|
+
import { getUserInfo } from '../utils/api.js';
|
|
10
|
+
|
|
11
|
+
// Generate PKCE code verifier (random 64-char hex string)
|
|
12
|
+
function generateCodeVerifier() {
|
|
13
|
+
return randomBytes(32).toString('hex');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Generate S256 code challenge from verifier
|
|
17
|
+
function generateCodeChallenge(verifier) {
|
|
18
|
+
return createHash('sha256')
|
|
19
|
+
.update(verifier)
|
|
20
|
+
.digest('base64url');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function loginCommand() {
|
|
24
|
+
const config = getConfig();
|
|
25
|
+
|
|
26
|
+
// Check if already logged in
|
|
27
|
+
const existing = loadCredentials();
|
|
28
|
+
if (existing?.access_token) {
|
|
29
|
+
console.log(chalk.yellow('You are already logged in.'));
|
|
30
|
+
console.log(chalk.dim('Run "myvillage logout" first to log in with a different account.'));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const spinner = ora('Preparing authentication...').start();
|
|
35
|
+
|
|
36
|
+
// Generate PKCE values and state token
|
|
37
|
+
const state = randomBytes(16).toString('hex');
|
|
38
|
+
const codeVerifier = generateCodeVerifier();
|
|
39
|
+
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
40
|
+
|
|
41
|
+
// Build authorization URL
|
|
42
|
+
const params = new URLSearchParams({
|
|
43
|
+
client_id: config.clientId,
|
|
44
|
+
redirect_uri: `http://localhost:${config.callbackPort}/callback`,
|
|
45
|
+
response_type: 'code',
|
|
46
|
+
scope: 'openid profile email villager offline_access',
|
|
47
|
+
state,
|
|
48
|
+
code_challenge: codeChallenge,
|
|
49
|
+
code_challenge_method: 'S256',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const authUrl = `${config.oauthBaseUrl}/authorize?${params.toString()}`;
|
|
53
|
+
|
|
54
|
+
// Create a promise that resolves when we receive the OAuth callback
|
|
55
|
+
const authResult = await new Promise((resolve, reject) => {
|
|
56
|
+
const server = createServer(async (req, res) => {
|
|
57
|
+
const url = new URL(req.url, `http://localhost:${config.callbackPort}`);
|
|
58
|
+
|
|
59
|
+
if (url.pathname !== '/callback') {
|
|
60
|
+
res.writeHead(404);
|
|
61
|
+
res.end('Not found');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const code = url.searchParams.get('code');
|
|
66
|
+
const returnedState = url.searchParams.get('state');
|
|
67
|
+
const error = url.searchParams.get('error');
|
|
68
|
+
const errorDescription = url.searchParams.get('error_description');
|
|
69
|
+
|
|
70
|
+
// Send response to the browser
|
|
71
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
72
|
+
if (error) {
|
|
73
|
+
res.end(`
|
|
74
|
+
<html><body style="font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; background: #302017; color: #E4DCCB;">
|
|
75
|
+
<div style="text-align: center;">
|
|
76
|
+
<h1 style="color: #FF6B6B;">Authentication Failed</h1>
|
|
77
|
+
<p>${errorDescription || error}</p>
|
|
78
|
+
<p>You can close this window.</p>
|
|
79
|
+
</div>
|
|
80
|
+
</body></html>
|
|
81
|
+
`);
|
|
82
|
+
} else {
|
|
83
|
+
res.end(`
|
|
84
|
+
<html><body style="font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; background: #302017; color: #E4DCCB;">
|
|
85
|
+
<div style="text-align: center;">
|
|
86
|
+
<h1 style="color: #FFD700;">Success!</h1>
|
|
87
|
+
<p>You are now logged in to MyVillageOS CLI.</p>
|
|
88
|
+
<p>You can close this window and return to your terminal.</p>
|
|
89
|
+
</div>
|
|
90
|
+
</body></html>
|
|
91
|
+
`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Close the server
|
|
95
|
+
server.close();
|
|
96
|
+
|
|
97
|
+
if (error) {
|
|
98
|
+
reject(new Error(errorDescription || error));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Validate state parameter
|
|
103
|
+
if (returnedState !== state) {
|
|
104
|
+
reject(new Error('State mismatch - possible CSRF attack. Please try again.'));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
resolve(code);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
server.listen(config.callbackPort, () => {
|
|
112
|
+
spinner.text = 'Opening browser for authentication...';
|
|
113
|
+
|
|
114
|
+
// Open browser to authorization URL
|
|
115
|
+
open(authUrl).catch(() => {
|
|
116
|
+
spinner.stop();
|
|
117
|
+
console.log(chalk.yellow('\nCould not open browser automatically.'));
|
|
118
|
+
console.log(chalk.dim('Please open this URL in your browser:'));
|
|
119
|
+
console.log(chalk.cyan(authUrl));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
spinner.text = 'Waiting for authentication in browser...';
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
server.on('error', (err) => {
|
|
126
|
+
if (err.code === 'EADDRINUSE') {
|
|
127
|
+
reject(new Error(`Port ${config.callbackPort} is already in use. Close other applications and try again.`));
|
|
128
|
+
} else {
|
|
129
|
+
reject(err);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Timeout after 5 minutes
|
|
134
|
+
setTimeout(() => {
|
|
135
|
+
server.close();
|
|
136
|
+
reject(new Error('Authentication timed out. Please try again.'));
|
|
137
|
+
}, 5 * 60 * 1000);
|
|
138
|
+
}).catch((err) => {
|
|
139
|
+
spinner.fail(err.message);
|
|
140
|
+
return null;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (!authResult) return;
|
|
144
|
+
|
|
145
|
+
// Exchange authorization code for tokens
|
|
146
|
+
spinner.text = 'Exchanging authorization code for tokens...';
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const tokenResponse = await axios.post(
|
|
150
|
+
`${config.oauthBaseUrl}/token`,
|
|
151
|
+
new URLSearchParams({
|
|
152
|
+
grant_type: 'authorization_code',
|
|
153
|
+
code: authResult,
|
|
154
|
+
redirect_uri: `http://localhost:${config.callbackPort}/callback`,
|
|
155
|
+
client_id: config.clientId,
|
|
156
|
+
code_verifier: codeVerifier,
|
|
157
|
+
}),
|
|
158
|
+
{
|
|
159
|
+
headers: {
|
|
160
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
161
|
+
'User-Agent': 'MyVillageOS-CLI/1.0.0',
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const { access_token, refresh_token, expires_in } = tokenResponse.data;
|
|
167
|
+
|
|
168
|
+
// Fetch user info
|
|
169
|
+
spinner.text = 'Fetching user info...';
|
|
170
|
+
const userInfo = await getUserInfo(access_token);
|
|
171
|
+
|
|
172
|
+
// Store credentials securely
|
|
173
|
+
saveCredentials({
|
|
174
|
+
access_token,
|
|
175
|
+
refresh_token,
|
|
176
|
+
expires_at: Date.now() + expires_in * 1000,
|
|
177
|
+
user_id: userInfo.sub,
|
|
178
|
+
user_email: userInfo.email,
|
|
179
|
+
user_name: userInfo.name,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
spinner.succeed('Authentication complete!');
|
|
183
|
+
console.log();
|
|
184
|
+
console.log(chalk.green(` \u2713 Successfully logged in as ${userInfo.email}`));
|
|
185
|
+
|
|
186
|
+
if (userInfo.villager) {
|
|
187
|
+
const v = userInfo.villager;
|
|
188
|
+
if (v.mvtBalance !== undefined) {
|
|
189
|
+
console.log(chalk.dim(` MVT Balance: ${v.mvtBalance} tokens`));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
console.log();
|
|
194
|
+
} catch (err) {
|
|
195
|
+
const message = err.response?.data?.error_description
|
|
196
|
+
|| err.response?.data?.error
|
|
197
|
+
|| err.message;
|
|
198
|
+
spinner.fail(`Authentication failed: ${message}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { clearCredentials } from '../utils/auth.js';
|
|
3
|
+
|
|
4
|
+
export async function logoutCommand() {
|
|
5
|
+
const removed = clearCredentials();
|
|
6
|
+
|
|
7
|
+
if (removed) {
|
|
8
|
+
console.log(chalk.green(' \u2713 Successfully logged out.'));
|
|
9
|
+
} else {
|
|
10
|
+
console.log(chalk.dim(' No stored credentials found. You are not logged in.'));
|
|
11
|
+
}
|
|
12
|
+
}
|