@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.
- package/bin/mags.js +234 -27
- 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
|
-
|
|
148
|
-
|
|
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 <
|
|
151
|
-
stop <
|
|
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
|
|
167
|
-
mags logs
|
|
168
|
-
mags url
|
|
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
|
|
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(
|
|
412
|
-
if (!
|
|
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(
|
|
436
|
-
if (!
|
|
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(
|
|
445
|
-
if (!
|
|
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(
|
|
481
|
-
if (!
|
|
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.
|
|
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);
|