@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.
Files changed (3) hide show
  1. package/README.md +31 -2
  2. package/bin/mags.js +328 -27
  3. 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. Set your API token
13
+ ### 1. Login to Magpie
14
14
 
15
15
  ```bash
16
- export MAGS_API_TOKEN="your-token-here"
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 API_URL = process.env.MAGS_API_URL || 'https://api.magpiecloud.com';
9
- const API_TOKEN = process.env.MAGS_API_TOKEN || '';
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
- Environment:
87
- MAGS_API_TOKEN Your API token (required)
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 main() {
298
- const args = process.argv.slice(2);
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
- if (!API_TOKEN) {
301
- log('red', 'Error: MAGS_API_TOKEN not set');
302
- console.log('Set it via: export MAGS_API_TOKEN=your-token');
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}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpiecloud/mags",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Mags CLI - Execute scripts on Magpie's instant VM infrastructure",
5
5
  "main": "index.js",
6
6
  "bin": {