@magpiecloud/mags 1.8.2 → 1.8.4

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 CHANGED
@@ -207,7 +207,11 @@ ${colors.bold}Commands:${colors.reset}
207
207
  logs <name|id> Get job logs
208
208
  list List recent jobs
209
209
  url <name|id> [port] Enable URL access for a job
210
+ url alias <sub> <workspace> Create a stable URL alias for a workspace
211
+ url alias list List your URL aliases
212
+ url alias remove <subdomain> Delete a URL alias
210
213
  stop <name|id> Stop a running job
214
+ resize <workspace> --disk <GB> Resize a workspace's disk (restarts VM)
211
215
  sync <workspace|id> Sync workspace to S3 (without stopping)
212
216
  workspace list List persistent workspaces
213
217
  workspace delete <id> Delete a workspace and its S3 data
@@ -259,6 +263,10 @@ ${colors.bold}Examples:${colors.reset}
259
263
  mags status myvm
260
264
  mags logs myvm
261
265
  mags url myvm 8080
266
+ mags url alias my-api myvm # Stable URL: my-api.apps.magpiecloud.com
267
+ mags url alias my-api myvm --lfg # Stable URL: my-api.app.lfg.run
268
+ mags url alias list # List all aliases
269
+ mags url alias remove my-api # Remove alias
262
270
  mags setup-claude # Install Claude Code skill
263
271
  `);
264
272
  process.exit(1);
@@ -667,6 +675,98 @@ async function enableUrlAccess(nameOrId, port = 8080) {
667
675
  }
668
676
  }
669
677
 
678
+ async function urlAliasCommand(args) {
679
+ if (args.length === 0) {
680
+ log('red', 'Error: URL alias subcommand required');
681
+ console.log('\nUsage:');
682
+ console.log(' mags url alias <subdomain> <workspace> [--lfg]');
683
+ console.log(' mags url alias list');
684
+ console.log(' mags url alias remove <subdomain>');
685
+ process.exit(1);
686
+ }
687
+
688
+ const subcommand = args[0];
689
+
690
+ switch (subcommand) {
691
+ case 'list':
692
+ case 'ls':
693
+ await urlAliasList();
694
+ break;
695
+ case 'remove':
696
+ case 'rm':
697
+ case 'delete':
698
+ if (!args[1]) {
699
+ log('red', 'Error: Subdomain required');
700
+ console.log('\nUsage: mags url alias remove <subdomain>');
701
+ process.exit(1);
702
+ }
703
+ await urlAliasRemove(args[1]);
704
+ break;
705
+ default:
706
+ // mags url alias <subdomain> <workspace> [--lfg]
707
+ const subdomain = args[0];
708
+ const workspace = args[1];
709
+ if (!workspace) {
710
+ log('red', 'Error: Workspace required');
711
+ console.log('\nUsage: mags url alias <subdomain> <workspace> [--lfg]');
712
+ process.exit(1);
713
+ }
714
+ const useLfg = args.includes('--lfg');
715
+ await urlAliasCreate(subdomain, workspace, useLfg);
716
+ break;
717
+ }
718
+ }
719
+
720
+ async function urlAliasCreate(subdomain, workspaceId, useLfg) {
721
+ const domain = useLfg ? 'app.lfg.run' : 'apps.magpiecloud.com';
722
+ log('blue', `Creating URL alias: ${subdomain}.${domain} → workspace '${workspaceId}'...`);
723
+
724
+ const resp = await request('POST', '/api/v1/mags-url-aliases', {
725
+ subdomain,
726
+ workspace_id: workspaceId,
727
+ domain,
728
+ });
729
+
730
+ if (resp.error) {
731
+ log('red', `Error: ${resp.error}`);
732
+ process.exit(1);
733
+ }
734
+
735
+ if (resp.url) {
736
+ log('green', `URL alias created: ${resp.url}`);
737
+ } else {
738
+ log('green', `URL alias created: https://${subdomain}.${domain}`);
739
+ }
740
+ }
741
+
742
+ async function urlAliasList() {
743
+ const resp = await request('GET', '/api/v1/mags-url-aliases');
744
+ const aliases = resp.aliases || [];
745
+
746
+ if (aliases.length > 0) {
747
+ log('cyan', 'URL Aliases:\n');
748
+ aliases.forEach(a => {
749
+ console.log(` ${colors.bold}${a.subdomain}${colors.reset}`);
750
+ console.log(` URL: ${colors.green}${a.url}${colors.reset}`);
751
+ console.log(` Workspace: ${a.workspace_id} Domain: ${a.domain}`);
752
+ console.log('');
753
+ });
754
+ log('gray', `Total: ${aliases.length} alias(es)`);
755
+ } else {
756
+ log('yellow', 'No URL aliases found');
757
+ }
758
+ }
759
+
760
+ async function urlAliasRemove(subdomain) {
761
+ log('blue', `Removing URL alias '${subdomain}'...`);
762
+ const resp = await request('DELETE', `/api/v1/mags-url-aliases/${subdomain}`);
763
+ if (resp.error) {
764
+ log('red', `Error: ${resp.error}`);
765
+ process.exit(1);
766
+ }
767
+ log('green', resp.message || `URL alias '${subdomain}' removed`);
768
+ }
769
+
670
770
  async function getStatus(nameOrId) {
671
771
  if (!nameOrId) {
672
772
  log('red', 'Error: Job name or ID required');
@@ -730,6 +830,61 @@ async function stopJob(nameOrId) {
730
830
  }
731
831
  }
732
832
 
833
+ async function resizeVM(args) {
834
+ let name = null;
835
+ let diskGB = 0;
836
+
837
+ for (let i = 0; i < args.length; i++) {
838
+ if (args[i] === '--disk' && args[i + 1]) {
839
+ diskGB = parseInt(args[++i]) || 0;
840
+ } else if (!name) {
841
+ name = args[i];
842
+ }
843
+ }
844
+
845
+ if (!name || !diskGB) {
846
+ log('red', 'Error: Workspace name and --disk <GB> required');
847
+ console.log('\nUsage: mags resize <workspace> --disk <GB>\n');
848
+ process.exit(1);
849
+ }
850
+
851
+ // Find existing job for this workspace
852
+ const existingJob = await findWorkspaceJob(name);
853
+ if (existingJob) {
854
+ // Sync workspace before stopping (preserve files)
855
+ if (existingJob.status === 'running') {
856
+ log('blue', 'Syncing workspace before resize...');
857
+ await request('POST', `/api/v1/mags-jobs/${existingJob.request_id}/sync`);
858
+ }
859
+ log('blue', `Stopping existing VM...`);
860
+ await request('POST', `/api/v1/mags-jobs/${existingJob.request_id}/stop`);
861
+ // Brief wait for the stop to complete
862
+ await new Promise(r => setTimeout(r, 1000));
863
+ }
864
+
865
+ // Create new VM with the same workspace name and new disk size
866
+ log('blue', `Creating new VM with ${diskGB}GB disk...`);
867
+ const payload = {
868
+ script: 'sleep infinity',
869
+ type: 'inline',
870
+ persistent: true,
871
+ name: name,
872
+ workspace_id: name,
873
+ startup_command: 'sleep infinity',
874
+ disk_gb: diskGB,
875
+ };
876
+
877
+ const response = await request('POST', '/api/v1/mags-jobs', payload);
878
+ if (!response.request_id) {
879
+ log('red', 'Failed to create VM:');
880
+ console.log(JSON.stringify(response, null, 2));
881
+ process.exit(1);
882
+ }
883
+
884
+ log('green', `Resized '${name}' to ${diskGB}GB disk`);
885
+ log('gray', `Job: ${response.request_id}`);
886
+ }
887
+
733
888
  async function syncWorkspace(nameOrId) {
734
889
  if (!nameOrId) {
735
890
  log('red', 'Error: Workspace name or job ID required');
@@ -1366,7 +1521,7 @@ async function main() {
1366
1521
  break;
1367
1522
  case '--version':
1368
1523
  case '-v':
1369
- console.log('mags v1.8.2');
1524
+ console.log('mags v1.8.3');
1370
1525
  process.exit(0);
1371
1526
  break;
1372
1527
  case 'new':
@@ -1387,7 +1542,11 @@ async function main() {
1387
1542
  break;
1388
1543
  case 'url':
1389
1544
  await requireAuth();
1390
- await enableUrlAccess(args[1], parseInt(args[2]) || 8080);
1545
+ if (args[1] === 'alias') {
1546
+ await urlAliasCommand(args.slice(2));
1547
+ } else {
1548
+ await enableUrlAccess(args[1], parseInt(args[2]) || 8080);
1549
+ }
1391
1550
  break;
1392
1551
  case 'status':
1393
1552
  await requireAuth();
@@ -1405,6 +1564,10 @@ async function main() {
1405
1564
  await requireAuth();
1406
1565
  await stopJob(args[1]);
1407
1566
  break;
1567
+ case 'resize':
1568
+ await requireAuth();
1569
+ await resizeVM(args.slice(1));
1570
+ break;
1408
1571
  case 'sync':
1409
1572
  await requireAuth();
1410
1573
  await syncWorkspace(args[1]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpiecloud/mags",
3
- "version": "1.8.2",
3
+ "version": "1.8.4",
4
4
  "description": "Mags CLI - Execute scripts on Magpie's instant VM infrastructure",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "magpie-mags"
7
- version = "1.3.0"
7
+ version = "1.3.1"
8
8
  description = "Mags SDK - Execute scripts on Magpie's instant VM infrastructure"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -211,6 +211,30 @@ class Mags:
211
211
  request_id = self._resolve_job_id(name_or_id)
212
212
  return self._request("POST", f"/mags-jobs/{request_id}/stop")
213
213
 
214
+ def resize(
215
+ self,
216
+ workspace: str,
217
+ disk_gb: int,
218
+ *,
219
+ timeout: float = 30.0,
220
+ poll_interval: float = 1.0,
221
+ ) -> dict:
222
+ """Resize a workspace's disk. Stops the existing VM, then creates a new one.
223
+
224
+ Workspace files are preserved in S3.
225
+ Returns ``{"request_id": ..., "status": "running"}``.
226
+ """
227
+ existing = self.find_job(workspace)
228
+ if existing and existing.get("status") == "running":
229
+ self._request("POST", f"/mags-jobs/{existing['request_id']}/sync")
230
+ self._request("POST", f"/mags-jobs/{existing['request_id']}/stop")
231
+ time.sleep(1)
232
+ elif existing and existing.get("status") == "sleeping":
233
+ self._request("POST", f"/mags-jobs/{existing['request_id']}/stop")
234
+ time.sleep(1)
235
+
236
+ return self.new(workspace, disk_gb=disk_gb, timeout=timeout, poll_interval=poll_interval)
237
+
214
238
  def new(
215
239
  self,
216
240
  name: str,
@@ -483,3 +507,36 @@ class Mags:
483
507
  def cron_delete(self, cron_id: str) -> dict:
484
508
  """Delete a cron job."""
485
509
  return self._request("DELETE", f"/mags-cron/{cron_id}")
510
+
511
+ # ── URL aliases ──────────────────────────────────────────────────
512
+
513
+ def url_alias_create(
514
+ self,
515
+ subdomain: str,
516
+ workspace_id: str,
517
+ domain: str = "apps.magpiecloud.com",
518
+ ) -> dict:
519
+ """Create a stable URL alias for a workspace.
520
+
521
+ The alias maps ``subdomain.<domain>`` to the active job in the workspace.
522
+ Use ``domain="app.lfg.run"`` for the LFG domain.
523
+
524
+ Returns ``{"id": ..., "subdomain": ..., "url": ...}``.
525
+ """
526
+ return self._request(
527
+ "POST",
528
+ "/mags-url-aliases",
529
+ json={
530
+ "subdomain": subdomain,
531
+ "workspace_id": workspace_id,
532
+ "domain": domain,
533
+ },
534
+ )
535
+
536
+ def url_alias_list(self) -> dict:
537
+ """List all URL aliases. Returns ``{"aliases": [...], "total": N}``."""
538
+ return self._request("GET", "/mags-url-aliases")
539
+
540
+ def url_alias_delete(self, subdomain: str) -> dict:
541
+ """Delete a URL alias by subdomain."""
542
+ return self._request("DELETE", f"/mags-url-aliases/{subdomain}")