@magpiecloud/mags 1.2.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 +156 -36
  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
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,16 +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
- ssh <job-id> Open SSH session to a running VM
148
- status <job-id> Get job status
149
- 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
150
182
  list List recent jobs
151
- url <job-id> [port] Enable URL access for a job
152
- 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
153
185
 
154
186
  ${colors.bold}Run Options:${colors.reset}
155
187
  -w, --workspace <id> Use persistent workspace (S3 sync)
188
+ -n, --name <name> Set job name (for easier reference)
156
189
  -p, --persistent Keep VM alive after script completes
157
190
  --url Enable public URL access (requires -p)
158
191
  --port <port> Port to expose for URL (default: 8080)
@@ -160,14 +193,15 @@ ${colors.bold}Run Options:${colors.reset}
160
193
 
161
194
  ${colors.bold}Examples:${colors.reset}
162
195
  mags login
196
+ mags new myvm # Create VM, get ID
197
+ mags ssh myvm # SSH by name
163
198
  mags run 'echo Hello World'
164
199
  mags run -w myproject 'python3 script.py'
165
200
  mags run -p --url 'python3 -m http.server 8080'
166
- mags run -w webapp -p --url --port 3000 'npm start'
167
- mags ssh abc123 # SSH into a running VM
168
- mags status abc123
169
- mags logs abc123
170
- 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
171
205
  `);
172
206
  process.exit(1);
173
207
  }
@@ -182,7 +216,7 @@ To authenticate, you need an API token from Magpie.
182
216
  log('blue', 'Opening Magpie dashboard to create an API token...');
183
217
  console.log('');
184
218
 
185
- const tokenUrl = 'https://magpiecloud.com/api-keys';
219
+ const tokenUrl = 'https://api.magpiecloud.com/api-keys';
186
220
  openBrowser(tokenUrl);
187
221
 
188
222
  await sleep(1000);
@@ -222,7 +256,8 @@ To authenticate, you need an API token from Magpie.
222
256
  log('gray', `Token saved to ${CONFIG_FILE}`);
223
257
  console.log('');
224
258
  log('cyan', 'You can now run mags commands. Try:');
225
- 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}`);
226
261
  } else {
227
262
  log('yellow', 'Login successful, but could not save token to config file.');
228
263
  log('yellow', 'You may need to login again next time.');
@@ -299,9 +334,59 @@ To use Mags, you need to authenticate first.
299
334
  return true;
300
335
  }
301
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
+
302
386
  async function runJob(args) {
303
387
  let script = '';
304
388
  let workspace = '';
389
+ let name = '';
305
390
  let persistent = false;
306
391
  let enableUrl = false;
307
392
  let port = 8080;
@@ -314,6 +399,10 @@ async function runJob(args) {
314
399
  case '--workspace':
315
400
  workspace = args[++i];
316
401
  break;
402
+ case '-n':
403
+ case '--name':
404
+ name = args[++i];
405
+ break;
317
406
  case '-p':
318
407
  case '--persistent':
319
408
  persistent = true;
@@ -346,6 +435,7 @@ async function runJob(args) {
346
435
  persistent
347
436
  };
348
437
  if (workspace) payload.workspace_id = workspace;
438
+ if (name) payload.name = name;
349
439
  if (startupCommand) payload.startup_command = startupCommand;
350
440
 
351
441
  const response = await request('POST', '/api/v1/mags-jobs', payload);
@@ -358,6 +448,7 @@ async function runJob(args) {
358
448
 
359
449
  const requestId = response.request_id;
360
450
  log('green', `Job submitted: ${requestId}`);
451
+ if (name) log('blue', `Name: ${name}`);
361
452
  if (workspace) log('blue', `Workspace: ${workspace}`);
362
453
  if (persistent) log('yellow', 'Persistent: VM will stay alive');
363
454
 
@@ -382,9 +473,6 @@ async function runJob(args) {
382
473
  } else {
383
474
  log('yellow', 'Warning: Could not enable URL access');
384
475
  }
385
- } else if (status.subdomain) {
386
- log('cyan', `Subdomain: ${status.subdomain}`);
387
- log('cyan', `To enable URL: mags url ${requestId} ${port}`);
388
476
  }
389
477
  return;
390
478
  } else if (status.status === 'error') {
@@ -410,12 +498,13 @@ async function runJob(args) {
410
498
  }
411
499
  }
412
500
 
413
- async function enableUrlAccess(requestId, port = 8080) {
414
- if (!requestId) {
415
- 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');
416
504
  usage();
417
505
  }
418
506
 
507
+ const requestId = await resolveJobId(nameOrId);
419
508
  log('blue', `Enabling URL access on port ${port}...`);
420
509
 
421
510
  const accessResp = await request('POST', `/api/v1/mags-jobs/${requestId}/access`, { port });
@@ -434,20 +523,22 @@ async function enableUrlAccess(requestId, port = 8080) {
434
523
  }
435
524
  }
436
525
 
437
- async function getStatus(requestId) {
438
- if (!requestId) {
439
- log('red', 'Error: Job ID required');
526
+ async function getStatus(nameOrId) {
527
+ if (!nameOrId) {
528
+ log('red', 'Error: Job name or ID required');
440
529
  usage();
441
530
  }
531
+ const requestId = await resolveJobId(nameOrId);
442
532
  const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
443
533
  console.log(JSON.stringify(status, null, 2));
444
534
  }
445
535
 
446
- async function getLogs(requestId) {
447
- if (!requestId) {
448
- log('red', 'Error: Job ID required');
536
+ async function getLogs(nameOrId) {
537
+ if (!nameOrId) {
538
+ log('red', 'Error: Job name or ID required');
449
539
  usage();
450
540
  }
541
+ const requestId = await resolveJobId(nameOrId);
451
542
  const logsResp = await request('GET', `/api/v1/mags-jobs/${requestId}/logs`);
452
543
  if (logsResp.logs) {
453
544
  logsResp.logs.forEach(l => {
@@ -479,11 +570,12 @@ async function listJobs() {
479
570
  }
480
571
  }
481
572
 
482
- async function stopJob(requestId) {
483
- if (!requestId) {
484
- log('red', 'Error: Job ID required');
573
+ async function stopJob(nameOrId) {
574
+ if (!nameOrId) {
575
+ log('red', 'Error: Job name or ID required');
485
576
  usage();
486
577
  }
578
+ const requestId = await resolveJobId(nameOrId);
487
579
  log('blue', `Stopping job ${requestId}...`);
488
580
  const resp = await request('POST', `/api/v1/mags-jobs/${requestId}/stop`);
489
581
  if (resp.success) {
@@ -494,14 +586,21 @@ async function stopJob(requestId) {
494
586
  }
495
587
  }
496
588
 
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');
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');
502
598
  process.exit(1);
503
599
  }
504
600
 
601
+ // Resolve name to ID
602
+ const requestId = await resolveJobId(nameOrId);
603
+
505
604
  // First check job status
506
605
  log('blue', 'Checking job status...');
507
606
  const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
@@ -531,6 +630,7 @@ async function sshToJob(requestId) {
531
630
 
532
631
  const sshHost = accessResp.ssh_host;
533
632
  const sshPort = accessResp.ssh_port;
633
+ const sshKey = accessResp.ssh_private_key;
534
634
 
535
635
  if (!sshHost || !sshPort) {
536
636
  log('red', 'SSH access enabled but no connection details returned');
@@ -541,20 +641,32 @@ async function sshToJob(requestId) {
541
641
  log('green', `Connecting to ${sshHost}:${sshPort}...`);
542
642
  console.log(`${colors.gray}(Use Ctrl+D or 'exit' to disconnect)${colors.reset}\n`);
543
643
 
544
- // Spawn SSH process
644
+ // Build SSH arguments
545
645
  const sshArgs = [
546
646
  '-o', 'StrictHostKeyChecking=no',
547
647
  '-o', 'UserKnownHostsFile=/dev/null',
548
648
  '-o', 'LogLevel=ERROR',
549
- '-p', sshPort.toString(),
550
- `root@${sshHost}`
649
+ '-p', sshPort.toString()
551
650
  ];
552
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
+
553
662
  const ssh = spawn('ssh', sshArgs, {
554
663
  stdio: 'inherit' // Inherit stdin/stdout/stderr for interactive session
555
664
  });
556
665
 
557
666
  ssh.on('error', (err) => {
667
+ if (keyFile) {
668
+ try { fs.unlinkSync(keyFile); } catch (e) {}
669
+ }
558
670
  if (err.code === 'ENOENT') {
559
671
  log('red', 'SSH client not found. Please install OpenSSH.');
560
672
  log('gray', 'On macOS/Linux: ssh is usually pre-installed');
@@ -566,6 +678,10 @@ async function sshToJob(requestId) {
566
678
  });
567
679
 
568
680
  ssh.on('close', (code) => {
681
+ // Clean up temp key file
682
+ if (keyFile) {
683
+ try { fs.unlinkSync(keyFile); } catch (e) {}
684
+ }
569
685
  if (code === 0) {
570
686
  log('green', '\nSSH session ended');
571
687
  } else {
@@ -599,9 +715,13 @@ async function main() {
599
715
  break;
600
716
  case '--version':
601
717
  case '-v':
602
- console.log('mags v1.2.0');
718
+ console.log('mags v1.3.0');
603
719
  process.exit(0);
604
720
  break;
721
+ case 'new':
722
+ await requireAuth();
723
+ await newVM(args[1]);
724
+ break;
605
725
  case 'run':
606
726
  await requireAuth();
607
727
  await runJob(args.slice(1));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpiecloud/mags",
3
- "version": "1.2.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": {