@magpiecloud/mags 1.1.0 → 1.3.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 (2) hide show
  1. package/bin/mags.js +234 -27
  2. package/package.json +1 -1
package/bin/mags.js CHANGED
@@ -5,12 +5,14 @@ const http = require('http');
5
5
  const { URL } = require('url');
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
+ const os = require('os');
8
9
  const readline = require('readline');
9
- const { exec } = require('child_process');
10
+ const { exec, spawn } = require('child_process');
10
11
 
11
12
  // Config file path
12
13
  const CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE, '.mags');
13
14
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
15
+ const SSH_KEY_FILE = path.join(CONFIG_DIR, 'ssh_key');
14
16
 
15
17
  // Load saved config
16
18
  function loadConfig() {
@@ -133,6 +135,35 @@ function sleep(ms) {
133
135
  return new Promise(resolve => setTimeout(resolve, ms));
134
136
  }
135
137
 
138
+ // Resolve job ID from name or ID
139
+ async function resolveJobId(nameOrId) {
140
+ // If it looks like a UUID, use it directly
141
+ if (nameOrId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
142
+ return nameOrId;
143
+ }
144
+
145
+ // Otherwise, search by name/workspace
146
+ const resp = await request('GET', `/api/v1/mags-jobs?page=1&page_size=50`);
147
+ if (resp.jobs && resp.jobs.length > 0) {
148
+ // Try exact match on name first
149
+ let job = resp.jobs.find(j => j.name === nameOrId);
150
+ if (job) return job.request_id;
151
+
152
+ // Try workspace_id match
153
+ job = resp.jobs.find(j => j.workspace_id === nameOrId);
154
+ if (job) return job.request_id;
155
+
156
+ // Try partial name match (running/sleeping jobs only)
157
+ job = resp.jobs.find(j =>
158
+ (j.status === 'running' || j.status === 'sleeping') &&
159
+ (j.name && j.name.includes(nameOrId))
160
+ );
161
+ if (job) return job.request_id;
162
+ }
163
+
164
+ return nameOrId; // Return as-is, let API handle error
165
+ }
166
+
136
167
  function usage() {
137
168
  console.log(`
138
169
  ${colors.cyan}${colors.bold}Mags CLI - Instant VM Execution${colors.reset}
@@ -143,15 +174,18 @@ ${colors.bold}Commands:${colors.reset}
143
174
  login Authenticate with Magpie
144
175
  logout Remove saved credentials
145
176
  whoami Show current authenticated user
177
+ new <name> Create a new persistent VM (returns ID only)
146
178
  run [options] <script> Execute a script on a microVM
147
- status <job-id> Get job status
148
- logs <job-id> Get job logs
179
+ ssh <name|id> Open SSH session to a running VM
180
+ status <name|id> Get job status
181
+ logs <name|id> Get job logs
149
182
  list List recent jobs
150
- url <job-id> [port] Enable URL access for a job
151
- stop <job-id> Stop a running job
183
+ url <name|id> [port] Enable URL access for a job
184
+ stop <name|id> Stop a running job
152
185
 
153
186
  ${colors.bold}Run Options:${colors.reset}
154
187
  -w, --workspace <id> Use persistent workspace (S3 sync)
188
+ -n, --name <name> Set job name (for easier reference)
155
189
  -p, --persistent Keep VM alive after script completes
156
190
  --url Enable public URL access (requires -p)
157
191
  --port <port> Port to expose for URL (default: 8080)
@@ -159,13 +193,15 @@ ${colors.bold}Run Options:${colors.reset}
159
193
 
160
194
  ${colors.bold}Examples:${colors.reset}
161
195
  mags login
196
+ mags new myvm # Create VM, get ID
197
+ mags ssh myvm # SSH by name
162
198
  mags run 'echo Hello World'
163
199
  mags run -w myproject 'python3 script.py'
164
200
  mags run -p --url 'python3 -m http.server 8080'
165
- mags run -w webapp -p --url --port 3000 'npm start'
166
- mags status abc123
167
- mags logs abc123
168
- mags url abc123 8080
201
+ mags run -n webapp -w webapp -p --url --port 3000 'npm start'
202
+ mags status myvm
203
+ mags logs myvm
204
+ mags url myvm 8080
169
205
  `);
170
206
  process.exit(1);
171
207
  }
@@ -180,7 +216,7 @@ To authenticate, you need an API token from Magpie.
180
216
  log('blue', 'Opening Magpie dashboard to create an API token...');
181
217
  console.log('');
182
218
 
183
- const tokenUrl = 'https://magpiecloud.com/api-keys';
219
+ const tokenUrl = 'https://api.magpiecloud.com/api-keys';
184
220
  openBrowser(tokenUrl);
185
221
 
186
222
  await sleep(1000);
@@ -220,7 +256,8 @@ To authenticate, you need an API token from Magpie.
220
256
  log('gray', `Token saved to ${CONFIG_FILE}`);
221
257
  console.log('');
222
258
  log('cyan', 'You can now run mags commands. Try:');
223
- console.log(` ${colors.bold}mags run 'echo Hello World'${colors.reset}`);
259
+ console.log(` ${colors.bold}mags new myvm${colors.reset}`);
260
+ console.log(` ${colors.bold}mags ssh myvm${colors.reset}`);
224
261
  } else {
225
262
  log('yellow', 'Login successful, but could not save token to config file.');
226
263
  log('yellow', 'You may need to login again next time.');
@@ -297,9 +334,59 @@ To use Mags, you need to authenticate first.
297
334
  return true;
298
335
  }
299
336
 
337
+ // Create a new persistent VM
338
+ async function newVM(name) {
339
+ if (!name) {
340
+ log('red', 'Error: Name required');
341
+ console.log(`\nUsage: mags new <name>\n`);
342
+ process.exit(1);
343
+ }
344
+
345
+ const payload = {
346
+ script: 'sleep infinity',
347
+ type: 'inline',
348
+ persistent: true,
349
+ name: name,
350
+ workspace_id: name,
351
+ startup_command: 'sleep infinity'
352
+ };
353
+
354
+ const response = await request('POST', '/api/v1/mags-jobs', payload);
355
+
356
+ if (!response.request_id) {
357
+ log('red', 'Failed to create VM:');
358
+ console.log(JSON.stringify(response, null, 2));
359
+ process.exit(1);
360
+ }
361
+
362
+ // Wait for VM to be ready
363
+ const maxAttempts = 60;
364
+ let attempt = 0;
365
+
366
+ while (attempt < maxAttempts) {
367
+ const status = await request('GET', `/api/v1/mags-jobs/${response.request_id}/status`);
368
+
369
+ if (status.status === 'running') {
370
+ // Just output the ID
371
+ console.log(response.request_id);
372
+ return;
373
+ } else if (status.status === 'error') {
374
+ log('red', `VM creation failed: ${status.error_message || 'Unknown error'}`);
375
+ process.exit(1);
376
+ }
377
+
378
+ await sleep(500);
379
+ attempt++;
380
+ }
381
+
382
+ log('yellow', 'VM creation timed out, but may still be starting');
383
+ console.log(response.request_id);
384
+ }
385
+
300
386
  async function runJob(args) {
301
387
  let script = '';
302
388
  let workspace = '';
389
+ let name = '';
303
390
  let persistent = false;
304
391
  let enableUrl = false;
305
392
  let port = 8080;
@@ -312,6 +399,10 @@ async function runJob(args) {
312
399
  case '--workspace':
313
400
  workspace = args[++i];
314
401
  break;
402
+ case '-n':
403
+ case '--name':
404
+ name = args[++i];
405
+ break;
315
406
  case '-p':
316
407
  case '--persistent':
317
408
  persistent = true;
@@ -344,6 +435,7 @@ async function runJob(args) {
344
435
  persistent
345
436
  };
346
437
  if (workspace) payload.workspace_id = workspace;
438
+ if (name) payload.name = name;
347
439
  if (startupCommand) payload.startup_command = startupCommand;
348
440
 
349
441
  const response = await request('POST', '/api/v1/mags-jobs', payload);
@@ -356,6 +448,7 @@ async function runJob(args) {
356
448
 
357
449
  const requestId = response.request_id;
358
450
  log('green', `Job submitted: ${requestId}`);
451
+ if (name) log('blue', `Name: ${name}`);
359
452
  if (workspace) log('blue', `Workspace: ${workspace}`);
360
453
  if (persistent) log('yellow', 'Persistent: VM will stay alive');
361
454
 
@@ -380,9 +473,6 @@ async function runJob(args) {
380
473
  } else {
381
474
  log('yellow', 'Warning: Could not enable URL access');
382
475
  }
383
- } else if (status.subdomain) {
384
- log('cyan', `Subdomain: ${status.subdomain}`);
385
- log('cyan', `To enable URL: mags url ${requestId} ${port}`);
386
476
  }
387
477
  return;
388
478
  } else if (status.status === 'error') {
@@ -408,12 +498,13 @@ async function runJob(args) {
408
498
  }
409
499
  }
410
500
 
411
- async function enableUrlAccess(requestId, port = 8080) {
412
- if (!requestId) {
413
- log('red', 'Error: Job ID required');
501
+ async function enableUrlAccess(nameOrId, port = 8080) {
502
+ if (!nameOrId) {
503
+ log('red', 'Error: Job name or ID required');
414
504
  usage();
415
505
  }
416
506
 
507
+ const requestId = await resolveJobId(nameOrId);
417
508
  log('blue', `Enabling URL access on port ${port}...`);
418
509
 
419
510
  const accessResp = await request('POST', `/api/v1/mags-jobs/${requestId}/access`, { port });
@@ -432,20 +523,22 @@ async function enableUrlAccess(requestId, port = 8080) {
432
523
  }
433
524
  }
434
525
 
435
- async function getStatus(requestId) {
436
- if (!requestId) {
437
- log('red', 'Error: Job ID required');
526
+ async function getStatus(nameOrId) {
527
+ if (!nameOrId) {
528
+ log('red', 'Error: Job name or ID required');
438
529
  usage();
439
530
  }
531
+ const requestId = await resolveJobId(nameOrId);
440
532
  const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
441
533
  console.log(JSON.stringify(status, null, 2));
442
534
  }
443
535
 
444
- async function getLogs(requestId) {
445
- if (!requestId) {
446
- log('red', 'Error: Job ID required');
536
+ async function getLogs(nameOrId) {
537
+ if (!nameOrId) {
538
+ log('red', 'Error: Job name or ID required');
447
539
  usage();
448
540
  }
541
+ const requestId = await resolveJobId(nameOrId);
449
542
  const logsResp = await request('GET', `/api/v1/mags-jobs/${requestId}/logs`);
450
543
  if (logsResp.logs) {
451
544
  logsResp.logs.forEach(l => {
@@ -477,11 +570,12 @@ async function listJobs() {
477
570
  }
478
571
  }
479
572
 
480
- async function stopJob(requestId) {
481
- if (!requestId) {
482
- log('red', 'Error: Job ID required');
573
+ async function stopJob(nameOrId) {
574
+ if (!nameOrId) {
575
+ log('red', 'Error: Job name or ID required');
483
576
  usage();
484
577
  }
578
+ const requestId = await resolveJobId(nameOrId);
485
579
  log('blue', `Stopping job ${requestId}...`);
486
580
  const resp = await request('POST', `/api/v1/mags-jobs/${requestId}/stop`);
487
581
  if (resp.success) {
@@ -492,6 +586,111 @@ async function stopJob(requestId) {
492
586
  }
493
587
  }
494
588
 
589
+ async function sshToJob(nameOrId) {
590
+ if (!nameOrId) {
591
+ log('red', 'Error: Job name or ID required');
592
+ console.log(`\nUsage: mags ssh <name|id>\n`);
593
+ console.log('Examples:');
594
+ console.log(' mags ssh myvm');
595
+ console.log(' mags ssh 7bd12031-25ff-497f-b753-7ae73ce10317');
596
+ console.log('');
597
+ console.log('Get job names/IDs with: mags list');
598
+ process.exit(1);
599
+ }
600
+
601
+ // Resolve name to ID
602
+ const requestId = await resolveJobId(nameOrId);
603
+
604
+ // First check job status
605
+ log('blue', 'Checking job status...');
606
+ const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
607
+
608
+ if (status.error) {
609
+ log('red', `Error: ${status.error}`);
610
+ process.exit(1);
611
+ }
612
+
613
+ if (status.status !== 'running' && status.status !== 'sleeping') {
614
+ log('red', `Cannot SSH to job with status: ${status.status}`);
615
+ log('gray', 'Job must be running or sleeping (persistent)');
616
+ process.exit(1);
617
+ }
618
+
619
+ // Enable SSH access (port 22)
620
+ log('blue', 'Enabling SSH access...');
621
+ const accessResp = await request('POST', `/api/v1/mags-jobs/${requestId}/access`, { port: 22 });
622
+
623
+ if (!accessResp.success) {
624
+ log('red', 'Failed to enable SSH access');
625
+ if (accessResp.error) {
626
+ log('red', accessResp.error);
627
+ }
628
+ process.exit(1);
629
+ }
630
+
631
+ const sshHost = accessResp.ssh_host;
632
+ const sshPort = accessResp.ssh_port;
633
+ const sshKey = accessResp.ssh_private_key;
634
+
635
+ if (!sshHost || !sshPort) {
636
+ log('red', 'SSH access enabled but no connection details returned');
637
+ console.log(JSON.stringify(accessResp, null, 2));
638
+ process.exit(1);
639
+ }
640
+
641
+ log('green', `Connecting to ${sshHost}:${sshPort}...`);
642
+ console.log(`${colors.gray}(Use Ctrl+D or 'exit' to disconnect)${colors.reset}\n`);
643
+
644
+ // Build SSH arguments
645
+ const sshArgs = [
646
+ '-o', 'StrictHostKeyChecking=no',
647
+ '-o', 'UserKnownHostsFile=/dev/null',
648
+ '-o', 'LogLevel=ERROR',
649
+ '-p', sshPort.toString()
650
+ ];
651
+
652
+ // If API returned an SSH key, write it to a temp file and use it
653
+ let keyFile = null;
654
+ if (sshKey) {
655
+ keyFile = path.join(os.tmpdir(), `mags_ssh_${Date.now()}`);
656
+ fs.writeFileSync(keyFile, sshKey, { mode: 0o600 });
657
+ sshArgs.push('-i', keyFile);
658
+ }
659
+
660
+ sshArgs.push(`root@${sshHost}`);
661
+
662
+ const ssh = spawn('ssh', sshArgs, {
663
+ stdio: 'inherit' // Inherit stdin/stdout/stderr for interactive session
664
+ });
665
+
666
+ ssh.on('error', (err) => {
667
+ if (keyFile) {
668
+ try { fs.unlinkSync(keyFile); } catch (e) {}
669
+ }
670
+ if (err.code === 'ENOENT') {
671
+ log('red', 'SSH client not found. Please install OpenSSH.');
672
+ log('gray', 'On macOS/Linux: ssh is usually pre-installed');
673
+ log('gray', 'On Windows: Install OpenSSH or use WSL');
674
+ } else {
675
+ log('red', `SSH error: ${err.message}`);
676
+ }
677
+ process.exit(1);
678
+ });
679
+
680
+ ssh.on('close', (code) => {
681
+ // Clean up temp key file
682
+ if (keyFile) {
683
+ try { fs.unlinkSync(keyFile); } catch (e) {}
684
+ }
685
+ if (code === 0) {
686
+ log('green', '\nSSH session ended');
687
+ } else {
688
+ log('yellow', `\nSSH session ended with code ${code}`);
689
+ }
690
+ process.exit(code || 0);
691
+ });
692
+ }
693
+
495
694
  async function main() {
496
695
  const args = process.argv.slice(2);
497
696
  const command = args[0];
@@ -516,13 +715,21 @@ async function main() {
516
715
  break;
517
716
  case '--version':
518
717
  case '-v':
519
- console.log('mags v1.0.0');
718
+ console.log('mags v1.3.0');
520
719
  process.exit(0);
521
720
  break;
721
+ case 'new':
722
+ await requireAuth();
723
+ await newVM(args[1]);
724
+ break;
522
725
  case 'run':
523
726
  await requireAuth();
524
727
  await runJob(args.slice(1));
525
728
  break;
729
+ case 'ssh':
730
+ await requireAuth();
731
+ await sshToJob(args[1]);
732
+ break;
526
733
  case 'url':
527
734
  await requireAuth();
528
735
  await enableUrlAccess(args[1], parseInt(args[2]) || 8080);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpiecloud/mags",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Mags CLI - Execute scripts on Magpie's instant VM infrastructure",
5
5
  "main": "index.js",
6
6
  "bin": {