@magpiecloud/mags 1.8.12 → 1.8.14

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/README.md CHANGED
@@ -6,7 +6,7 @@ Execute scripts instantly on Magpie's microVM infrastructure. VMs boot in <100ms
6
6
 
7
7
  Mags is a CLI and SDK for running scripts on ephemeral microVMs. Each execution gets its own isolated VM that:
8
8
 
9
- - Boots in <100ms (from warm pool)
9
+ - Boots in ~300ms (from warm pool)
10
10
  - Supports optional S3-backed persistent workspaces (with `-p` flag)
11
11
  - Syncs /root directory automatically when persistence is enabled
12
12
  - Can expose public URLs for web services
@@ -419,7 +419,7 @@ mags run 'apk add ffmpeg imagemagick'
419
419
 
420
420
  | Metric | Value |
421
421
  |--------|-------|
422
- | Warm start | <100ms |
422
+ | Warm start | ~300ms |
423
423
  | Cold start | ~4 seconds |
424
424
  | Script overhead | ~50ms |
425
425
  | Workspace sync | Every 30 seconds |
package/bin/mags.js CHANGED
@@ -429,6 +429,23 @@ async function newVM(args) {
429
429
  process.exit(1);
430
430
  }
431
431
 
432
+ // Check if a VM with this name already exists (running or sleeping)
433
+ try {
434
+ const jobs = await request('GET', '/api/v1/mags-jobs');
435
+ const existing = (jobs.jobs || []).find(j =>
436
+ j.name === name && (j.status === 'running' || j.status === 'sleeping')
437
+ );
438
+ if (existing) {
439
+ log('yellow', `Sandbox '${name}' already exists (status: ${existing.status})`);
440
+ console.log(` SSH: mags ssh ${name}`);
441
+ console.log(` Exec: mags exec ${name} <command>`);
442
+ console.log(` Stop: mags stop ${name}`);
443
+ process.exit(0);
444
+ }
445
+ } catch (e) {
446
+ // Non-fatal — proceed with creation if list fails
447
+ }
448
+
432
449
  const payload = {
433
450
  script: 'sleep infinity',
434
451
  type: 'inline',
@@ -1449,123 +1466,225 @@ async function browserSession(args) {
1449
1466
  log('gray', `Stop with: mags stop ${workspace || requestId}`);
1450
1467
  }
1451
1468
 
1452
- async function sshToJob(nameOrId) {
1453
- if (!nameOrId) {
1454
- log('red', 'Error: Workspace, job name, or job ID required');
1455
- console.log(`\nUsage: mags ssh <workspace|name|id>\n`);
1456
- console.log('Examples:');
1457
- console.log(' mags ssh myproject # Auto-starts VM if needed');
1458
- console.log(' mags ssh 7bd12031-25ff...');
1459
- console.log('');
1460
- console.log('Get job names/IDs with: mags list');
1461
- process.exit(1);
1469
+ // WebSocket tunnel proxy — pipes stdin/stdout through a WebSocket to the agent SSH proxy.
1470
+ // Used as SSH ProxyCommand: ssh -o ProxyCommand="mags proxy <jobID>" root@mags-vm
1471
+ async function proxyTunnel(jobID) {
1472
+ const url = new URL(`/api/v1/mags-jobs/${jobID}/tunnel`, API_URL);
1473
+ const wsUrl = url.toString().replace(/^http/, 'ws');
1474
+
1475
+ // Dynamic import for WebSocket
1476
+ let WebSocket;
1477
+ try {
1478
+ WebSocket = require('ws');
1479
+ } catch {
1480
+ if (typeof globalThis.WebSocket !== 'undefined') {
1481
+ WebSocket = globalThis.WebSocket;
1482
+ } else {
1483
+ process.stderr.write('Error: ws package required. Run: npm install -g ws\n');
1484
+ process.exit(1);
1485
+ }
1462
1486
  }
1463
1487
 
1464
- // First, try to find a running/sleeping job for this workspace
1465
- const existingJob = await findWorkspaceJob(nameOrId);
1488
+ const ws = new WebSocket(wsUrl, {
1489
+ headers: {
1490
+ 'Authorization': `Bearer ${API_TOKEN}`,
1491
+ },
1492
+ perMessageDeflate: false,
1493
+ rejectUnauthorized: false,
1494
+ });
1466
1495
 
1467
- let jobID;
1496
+ // Buffer stdin data until WebSocket is open AND key is received
1497
+ const pendingData = [];
1498
+ let bridgeReady = false;
1499
+ let firstMessage = true;
1500
+
1501
+ process.stdin.on('data', (data) => {
1502
+ if (bridgeReady && ws.readyState === WebSocket.OPEN) {
1503
+ ws.send(data);
1504
+ } else {
1505
+ pendingData.push(Buffer.from(data));
1506
+ }
1507
+ });
1508
+
1509
+ ws.on('open', () => {
1510
+ // Don't flush yet — wait for first message (SSH key)
1511
+ });
1512
+
1513
+ ws.on('message', (data, isBinary) => {
1514
+ if (firstMessage && !isBinary) {
1515
+ // First text message is the SSH private key — save to temp file
1516
+ firstMessage = false;
1517
+ const keyData = Buffer.from(data).toString('utf8');
1518
+ if (keyData.startsWith('-----BEGIN')) {
1519
+ const keyPath = path.join(os.tmpdir(), `mags_tunnel_key_${jobID}`);
1520
+ fs.writeFileSync(keyPath, keyData, { mode: 0o600 });
1521
+ }
1522
+ // Now start the SSH data bridge
1523
+ bridgeReady = true;
1524
+ for (const buf of pendingData) {
1525
+ ws.send(buf);
1526
+ }
1527
+ pendingData.length = 0;
1528
+ return;
1529
+ }
1530
+ process.stdout.write(Buffer.from(data));
1531
+ });
1532
+
1533
+ ws.on('close', () => {
1534
+ process.exit(0);
1535
+ });
1536
+
1537
+ ws.on('error', (err) => {
1538
+ process.stderr.write(`Tunnel error: ${err.message}\n`);
1539
+ process.exit(1);
1540
+ });
1541
+
1542
+ process.stdin.on('end', () => {
1543
+ ws.close();
1544
+ });
1545
+ }
1546
+
1547
+ // Fetch SSH key from tunnel endpoint (quick WS handshake)
1548
+ async function fetchTunnelKey(jobID) {
1549
+ const WebSocket = require('ws');
1550
+ const url = new URL(`/api/v1/mags-jobs/${jobID}/tunnel`, API_URL);
1551
+ const wsUrl = url.toString().replace(/^http/, 'ws');
1552
+
1553
+ return new Promise((resolve, reject) => {
1554
+ const ws = new WebSocket(wsUrl, {
1555
+ headers: { 'Authorization': `Bearer ${API_TOKEN}` },
1556
+ perMessageDeflate: false,
1557
+ rejectUnauthorized: false,
1558
+ });
1559
+
1560
+ const timeout = setTimeout(() => {
1561
+ ws.close();
1562
+ reject(new Error('Tunnel key fetch timeout'));
1563
+ }, 60000);
1564
+
1565
+ ws.on('message', (data, isBinary) => {
1566
+ if (!isBinary) {
1567
+ const key = Buffer.from(data).toString('utf8');
1568
+ if (key.startsWith('-----BEGIN')) {
1569
+ clearTimeout(timeout);
1570
+ ws.close();
1571
+ resolve(key);
1572
+ return;
1573
+ }
1574
+ }
1575
+ // Not a key message — shouldn't happen as first message
1576
+ clearTimeout(timeout);
1577
+ ws.close();
1578
+ reject(new Error('No SSH key received from tunnel'));
1579
+ });
1580
+
1581
+ ws.on('error', (err) => {
1582
+ clearTimeout(timeout);
1583
+ reject(err);
1584
+ });
1585
+ });
1586
+ }
1587
+
1588
+ // Resolve a workspace name to a job ID, creating a new VM if needed
1589
+ async function resolveOrCreateJob(nameOrId) {
1590
+ const existingJob = await findWorkspaceJob(nameOrId);
1468
1591
 
1469
1592
  if (existingJob && existingJob.status === 'running') {
1470
1593
  log('green', `Found running VM for '${nameOrId}'`);
1471
- jobID = existingJob.request_id;
1594
+ return existingJob.request_id;
1472
1595
  } else if (existingJob && existingJob.status === 'sleeping') {
1473
1596
  log('yellow', `Waking sleeping VM for '${nameOrId}'...`);
1474
- jobID = existingJob.request_id;
1475
- } else {
1476
- // No running/sleeping VM — start a new persistent one
1477
- log('blue', `Starting VM with workspace '${nameOrId}'...`);
1478
-
1479
- const payload = {
1480
- script: 'echo "SSH session ready" && sleep 3600',
1481
- type: 'inline',
1482
- workspace_id: nameOrId,
1483
- persistent: true,
1484
- startup_command: 'sleep 3600'
1485
- };
1597
+ return existingJob.request_id;
1598
+ }
1486
1599
 
1487
- const response = await request('POST', '/api/v1/mags-jobs', payload);
1600
+ // No running/sleeping VM start a new persistent one
1601
+ log('blue', `Starting VM with workspace '${nameOrId}'...`);
1602
+ const payload = {
1603
+ script: 'echo "SSH session ready" && sleep 3600',
1604
+ type: 'inline',
1605
+ workspace_id: nameOrId,
1606
+ persistent: true,
1607
+ startup_command: 'sleep 3600'
1608
+ };
1488
1609
 
1489
- if (!response.request_id) {
1490
- log('red', 'Failed to start VM:');
1491
- console.log(JSON.stringify(response, null, 2));
1492
- process.exit(1);
1493
- }
1610
+ const response = await request('POST', '/api/v1/mags-jobs', payload);
1611
+ if (!response.request_id) {
1612
+ log('red', 'Failed to start VM:');
1613
+ console.log(JSON.stringify(response, null, 2));
1614
+ process.exit(1);
1615
+ }
1494
1616
 
1495
- jobID = response.request_id;
1496
- log('gray', `Job: ${jobID}`);
1617
+ const jobID = response.request_id;
1618
+ log('gray', `Job: ${jobID}`);
1497
1619
 
1498
- // Wait for VM to be ready (status=running AND vm_id assigned)
1499
- log('blue', 'Waiting for VM...');
1500
- for (let i = 0; i < 60; i++) {
1501
- const status = await request('GET', `/api/v1/mags-jobs/${jobID}/status`);
1502
- if (status.status === 'running' && status.vm_id) break;
1503
- if (status.status === 'error') {
1504
- log('red', 'VM failed to start');
1505
- process.exit(1);
1506
- }
1507
- process.stdout.write('.');
1508
- await sleep(300);
1620
+ log('blue', 'Waiting for VM...');
1621
+ for (let i = 0; i < 60; i++) {
1622
+ const status = await request('GET', `/api/v1/mags-jobs/${jobID}/status`);
1623
+ if (status.status === 'running' && status.vm_id) break;
1624
+ if (status.status === 'error') {
1625
+ log('red', 'VM failed to start');
1626
+ process.exit(1);
1509
1627
  }
1510
- console.log('');
1628
+ process.stdout.write('.');
1629
+ await sleep(300);
1511
1630
  }
1631
+ console.log('');
1632
+ return jobID;
1633
+ }
1512
1634
 
1513
- // Enable SSH access (port 22)
1514
- log('blue', 'Enabling SSH access...');
1635
+ // Get SSH key for a job (calls EnableAccess to set up agent proxy and get key)
1636
+ async function getSSHKey(jobID) {
1515
1637
  const accessResp = await request('POST', `/api/v1/mags-jobs/${jobID}/access`, { port: 22 });
1516
-
1517
1638
  if (!accessResp.success) {
1518
1639
  log('red', 'Failed to enable SSH access');
1519
- if (accessResp.error) {
1520
- log('red', accessResp.error);
1521
- }
1640
+ if (accessResp.error) log('red', accessResp.error);
1522
1641
  process.exit(1);
1523
1642
  }
1643
+ return accessResp.ssh_private_key;
1644
+ }
1524
1645
 
1525
- const sshHost = accessResp.ssh_host;
1526
- const sshPort = accessResp.ssh_port;
1527
- const sshKey = accessResp.ssh_private_key;
1528
-
1529
- if (!sshHost || !sshPort) {
1530
- log('red', 'SSH access enabled but no connection details returned');
1531
- console.log(JSON.stringify(accessResp, null, 2));
1646
+ async function sshToJob(nameOrId) {
1647
+ if (!nameOrId) {
1648
+ log('red', 'Error: Workspace, job name, or job ID required');
1649
+ console.log(`\nUsage: mags ssh <workspace|name|id>\n`);
1650
+ console.log('Examples:');
1651
+ console.log(' mags ssh myproject # Auto-starts VM if needed');
1652
+ console.log(' mags ssh 7bd12031-25ff...');
1653
+ console.log('');
1654
+ console.log('Get job names/IDs with: mags list');
1532
1655
  process.exit(1);
1533
1656
  }
1534
1657
 
1535
- log('green', `Connecting to ${sshHost}:${sshPort}...`);
1658
+ const jobID = await resolveOrCreateJob(nameOrId);
1659
+
1660
+ // Fetch SSH key from tunnel endpoint (also warms up the agent proxy)
1661
+ log('blue', 'Setting up tunnel...');
1662
+ const sshKey = await fetchTunnelKey(jobID);
1663
+ const magsPath = process.argv[1];
1664
+ const keyFile = path.join(os.tmpdir(), `mags_tunnel_key_${jobID}`);
1665
+ fs.writeFileSync(keyFile, sshKey, { mode: 0o600 });
1666
+
1667
+ log('green', `Connecting via secure tunnel...`);
1536
1668
  console.log(`${colors.gray}(Use Ctrl+D or 'exit' to disconnect)${colors.reset}\n`);
1537
1669
 
1538
- // Build SSH arguments
1539
1670
  const sshArgs = [
1540
- '-tt', // Force TTY allocation even when stdin isn't a terminal
1671
+ '-tt',
1541
1672
  '-o', 'StrictHostKeyChecking=no',
1542
1673
  '-o', 'UserKnownHostsFile=/dev/null',
1543
1674
  '-o', 'LogLevel=ERROR',
1544
- '-p', sshPort.toString()
1675
+ '-o', `ProxyCommand=${magsPath} proxy ${jobID}`,
1676
+ '-i', keyFile,
1677
+ 'root@mags-vm',
1545
1678
  ];
1546
1679
 
1547
- // If API returned an SSH key, write it to a temp file and use it
1548
- let keyFile = null;
1549
- if (sshKey) {
1550
- keyFile = path.join(os.tmpdir(), `mags_ssh_${Date.now()}`);
1551
- fs.writeFileSync(keyFile, sshKey, { mode: 0o600 });
1552
- sshArgs.push('-i', keyFile);
1553
- }
1554
-
1555
- sshArgs.push(`root@${sshHost}`);
1556
-
1557
1680
  const ssh = spawn('ssh', sshArgs, {
1558
- stdio: 'inherit' // Inherit stdin/stdout/stderr for interactive session
1681
+ stdio: 'inherit'
1559
1682
  });
1560
1683
 
1561
1684
  ssh.on('error', (err) => {
1562
- if (keyFile) {
1563
- try { fs.unlinkSync(keyFile); } catch (e) {}
1564
- }
1685
+ try { fs.unlinkSync(keyFile); } catch (e) {}
1565
1686
  if (err.code === 'ENOENT') {
1566
1687
  log('red', 'SSH client not found. Please install OpenSSH.');
1567
- log('gray', 'On macOS/Linux: ssh is usually pre-installed');
1568
- log('gray', 'On Windows: Install OpenSSH or use WSL');
1569
1688
  } else {
1570
1689
  log('red', `SSH error: ${err.message}`);
1571
1690
  }
@@ -1573,10 +1692,7 @@ async function sshToJob(nameOrId) {
1573
1692
  });
1574
1693
 
1575
1694
  ssh.on('close', (code) => {
1576
- // Clean up temp key file
1577
- if (keyFile) {
1578
- try { fs.unlinkSync(keyFile); } catch (e) {}
1579
- }
1695
+ try { fs.unlinkSync(keyFile); } catch (e) {}
1580
1696
  if (code === 0) {
1581
1697
  log('green', '\nSSH session ended');
1582
1698
  } else {
@@ -1612,31 +1728,21 @@ async function execOnJob(nameOrId, command) {
1612
1728
  process.exit(1);
1613
1729
  }
1614
1730
 
1615
- // Enable SSH access
1616
- log('blue', 'Enabling SSH access...');
1617
- const accessResp = await request('POST', `/api/v1/mags-jobs/${jobID}/access`, { port: 22 });
1618
-
1619
- if (!accessResp.success || !accessResp.ssh_host || !accessResp.ssh_port) {
1620
- log('red', 'Failed to enable SSH access');
1621
- if (accessResp.error) log('red', accessResp.error);
1622
- process.exit(1);
1623
- }
1731
+ // Fetch SSH key from tunnel endpoint (also warms up the agent proxy)
1732
+ log('blue', 'Setting up tunnel...');
1733
+ const sshKey = await fetchTunnelKey(jobID);
1734
+ const magsPath = process.argv[1];
1735
+ const keyFile = path.join(os.tmpdir(), `mags_tunnel_key_${jobID}`);
1736
+ fs.writeFileSync(keyFile, sshKey, { mode: 0o600 });
1624
1737
 
1625
- // Write SSH key to temp file
1626
- let keyFile = null;
1627
1738
  const sshArgs = [
1628
1739
  '-o', 'StrictHostKeyChecking=no',
1629
1740
  '-o', 'UserKnownHostsFile=/dev/null',
1630
1741
  '-o', 'LogLevel=ERROR',
1631
- '-p', accessResp.ssh_port.toString()
1742
+ '-o', `ProxyCommand=${magsPath} proxy ${jobID}`,
1743
+ '-i', keyFile,
1632
1744
  ];
1633
1745
 
1634
- if (accessResp.ssh_private_key) {
1635
- keyFile = path.join(os.tmpdir(), `mags_ssh_${Date.now()}`);
1636
- fs.writeFileSync(keyFile, accessResp.ssh_private_key, { mode: 0o600 });
1637
- sshArgs.push('-i', keyFile);
1638
- }
1639
-
1640
1746
  // Wrap command to use chroot if overlay is mounted, with proper env
1641
1747
  const escaped = command.replace(/'/g, "'\\''");
1642
1748
  const wrappedCmd = `if [ -d /overlay/bin ]; then chroot /overlay /bin/sh -l -c 'cd /root 2>/dev/null; ${escaped}'; else cd /root 2>/dev/null; ${escaped}; fi`;
@@ -1644,18 +1750,18 @@ async function execOnJob(nameOrId, command) {
1644
1750
  if (process.stdin.isTTY) {
1645
1751
  sshArgs.push('-t');
1646
1752
  }
1647
- sshArgs.push(`root@${accessResp.ssh_host}`, wrappedCmd);
1753
+ sshArgs.push('root@mags-vm', wrappedCmd);
1648
1754
 
1649
1755
  const ssh = spawn('ssh', sshArgs, { stdio: 'inherit' });
1650
1756
 
1651
1757
  ssh.on('error', (err) => {
1652
- if (keyFile) try { fs.unlinkSync(keyFile); } catch (e) {}
1758
+ try { fs.unlinkSync(keyFile); } catch (e) {}
1653
1759
  log('red', `SSH error: ${err.message}`);
1654
1760
  process.exit(1);
1655
1761
  });
1656
1762
 
1657
1763
  ssh.on('close', (code) => {
1658
- if (keyFile) try { fs.unlinkSync(keyFile); } catch (e) {}
1764
+ try { fs.unlinkSync(keyFile); } catch (e) {}
1659
1765
  process.exit(code || 0);
1660
1766
  });
1661
1767
  }
@@ -1751,6 +1857,9 @@ async function main() {
1751
1857
  case 'setup-claude':
1752
1858
  await setupClaude();
1753
1859
  break;
1860
+ case 'proxy':
1861
+ await proxyTunnel(args[1]);
1862
+ break;
1754
1863
  default:
1755
1864
  if (!command) {
1756
1865
  // No command - check if logged in
package/index.js CHANGED
@@ -333,7 +333,7 @@ class Mags {
333
333
  }
334
334
 
335
335
  /**
336
- * Execute a command on an existing running/sleeping VM via SSH
336
+ * Execute a command on an existing running/sleeping VM via HTTP exec endpoint
337
337
  * @param {string} nameOrId - Job name, workspace ID, or request ID
338
338
  * @param {string} command - Command to execute
339
339
  * @param {object} options - Options
@@ -350,58 +350,12 @@ class Mags {
350
350
  }
351
351
 
352
352
  const requestId = job.request_id || job.id;
353
- const access = await this.enableAccess(requestId, 22);
354
-
355
- if (!access.success || !access.ssh_host) {
356
- throw new MagsError(`Failed to enable SSH access: ${access.error || 'unknown error'}`);
357
- }
358
-
359
- const { execFile } = require('child_process');
360
- const fs = require('fs');
361
- const os = require('os');
362
- const path = require('path');
363
-
364
- const escaped = command.replace(/'/g, "'\\''");
365
- const wrapped =
366
- `if [ -d /overlay/bin ]; then ` +
367
- `chroot /overlay /bin/sh -l -c 'cd /root 2>/dev/null; ${escaped}'; ` +
368
- `else cd /root 2>/dev/null; ${escaped}; fi`;
369
-
370
- let keyFile = null;
371
- try {
372
- const sshArgs = [
373
- '-o', 'StrictHostKeyChecking=no',
374
- '-o', 'UserKnownHostsFile=/dev/null',
375
- '-o', 'LogLevel=ERROR',
376
- '-p', String(access.ssh_port),
377
- ];
378
-
379
- if (access.ssh_private_key) {
380
- keyFile = path.join(os.tmpdir(), `mags_ssh_${Date.now()}`);
381
- fs.writeFileSync(keyFile, access.ssh_private_key, { mode: 0o600 });
382
- sshArgs.push('-i', keyFile);
383
- }
353
+ const resp = await this._request('POST', `/api/v1/mags-jobs/${requestId}/exec`, {
354
+ command,
355
+ timeout: Math.ceil(timeout / 1000),
356
+ });
384
357
 
385
- sshArgs.push(`root@${access.ssh_host}`, wrapped);
386
-
387
- return new Promise((resolve, reject) => {
388
- execFile('ssh', sshArgs, { timeout, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
389
- if (keyFile) try { fs.unlinkSync(keyFile); } catch {}
390
- if (err && err.killed) {
391
- reject(new MagsError(`Command timed out after ${timeout}ms`));
392
- } else {
393
- resolve({
394
- exitCode: err ? err.code || 1 : 0,
395
- output: stdout,
396
- stderr: stderr,
397
- });
398
- }
399
- });
400
- });
401
- } catch (e) {
402
- if (keyFile) try { require('fs').unlinkSync(keyFile); } catch {}
403
- throw e;
404
- }
358
+ return { exitCode: resp.exit_code, output: resp.stdout, stderr: resp.stderr };
405
359
  }
406
360
 
407
361
  /**
package/nodejs/index.js CHANGED
@@ -328,7 +328,7 @@ class Mags {
328
328
  }
329
329
 
330
330
  /**
331
- * Execute a command on an existing running/sleeping VM via SSH
331
+ * Execute a command on an existing running/sleeping VM via HTTP exec endpoint
332
332
  * @param {string} nameOrId - Job name, workspace ID, or request ID
333
333
  * @param {string} command - Command to execute
334
334
  * @param {object} options - Options
@@ -345,58 +345,12 @@ class Mags {
345
345
  }
346
346
 
347
347
  const requestId = job.request_id || job.id;
348
- const access = await this.enableAccess(requestId, 22);
349
-
350
- if (!access.success || !access.ssh_host) {
351
- throw new MagsError(`Failed to enable SSH access: ${access.error || 'unknown error'}`);
352
- }
353
-
354
- const { execFileSync, execFile } = require('child_process');
355
- const fs = require('fs');
356
- const os = require('os');
357
- const path = require('path');
358
-
359
- const escaped = command.replace(/'/g, "'\\''");
360
- const wrapped =
361
- `if [ -d /overlay/bin ]; then ` +
362
- `chroot /overlay /bin/sh -l -c 'cd /root 2>/dev/null; ${escaped}'; ` +
363
- `else cd /root 2>/dev/null; ${escaped}; fi`;
364
-
365
- let keyFile = null;
366
- try {
367
- const sshArgs = [
368
- '-o', 'StrictHostKeyChecking=no',
369
- '-o', 'UserKnownHostsFile=/dev/null',
370
- '-o', 'LogLevel=ERROR',
371
- '-p', String(access.ssh_port),
372
- ];
373
-
374
- if (access.ssh_private_key) {
375
- keyFile = path.join(os.tmpdir(), `mags_ssh_${Date.now()}`);
376
- fs.writeFileSync(keyFile, access.ssh_private_key, { mode: 0o600 });
377
- sshArgs.push('-i', keyFile);
378
- }
348
+ const resp = await this._request('POST', `/api/v1/mags-jobs/${requestId}/exec`, {
349
+ command,
350
+ timeout: Math.ceil(timeout / 1000),
351
+ });
379
352
 
380
- sshArgs.push(`root@${access.ssh_host}`, wrapped);
381
-
382
- return new Promise((resolve, reject) => {
383
- const proc = execFile('ssh', sshArgs, { timeout, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
384
- if (keyFile) try { fs.unlinkSync(keyFile); } catch {}
385
- if (err && err.killed) {
386
- reject(new MagsError(`Command timed out after ${timeout}ms`));
387
- } else {
388
- resolve({
389
- exitCode: err ? err.code || 1 : 0,
390
- output: stdout,
391
- stderr: stderr,
392
- });
393
- }
394
- });
395
- });
396
- } catch (e) {
397
- if (keyFile) try { require('fs').unlinkSync(keyFile); } catch {}
398
- throw e;
399
- }
353
+ return { exitCode: resp.exit_code, output: resp.stdout, stderr: resp.stderr };
400
354
  }
401
355
 
402
356
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpiecloud/mags",
3
- "version": "1.8.12",
3
+ "version": "1.8.14",
4
4
  "description": "Mags CLI - Execute scripts on Magpie's instant VM infrastructure",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -23,5 +23,8 @@
23
23
  "homepage": "https://mags.run",
24
24
  "engines": {
25
25
  "node": ">=14.0.0"
26
+ },
27
+ "dependencies": {
28
+ "ws": "^8.18.0"
26
29
  }
27
30
  }
package/python/README.md CHANGED
@@ -135,15 +135,19 @@ print(f"Jobs: {usage['total_jobs']}, VM seconds: {usage['vm_seconds']:.0f}")
135
135
  | `find_job(name_or_id)` | Find a running/sleeping job by name or workspace |
136
136
  | `exec(name_or_id, command)` | Run a command on an existing VM via SSH |
137
137
  | `stop(name_or_id)` | Stop a running job |
138
+ | `url(name_or_id, port)` | Enable public URL and return full URL |
138
139
  | `resize(workspace, disk_gb)` | Resize a workspace's disk |
139
140
  | `status(request_id)` | Get job status |
140
141
  | `logs(request_id)` | Get job logs |
141
142
  | `list_jobs(page, page_size)` | List recent jobs |
142
- | `update_job(request_id, startup_command)` | Update job config |
143
+ | `update_job(request_id, **opts)` | Update job config (`startup_command`, `no_sleep`) |
143
144
  | `enable_access(request_id, port)` | Enable URL or SSH access |
144
145
  | `usage(window_days)` | Get usage summary |
145
146
  | `upload_file(path)` | Upload a file, returns file ID |
146
147
  | `upload_files(paths)` | Upload multiple files |
148
+ | `url_alias_create(sub, ws_id)` | Create a stable URL alias |
149
+ | `url_alias_list()` | List URL aliases |
150
+ | `url_alias_delete(sub)` | Delete a URL alias |
147
151
  | `cron_create(**opts)` | Create a cron job |
148
152
  | `cron_list()` | List cron jobs |
149
153
  | `cron_get(id)` | Get a cron job |
@@ -154,4 +158,4 @@ print(f"Jobs: {usage['total_jobs']}, VM seconds: {usage['vm_seconds']:.0f}")
154
158
 
155
159
  - Website: [mags.run](https://mags.run)
156
160
  - Node.js SDK: `npm install @magpiecloud/mags`
157
- - CLI: `go install` or download from releases
161
+ - CLI: `npm install -g @magpiecloud/mags`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "magpie-mags"
7
- version = "1.3.5"
7
+ version = "1.3.7"
8
8
  description = "Mags SDK - Execute scripts on Magpie's instant VM infrastructure"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}