@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.
- package/bin/mags.js +156 -36
- 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 <
|
|
148
|
-
status <
|
|
149
|
-
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 <
|
|
152
|
-
stop <
|
|
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
|
|
168
|
-
mags
|
|
169
|
-
mags
|
|
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
|
|
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(
|
|
414
|
-
if (!
|
|
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(
|
|
438
|
-
if (!
|
|
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(
|
|
447
|
-
if (!
|
|
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(
|
|
483
|
-
if (!
|
|
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(
|
|
498
|
-
if (!
|
|
499
|
-
log('red', 'Error: Job ID required');
|
|
500
|
-
console.log(`\nUsage: mags ssh <
|
|
501
|
-
console.log('
|
|
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
|
-
//
|
|
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.
|
|
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));
|