@magpiecloud/mags 1.0.0 → 1.2.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/README.md +31 -2
- package/bin/mags.js +328 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,18 +10,47 @@ npm install -g @magpiecloud/mags
|
|
|
10
10
|
|
|
11
11
|
## Quick Start
|
|
12
12
|
|
|
13
|
-
### 1.
|
|
13
|
+
### 1. Login to Magpie
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
|
|
16
|
+
mags login
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
This will open your browser to create an API token. Paste the token when prompted, and it will be saved for future use.
|
|
20
|
+
|
|
19
21
|
### 2. Run a script
|
|
20
22
|
|
|
21
23
|
```bash
|
|
22
24
|
mags run 'echo Hello World'
|
|
23
25
|
```
|
|
24
26
|
|
|
27
|
+
## Authentication
|
|
28
|
+
|
|
29
|
+
The CLI supports multiple authentication methods:
|
|
30
|
+
|
|
31
|
+
### Interactive Login (Recommended)
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
mags login
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Opens the Magpie dashboard in your browser where you can create an API token. The token is saved to `~/.mags/config.json` and used automatically for all future commands.
|
|
38
|
+
|
|
39
|
+
### Other Auth Commands
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
mags whoami # Check current authentication status
|
|
43
|
+
mags logout # Remove saved credentials
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Environment Variable
|
|
47
|
+
|
|
48
|
+
You can also set the token via environment variable (overrides saved config):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
export MAGS_API_TOKEN="your-token-here"
|
|
52
|
+
```
|
|
53
|
+
|
|
25
54
|
## CLI Commands
|
|
26
55
|
|
|
27
56
|
```bash
|
package/bin/mags.js
CHANGED
|
@@ -3,10 +3,44 @@
|
|
|
3
3
|
const https = require('https');
|
|
4
4
|
const http = require('http');
|
|
5
5
|
const { URL } = require('url');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const readline = require('readline');
|
|
9
|
+
const { exec, spawn } = require('child_process');
|
|
10
|
+
|
|
11
|
+
// Config file path
|
|
12
|
+
const CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE, '.mags');
|
|
13
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
14
|
+
|
|
15
|
+
// Load saved config
|
|
16
|
+
function loadConfig() {
|
|
17
|
+
try {
|
|
18
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
19
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
20
|
+
}
|
|
21
|
+
} catch (err) {
|
|
22
|
+
// Ignore errors
|
|
23
|
+
}
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Save config
|
|
28
|
+
function saveConfig(config) {
|
|
29
|
+
try {
|
|
30
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
31
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
32
|
+
}
|
|
33
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
34
|
+
return true;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
6
39
|
|
|
7
40
|
// Configuration
|
|
8
|
-
const
|
|
9
|
-
const
|
|
41
|
+
const config = loadConfig();
|
|
42
|
+
const API_URL = process.env.MAGS_API_URL || config.api_url || 'https://api.magpiecloud.com';
|
|
43
|
+
let API_TOKEN = process.env.MAGS_API_TOKEN || config.api_token || '';
|
|
10
44
|
|
|
11
45
|
// Colors
|
|
12
46
|
const colors = {
|
|
@@ -16,6 +50,7 @@ const colors = {
|
|
|
16
50
|
blue: '\x1b[34m',
|
|
17
51
|
cyan: '\x1b[36m',
|
|
18
52
|
gray: '\x1b[90m',
|
|
53
|
+
bold: '\x1b[1m',
|
|
19
54
|
reset: '\x1b[0m'
|
|
20
55
|
};
|
|
21
56
|
|
|
@@ -23,6 +58,42 @@ function log(color, msg) {
|
|
|
23
58
|
console.log(`${colors[color]}${msg}${colors.reset}`);
|
|
24
59
|
}
|
|
25
60
|
|
|
61
|
+
// Prompt for input
|
|
62
|
+
function prompt(question) {
|
|
63
|
+
const rl = readline.createInterface({
|
|
64
|
+
input: process.stdin,
|
|
65
|
+
output: process.stdout
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
rl.question(question, (answer) => {
|
|
70
|
+
rl.close();
|
|
71
|
+
resolve(answer.trim());
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Open URL in browser
|
|
77
|
+
function openBrowser(url) {
|
|
78
|
+
const platform = process.platform;
|
|
79
|
+
let cmd;
|
|
80
|
+
|
|
81
|
+
if (platform === 'darwin') {
|
|
82
|
+
cmd = `open "${url}"`;
|
|
83
|
+
} else if (platform === 'win32') {
|
|
84
|
+
cmd = `start "${url}"`;
|
|
85
|
+
} else {
|
|
86
|
+
cmd = `xdg-open "${url}"`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
exec(cmd, (err) => {
|
|
90
|
+
if (err) {
|
|
91
|
+
log('yellow', `Could not open browser automatically.`);
|
|
92
|
+
log('cyan', `Please open this URL manually: ${url}`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
26
97
|
function request(method, path, body = null) {
|
|
27
98
|
return new Promise((resolve, reject) => {
|
|
28
99
|
const url = new URL(path, API_URL);
|
|
@@ -64,34 +135,36 @@ function sleep(ms) {
|
|
|
64
135
|
|
|
65
136
|
function usage() {
|
|
66
137
|
console.log(`
|
|
67
|
-
${colors.cyan}Mags CLI - Instant VM Execution${colors.reset}
|
|
138
|
+
${colors.cyan}${colors.bold}Mags CLI - Instant VM Execution${colors.reset}
|
|
68
139
|
|
|
69
140
|
Usage: mags <command> [options] [script]
|
|
70
141
|
|
|
71
|
-
Commands
|
|
142
|
+
${colors.bold}Commands:${colors.reset}
|
|
143
|
+
login Authenticate with Magpie
|
|
144
|
+
logout Remove saved credentials
|
|
145
|
+
whoami Show current authenticated user
|
|
72
146
|
run [options] <script> Execute a script on a microVM
|
|
147
|
+
ssh <job-id> Open SSH session to a running VM
|
|
73
148
|
status <job-id> Get job status
|
|
74
149
|
logs <job-id> Get job logs
|
|
75
150
|
list List recent jobs
|
|
76
151
|
url <job-id> [port] Enable URL access for a job
|
|
77
152
|
stop <job-id> Stop a running job
|
|
78
153
|
|
|
79
|
-
Run Options
|
|
154
|
+
${colors.bold}Run Options:${colors.reset}
|
|
80
155
|
-w, --workspace <id> Use persistent workspace (S3 sync)
|
|
81
156
|
-p, --persistent Keep VM alive after script completes
|
|
82
157
|
--url Enable public URL access (requires -p)
|
|
83
158
|
--port <port> Port to expose for URL (default: 8080)
|
|
84
159
|
--startup-command <cmd> Command to run when VM wakes from sleep
|
|
85
160
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
MAGS_API_URL API endpoint (default: https://api.magpiecloud.com)
|
|
89
|
-
|
|
90
|
-
Examples:
|
|
161
|
+
${colors.bold}Examples:${colors.reset}
|
|
162
|
+
mags login
|
|
91
163
|
mags run 'echo Hello World'
|
|
92
164
|
mags run -w myproject 'python3 script.py'
|
|
93
165
|
mags run -p --url 'python3 -m http.server 8080'
|
|
94
166
|
mags run -w webapp -p --url --port 3000 'npm start'
|
|
167
|
+
mags ssh abc123 # SSH into a running VM
|
|
95
168
|
mags status abc123
|
|
96
169
|
mags logs abc123
|
|
97
170
|
mags url abc123 8080
|
|
@@ -99,6 +172,133 @@ Examples:
|
|
|
99
172
|
process.exit(1);
|
|
100
173
|
}
|
|
101
174
|
|
|
175
|
+
async function login() {
|
|
176
|
+
console.log(`
|
|
177
|
+
${colors.cyan}${colors.bold}Mags Login${colors.reset}
|
|
178
|
+
|
|
179
|
+
To authenticate, you need an API token from Magpie.
|
|
180
|
+
`);
|
|
181
|
+
|
|
182
|
+
log('blue', 'Opening Magpie dashboard to create an API token...');
|
|
183
|
+
console.log('');
|
|
184
|
+
|
|
185
|
+
const tokenUrl = 'https://magpiecloud.com/api-keys';
|
|
186
|
+
openBrowser(tokenUrl);
|
|
187
|
+
|
|
188
|
+
await sleep(1000);
|
|
189
|
+
|
|
190
|
+
console.log(`${colors.gray}If the browser didn't open, visit:${colors.reset}`);
|
|
191
|
+
console.log(`${colors.cyan}${tokenUrl}${colors.reset}`);
|
|
192
|
+
console.log('');
|
|
193
|
+
console.log(`${colors.gray}1. Click "Create Token"${colors.reset}`);
|
|
194
|
+
console.log(`${colors.gray}2. Give it a name (e.g., "mags-cli")${colors.reset}`);
|
|
195
|
+
console.log(`${colors.gray}3. Copy the token and paste it below${colors.reset}`);
|
|
196
|
+
console.log('');
|
|
197
|
+
|
|
198
|
+
const token = await prompt(`${colors.bold}Paste your API token: ${colors.reset}`);
|
|
199
|
+
|
|
200
|
+
if (!token) {
|
|
201
|
+
log('red', 'No token provided. Login cancelled.');
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Validate token by making a test request
|
|
206
|
+
API_TOKEN = token;
|
|
207
|
+
log('blue', 'Verifying token...');
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const resp = await request('GET', '/api/v1/mags-jobs?page=1&page_size=1');
|
|
211
|
+
|
|
212
|
+
if (resp.error === 'unauthorized' || resp.error === 'Unauthorized') {
|
|
213
|
+
log('red', 'Invalid token. Please check and try again.');
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Save token
|
|
218
|
+
const newConfig = { ...config, api_token: token };
|
|
219
|
+
if (saveConfig(newConfig)) {
|
|
220
|
+
console.log('');
|
|
221
|
+
log('green', 'Login successful!');
|
|
222
|
+
log('gray', `Token saved to ${CONFIG_FILE}`);
|
|
223
|
+
console.log('');
|
|
224
|
+
log('cyan', 'You can now run mags commands. Try:');
|
|
225
|
+
console.log(` ${colors.bold}mags run 'echo Hello World'${colors.reset}`);
|
|
226
|
+
} else {
|
|
227
|
+
log('yellow', 'Login successful, but could not save token to config file.');
|
|
228
|
+
log('yellow', 'You may need to login again next time.');
|
|
229
|
+
}
|
|
230
|
+
} catch (err) {
|
|
231
|
+
log('red', `Error validating token: ${err.message}`);
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function logout() {
|
|
237
|
+
if (!config.api_token) {
|
|
238
|
+
log('yellow', 'You are not logged in.');
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const newConfig = { ...config };
|
|
243
|
+
delete newConfig.api_token;
|
|
244
|
+
|
|
245
|
+
if (saveConfig(newConfig)) {
|
|
246
|
+
log('green', 'Logged out successfully.');
|
|
247
|
+
log('gray', 'Token removed from config.');
|
|
248
|
+
} else {
|
|
249
|
+
log('red', 'Could not remove token from config file.');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function whoami() {
|
|
254
|
+
if (!API_TOKEN) {
|
|
255
|
+
log('yellow', 'Not logged in.');
|
|
256
|
+
log('gray', 'Run `mags login` to authenticate.');
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
log('blue', 'Checking authentication...');
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
const resp = await request('GET', '/api/v1/mags-jobs?page=1&page_size=1');
|
|
264
|
+
|
|
265
|
+
if (resp.error === 'unauthorized' || resp.error === 'Unauthorized') {
|
|
266
|
+
log('red', 'Token is invalid or expired.');
|
|
267
|
+
log('gray', 'Run `mags login` to re-authenticate.');
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
log('green', 'Authenticated');
|
|
272
|
+
if (process.env.MAGS_API_TOKEN) {
|
|
273
|
+
log('gray', 'Using token from MAGS_API_TOKEN environment variable');
|
|
274
|
+
} else {
|
|
275
|
+
log('gray', `Using token from ${CONFIG_FILE}`);
|
|
276
|
+
}
|
|
277
|
+
} catch (err) {
|
|
278
|
+
log('red', `Error: ${err.message}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function requireAuth() {
|
|
283
|
+
if (API_TOKEN) return true;
|
|
284
|
+
|
|
285
|
+
console.log(`
|
|
286
|
+
${colors.yellow}You are not logged in.${colors.reset}
|
|
287
|
+
|
|
288
|
+
To use Mags, you need to authenticate first.
|
|
289
|
+
`);
|
|
290
|
+
|
|
291
|
+
const answer = await prompt(`${colors.bold}Would you like to login now? (Y/n): ${colors.reset}`);
|
|
292
|
+
|
|
293
|
+
if (answer.toLowerCase() === 'n' || answer.toLowerCase() === 'no') {
|
|
294
|
+
log('gray', 'You can login later with: mags login');
|
|
295
|
+
process.exit(0);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
await login();
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
|
|
102
302
|
async function runJob(args) {
|
|
103
303
|
let script = '';
|
|
104
304
|
let workspace = '';
|
|
@@ -294,53 +494,154 @@ async function stopJob(requestId) {
|
|
|
294
494
|
}
|
|
295
495
|
}
|
|
296
496
|
|
|
297
|
-
async function
|
|
298
|
-
|
|
497
|
+
async function sshToJob(requestId) {
|
|
498
|
+
if (!requestId) {
|
|
499
|
+
log('red', 'Error: Job ID required');
|
|
500
|
+
console.log(`\nUsage: mags ssh <job-id>\n`);
|
|
501
|
+
console.log('Get job IDs with: mags list');
|
|
502
|
+
process.exit(1);
|
|
503
|
+
}
|
|
299
504
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
505
|
+
// First check job status
|
|
506
|
+
log('blue', 'Checking job status...');
|
|
507
|
+
const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
|
|
508
|
+
|
|
509
|
+
if (status.error) {
|
|
510
|
+
log('red', `Error: ${status.error}`);
|
|
511
|
+
process.exit(1);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (status.status !== 'running' && status.status !== 'sleeping') {
|
|
515
|
+
log('red', `Cannot SSH to job with status: ${status.status}`);
|
|
516
|
+
log('gray', 'Job must be running or sleeping (persistent)');
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Enable SSH access (port 22)
|
|
521
|
+
log('blue', 'Enabling SSH access...');
|
|
522
|
+
const accessResp = await request('POST', `/api/v1/mags-jobs/${requestId}/access`, { port: 22 });
|
|
523
|
+
|
|
524
|
+
if (!accessResp.success) {
|
|
525
|
+
log('red', 'Failed to enable SSH access');
|
|
526
|
+
if (accessResp.error) {
|
|
527
|
+
log('red', accessResp.error);
|
|
528
|
+
}
|
|
529
|
+
process.exit(1);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const sshHost = accessResp.ssh_host;
|
|
533
|
+
const sshPort = accessResp.ssh_port;
|
|
534
|
+
|
|
535
|
+
if (!sshHost || !sshPort) {
|
|
536
|
+
log('red', 'SSH access enabled but no connection details returned');
|
|
537
|
+
console.log(JSON.stringify(accessResp, null, 2));
|
|
303
538
|
process.exit(1);
|
|
304
539
|
}
|
|
305
540
|
|
|
541
|
+
log('green', `Connecting to ${sshHost}:${sshPort}...`);
|
|
542
|
+
console.log(`${colors.gray}(Use Ctrl+D or 'exit' to disconnect)${colors.reset}\n`);
|
|
543
|
+
|
|
544
|
+
// Spawn SSH process
|
|
545
|
+
const sshArgs = [
|
|
546
|
+
'-o', 'StrictHostKeyChecking=no',
|
|
547
|
+
'-o', 'UserKnownHostsFile=/dev/null',
|
|
548
|
+
'-o', 'LogLevel=ERROR',
|
|
549
|
+
'-p', sshPort.toString(),
|
|
550
|
+
`root@${sshHost}`
|
|
551
|
+
];
|
|
552
|
+
|
|
553
|
+
const ssh = spawn('ssh', sshArgs, {
|
|
554
|
+
stdio: 'inherit' // Inherit stdin/stdout/stderr for interactive session
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
ssh.on('error', (err) => {
|
|
558
|
+
if (err.code === 'ENOENT') {
|
|
559
|
+
log('red', 'SSH client not found. Please install OpenSSH.');
|
|
560
|
+
log('gray', 'On macOS/Linux: ssh is usually pre-installed');
|
|
561
|
+
log('gray', 'On Windows: Install OpenSSH or use WSL');
|
|
562
|
+
} else {
|
|
563
|
+
log('red', `SSH error: ${err.message}`);
|
|
564
|
+
}
|
|
565
|
+
process.exit(1);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
ssh.on('close', (code) => {
|
|
569
|
+
if (code === 0) {
|
|
570
|
+
log('green', '\nSSH session ended');
|
|
571
|
+
} else {
|
|
572
|
+
log('yellow', `\nSSH session ended with code ${code}`);
|
|
573
|
+
}
|
|
574
|
+
process.exit(code || 0);
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function main() {
|
|
579
|
+
const args = process.argv.slice(2);
|
|
306
580
|
const command = args[0];
|
|
307
581
|
|
|
582
|
+
// Commands that don't require auth
|
|
583
|
+
const noAuthCommands = ['login', '--help', '-h', '--version', '-v'];
|
|
584
|
+
|
|
308
585
|
try {
|
|
309
586
|
switch (command) {
|
|
587
|
+
case 'login':
|
|
588
|
+
await login();
|
|
589
|
+
break;
|
|
590
|
+
case 'logout':
|
|
591
|
+
await logout();
|
|
592
|
+
break;
|
|
593
|
+
case 'whoami':
|
|
594
|
+
await whoami();
|
|
595
|
+
break;
|
|
596
|
+
case '--help':
|
|
597
|
+
case '-h':
|
|
598
|
+
usage();
|
|
599
|
+
break;
|
|
600
|
+
case '--version':
|
|
601
|
+
case '-v':
|
|
602
|
+
console.log('mags v1.2.0');
|
|
603
|
+
process.exit(0);
|
|
604
|
+
break;
|
|
310
605
|
case 'run':
|
|
606
|
+
await requireAuth();
|
|
311
607
|
await runJob(args.slice(1));
|
|
312
608
|
break;
|
|
609
|
+
case 'ssh':
|
|
610
|
+
await requireAuth();
|
|
611
|
+
await sshToJob(args[1]);
|
|
612
|
+
break;
|
|
313
613
|
case 'url':
|
|
614
|
+
await requireAuth();
|
|
314
615
|
await enableUrlAccess(args[1], parseInt(args[2]) || 8080);
|
|
315
616
|
break;
|
|
316
617
|
case 'status':
|
|
618
|
+
await requireAuth();
|
|
317
619
|
await getStatus(args[1]);
|
|
318
620
|
break;
|
|
319
621
|
case 'logs':
|
|
622
|
+
await requireAuth();
|
|
320
623
|
await getLogs(args[1]);
|
|
321
624
|
break;
|
|
322
625
|
case 'list':
|
|
626
|
+
await requireAuth();
|
|
323
627
|
await listJobs();
|
|
324
628
|
break;
|
|
325
629
|
case 'stop':
|
|
630
|
+
await requireAuth();
|
|
326
631
|
await stopJob(args[1]);
|
|
327
632
|
break;
|
|
328
|
-
case '--help':
|
|
329
|
-
case '-h':
|
|
330
|
-
case '--version':
|
|
331
|
-
case '-v':
|
|
332
|
-
if (command === '--version' || command === '-v') {
|
|
333
|
-
console.log('mags v1.0.0');
|
|
334
|
-
process.exit(0);
|
|
335
|
-
}
|
|
336
|
-
usage();
|
|
337
|
-
break;
|
|
338
633
|
default:
|
|
339
634
|
if (!command) {
|
|
635
|
+
// No command - check if logged in
|
|
636
|
+
if (!API_TOKEN) {
|
|
637
|
+
await requireAuth();
|
|
638
|
+
} else {
|
|
639
|
+
usage();
|
|
640
|
+
}
|
|
641
|
+
} else {
|
|
642
|
+
log('red', `Unknown command: ${command}`);
|
|
340
643
|
usage();
|
|
341
644
|
}
|
|
342
|
-
log('red', `Unknown command: ${command}`);
|
|
343
|
-
usage();
|
|
344
645
|
}
|
|
345
646
|
} catch (err) {
|
|
346
647
|
log('red', `Error: ${err.message}`);
|