@magpiecloud/mags 1.8.16 → 1.9.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 (45) hide show
  1. package/API.md +388 -0
  2. package/Mags-API.postman_collection.json +374 -0
  3. package/QUICKSTART.md +295 -0
  4. package/README.md +378 -95
  5. package/bin/mags.js +79 -44
  6. package/deploy-page.sh +171 -0
  7. package/index.js +0 -2
  8. package/mags +0 -0
  9. package/mags.sh +270 -0
  10. package/nodejs/README.md +197 -0
  11. package/nodejs/bin/mags.js +1882 -0
  12. package/nodejs/index.js +602 -0
  13. package/nodejs/package.json +45 -0
  14. package/package.json +3 -18
  15. package/python/INTEGRATION.md +800 -0
  16. package/python/README.md +161 -0
  17. package/python/dist/magpie_mags-1.3.8-py3-none-any.whl +0 -0
  18. package/python/dist/magpie_mags-1.3.8.tar.gz +0 -0
  19. package/python/examples/demo.py +181 -0
  20. package/python/pyproject.toml +39 -0
  21. package/python/src/magpie_mags.egg-info/PKG-INFO +186 -0
  22. package/python/src/magpie_mags.egg-info/SOURCES.txt +9 -0
  23. package/python/src/magpie_mags.egg-info/dependency_links.txt +1 -0
  24. package/python/src/magpie_mags.egg-info/requires.txt +1 -0
  25. package/python/src/magpie_mags.egg-info/top_level.txt +1 -0
  26. package/python/src/mags/__init__.py +6 -0
  27. package/python/src/mags/client.py +527 -0
  28. package/python/test_sdk.py +78 -0
  29. package/skill.md +153 -0
  30. package/test-sdk.js +68 -0
  31. package/website/api.html +1095 -0
  32. package/website/claude-skill.html +481 -0
  33. package/website/cookbook/hn-marketing.html +410 -0
  34. package/website/cookbook/hn-marketing.sh +42 -0
  35. package/website/cookbook.html +282 -0
  36. package/website/docs.html +677 -0
  37. package/website/env.js +4 -0
  38. package/website/index.html +801 -0
  39. package/website/llms.txt +334 -0
  40. package/website/login.html +108 -0
  41. package/website/mags.md +210 -0
  42. package/website/script.js +453 -0
  43. package/website/styles.css +1075 -0
  44. package/website/tokens.html +169 -0
  45. package/website/usage.html +185 -0
package/bin/mags.js CHANGED
@@ -48,6 +48,21 @@ const config = loadConfig();
48
48
  const API_URL = process.env.MAGS_API_URL || config.api_url || 'https://api.magpiecloud.com';
49
49
  let API_TOKEN = process.env.MAGS_API_TOKEN || config.api_token || '';
50
50
 
51
+ // V2 flag: --v2 switches API paths from /api/v1/ to /api/v2/
52
+ let apiVersion = process.env.MAGS_API_VERSION || 'v1';
53
+ {
54
+ const v2Index = process.argv.indexOf('--v2');
55
+ if (v2Index !== -1) {
56
+ apiVersion = 'v2';
57
+ process.argv.splice(v2Index, 1);
58
+ }
59
+ }
60
+
61
+ // Helper to build versioned API paths
62
+ function apiPath(resource) {
63
+ return `/api/${apiVersion}/${resource}`;
64
+ }
65
+
51
66
  // Colors
52
67
  const colors = {
53
68
  red: '\x1b[31m',
@@ -147,7 +162,7 @@ async function resolveJobId(nameOrId) {
147
162
  }
148
163
 
149
164
  // Otherwise, search by name/workspace
150
- const resp = await request('GET', `/api/v1/mags-jobs?page=1&page_size=50`);
165
+ const resp = await request('GET', `${apiPath('mags-jobs')}?page=1&page_size=50`);
151
166
  if (resp.jobs && resp.jobs.length > 0) {
152
167
  // Try exact match on name first (prefer running/sleeping)
153
168
  let job = resp.jobs.find(j => j.name === nameOrId && (j.status === 'running' || j.status === 'sleeping'));
@@ -178,7 +193,7 @@ async function resolveJobId(nameOrId) {
178
193
 
179
194
  // Find a running or sleeping job for a workspace
180
195
  async function findWorkspaceJob(workspace) {
181
- const resp = await request('GET', `/api/v1/mags-jobs?page=1&page_size=50`);
196
+ const resp = await request('GET', `${apiPath('mags-jobs')}?page=1&page_size=50`);
182
197
  if (resp.jobs && resp.jobs.length > 0) {
183
198
  // Prefer running, then sleeping
184
199
  let job = resp.jobs.find(j => j.workspace_id === workspace && j.status === 'running');
@@ -230,6 +245,7 @@ ${colors.bold}Run Options:${colors.reset}
230
245
  --url Enable public URL access (requires -p)
231
246
  --port <port> Port to expose for URL (default: 8080)
232
247
  --startup-command <cmd> Command to run when VM wakes from sleep
248
+ -t, --type <type> VM rootfs type: standard, claude, pi
233
249
 
234
250
  ${colors.bold}Cron Commands:${colors.reset}
235
251
  cron add [options] <script> Create a scheduled cron job
@@ -310,7 +326,7 @@ To authenticate, you need an API token from Magpie.
310
326
  log('blue', 'Verifying token...');
311
327
 
312
328
  try {
313
- const resp = await request('GET', '/api/v1/mags-jobs?page=1&page_size=1');
329
+ const resp = await request('GET', `${apiPath('mags-jobs')}?page=1&page_size=1`);
314
330
 
315
331
  if (resp.error === 'unauthorized' || resp.error === 'Unauthorized') {
316
332
  log('red', 'Invalid token. Please check and try again.');
@@ -364,7 +380,7 @@ async function whoami() {
364
380
  log('blue', 'Checking authentication...');
365
381
 
366
382
  try {
367
- const resp = await request('GET', '/api/v1/mags-jobs?page=1&page_size=1');
383
+ const resp = await request('GET', `${apiPath('mags-jobs')}?page=1&page_size=1`);
368
384
 
369
385
  if (resp.error === 'unauthorized' || resp.error === 'Unauthorized') {
370
386
  log('red', 'Token is invalid or expired.');
@@ -409,6 +425,7 @@ async function newVM(args) {
409
425
  let baseWorkspace = null;
410
426
  let diskGB = 0;
411
427
  let persistent = false;
428
+ let rootfsType = '';
412
429
 
413
430
  for (let i = 0; i < args.length; i++) {
414
431
  if (args[i] === '-p' || args[i] === '--persistent') {
@@ -417,6 +434,8 @@ async function newVM(args) {
417
434
  baseWorkspace = args[++i];
418
435
  } else if (args[i] === '--disk' && args[i + 1]) {
419
436
  diskGB = parseInt(args[++i]) || 0;
437
+ } else if ((args[i] === '--type' || args[i] === '-t') && args[i + 1]) {
438
+ rootfsType = args[++i];
420
439
  } else if (!name) {
421
440
  name = args[i];
422
441
  }
@@ -424,14 +443,15 @@ async function newVM(args) {
424
443
 
425
444
  if (!name) {
426
445
  log('red', 'Error: Name required');
427
- console.log(`\nUsage: mags new <name> [-p] [--base <workspace>] [--disk <GB>]`);
428
- console.log(` -p, --persistent Enable S3 data persistence\n`);
446
+ console.log(`\nUsage: mags new <name> [-p] [--base <workspace>] [--disk <GB>] [--type <type>]`);
447
+ console.log(` -p, --persistent Enable S3 data persistence`);
448
+ console.log(` -t, --type VM type: standard (default), claude, pi\n`);
429
449
  process.exit(1);
430
450
  }
431
451
 
432
452
  // Check if a VM with this name already exists (running or sleeping)
433
453
  try {
434
- const jobs = await request('GET', '/api/v1/mags-jobs');
454
+ const jobs = await request('GET', apiPath('mags-jobs'));
435
455
  const existing = (jobs.jobs || []).find(j =>
436
456
  j.name === name && (j.status === 'running' || j.status === 'sleeping')
437
457
  );
@@ -452,13 +472,13 @@ async function newVM(args) {
452
472
  persistent: true,
453
473
  name: name,
454
474
  workspace_id: name,
455
- startup_command: 'sleep infinity',
456
- no_sync: !persistent
475
+ startup_command: 'sleep infinity'
457
476
  };
458
477
  if (baseWorkspace) payload.base_workspace_id = baseWorkspace;
459
478
  if (diskGB) payload.disk_gb = diskGB;
479
+ if (rootfsType) payload.rootfs_type = rootfsType;
460
480
 
461
- const response = await request('POST', '/api/v1/mags-jobs', payload);
481
+ const response = await request('POST', apiPath('mags-jobs'), payload);
462
482
 
463
483
  if (!response.request_id) {
464
484
  log('red', 'Failed to create VM:');
@@ -471,7 +491,7 @@ async function newVM(args) {
471
491
  let attempt = 0;
472
492
 
473
493
  while (attempt < maxAttempts) {
474
- const status = await request('GET', `/api/v1/mags-jobs/${response.request_id}/status`);
494
+ const status = await request('GET', `${apiPath('mags-jobs')}/${response.request_id}/status`);
475
495
 
476
496
  if (status.status === 'running') {
477
497
  log('green', `VM '${name}' created successfully${persistent ? ' (persistent)' : ' (local disk)'}`);
@@ -506,6 +526,7 @@ async function runJob(args) {
506
526
  let startupCommand = '';
507
527
  let diskGB = 0;
508
528
  let fileArgs = [];
529
+ let rootfsType = '';
509
530
 
510
531
  // Parse flags
511
532
  for (let i = 0; i < args.length; i++) {
@@ -546,6 +567,10 @@ async function runJob(args) {
546
567
  case '--startup-command':
547
568
  startupCommand = args[++i];
548
569
  break;
570
+ case '--type':
571
+ case '-t':
572
+ rootfsType = args[++i];
573
+ break;
549
574
  default:
550
575
  script = args.slice(i).join(' ');
551
576
  i = args.length;
@@ -608,8 +633,9 @@ async function runJob(args) {
608
633
  if (startupCommand) payload.startup_command = startupCommand;
609
634
  if (fileIds.length > 0) payload.file_ids = fileIds;
610
635
  if (diskGB) payload.disk_gb = diskGB;
636
+ if (rootfsType) payload.rootfs_type = rootfsType;
611
637
 
612
- const response = await request('POST', '/api/v1/mags-jobs', payload);
638
+ const response = await request('POST', apiPath('mags-jobs'), payload);
613
639
 
614
640
  if (!response.request_id) {
615
641
  log('red', 'Failed to submit job:');
@@ -628,7 +654,7 @@ async function runJob(args) {
628
654
  let attempt = 0;
629
655
 
630
656
  while (attempt < maxAttempts) {
631
- const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
657
+ const status = await request('GET', `${apiPath('mags-jobs')}/${requestId}/status`);
632
658
 
633
659
  if (status.status === 'completed') {
634
660
  log('green', `Completed in ${status.script_duration_ms}ms`);
@@ -646,7 +672,7 @@ async function runJob(args) {
646
672
 
647
673
  if (enableUrl && status.subdomain) {
648
674
  log('blue', `Enabling URL access on port ${port}...`);
649
- const accessResp = await request('POST', `/api/v1/mags-jobs/${requestId}/access`, { port });
675
+ const accessResp = await request('POST', `${apiPath('mags-jobs')}/${requestId}/access`, { port });
650
676
  if (accessResp.success) {
651
677
  log('green', `URL: https://${status.subdomain}.apps.magpiecloud.com`);
652
678
  } else {
@@ -669,7 +695,7 @@ async function runJob(args) {
669
695
 
670
696
  // Get logs
671
697
  log('cyan', 'Output:');
672
- const logsResp = await request('GET', `/api/v1/mags-jobs/${requestId}/logs`);
698
+ const logsResp = await request('GET', `${apiPath('mags-jobs')}/${requestId}/logs`);
673
699
  if (logsResp.logs) {
674
700
  logsResp.logs
675
701
  .filter(l => l.source === 'stdout' || l.source === 'stderr')
@@ -686,10 +712,10 @@ async function enableUrlAccess(nameOrId, port = 8080) {
686
712
  const requestId = await resolveJobId(nameOrId);
687
713
  log('blue', `Enabling URL access on port ${port}...`);
688
714
 
689
- const accessResp = await request('POST', `/api/v1/mags-jobs/${requestId}/access`, { port });
715
+ const accessResp = await request('POST', `${apiPath('mags-jobs')}/${requestId}/access`, { port });
690
716
 
691
717
  if (accessResp.success) {
692
- const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
718
+ const status = await request('GET', `${apiPath('mags-jobs')}/${requestId}/status`);
693
719
  if (status.subdomain) {
694
720
  log('green', `URL enabled: https://${status.subdomain}.apps.magpiecloud.com`);
695
721
  } else {
@@ -748,7 +774,7 @@ async function urlAliasCreate(subdomain, workspaceId, useLfg) {
748
774
  const domain = useLfg ? 'app.lfg.run' : 'apps.magpiecloud.com';
749
775
  log('blue', `Creating URL alias: ${subdomain}.${domain} → workspace '${workspaceId}'...`);
750
776
 
751
- const resp = await request('POST', '/api/v1/mags-url-aliases', {
777
+ const resp = await request('POST', apiPath('mags-url-aliases'), {
752
778
  subdomain,
753
779
  workspace_id: workspaceId,
754
780
  domain,
@@ -767,7 +793,7 @@ async function urlAliasCreate(subdomain, workspaceId, useLfg) {
767
793
  }
768
794
 
769
795
  async function urlAliasList() {
770
- const resp = await request('GET', '/api/v1/mags-url-aliases');
796
+ const resp = await request('GET', apiPath('mags-url-aliases'));
771
797
  const aliases = resp.aliases || [];
772
798
 
773
799
  if (aliases.length > 0) {
@@ -786,7 +812,7 @@ async function urlAliasList() {
786
812
 
787
813
  async function urlAliasRemove(subdomain) {
788
814
  log('blue', `Removing URL alias '${subdomain}'...`);
789
- const resp = await request('DELETE', `/api/v1/mags-url-aliases/${subdomain}`);
815
+ const resp = await request('DELETE', `${apiPath('mags-url-aliases')}/${subdomain}`);
790
816
  if (resp.error) {
791
817
  log('red', `Error: ${resp.error}`);
792
818
  process.exit(1);
@@ -800,7 +826,7 @@ async function getStatus(nameOrId) {
800
826
  usage();
801
827
  }
802
828
  const requestId = await resolveJobId(nameOrId);
803
- const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
829
+ const status = await request('GET', `${apiPath('mags-jobs')}/${requestId}/status`);
804
830
  console.log(JSON.stringify(status, null, 2));
805
831
  }
806
832
 
@@ -810,7 +836,7 @@ async function getLogs(nameOrId) {
810
836
  usage();
811
837
  }
812
838
  const requestId = await resolveJobId(nameOrId);
813
- const logsResp = await request('GET', `/api/v1/mags-jobs/${requestId}/logs`);
839
+ const logsResp = await request('GET', `${apiPath('mags-jobs')}/${requestId}/logs`);
814
840
  if (logsResp.logs) {
815
841
  logsResp.logs.forEach(l => {
816
842
  const levelColor = l.level === 'error' ? 'red' : l.level === 'warn' ? 'yellow' : 'gray';
@@ -820,7 +846,7 @@ async function getLogs(nameOrId) {
820
846
  }
821
847
 
822
848
  async function listJobs() {
823
- const resp = await request('GET', '/api/v1/mags-jobs?page=1&page_size=10');
849
+ const resp = await request('GET', `${apiPath('mags-jobs')}?page=1&page_size=10`);
824
850
  if (resp.jobs && resp.jobs.length > 0) {
825
851
  log('cyan', 'Recent Jobs:\n');
826
852
  resp.jobs.forEach(job => {
@@ -848,7 +874,7 @@ async function stopJob(nameOrId) {
848
874
  }
849
875
  const requestId = await resolveJobId(nameOrId);
850
876
  log('blue', `Stopping job ${requestId}...`);
851
- const resp = await request('POST', `/api/v1/mags-jobs/${requestId}/stop`);
877
+ const resp = await request('POST', `${apiPath('mags-jobs')}/${requestId}/stop`);
852
878
  if (resp.success) {
853
879
  log('green', 'Job stopped');
854
880
  } else {
@@ -890,7 +916,7 @@ async function setJobSettings(args) {
890
916
 
891
917
  const requestId = await resolveJobId(nameOrId);
892
918
  log('blue', `Updating settings for ${requestId}...`);
893
- const resp = await request('PATCH', `/api/v1/mags-jobs/${requestId}`, payload);
919
+ const resp = await request('PATCH', `${apiPath('mags-jobs')}/${requestId}`, payload);
894
920
  if (resp.success) {
895
921
  if (noSleep === true) log('green', 'VM set to never auto-sleep');
896
922
  if (noSleep === false) log('green', 'VM set to auto-sleep when idle');
@@ -923,10 +949,10 @@ async function resizeVM(args) {
923
949
  // Sync workspace before stopping (preserve files)
924
950
  if (existingJob.status === 'running') {
925
951
  log('blue', 'Syncing workspace before resize...');
926
- await request('POST', `/api/v1/mags-jobs/${existingJob.request_id}/sync`);
952
+ await request('POST', `${apiPath('mags-jobs')}/${existingJob.request_id}/sync`);
927
953
  }
928
954
  log('blue', `Stopping existing VM...`);
929
- await request('POST', `/api/v1/mags-jobs/${existingJob.request_id}/stop`);
955
+ await request('POST', `${apiPath('mags-jobs')}/${existingJob.request_id}/stop`);
930
956
  // Brief wait for the stop to complete
931
957
  await new Promise(r => setTimeout(r, 1000));
932
958
  }
@@ -943,7 +969,7 @@ async function resizeVM(args) {
943
969
  disk_gb: diskGB,
944
970
  };
945
971
 
946
- const response = await request('POST', '/api/v1/mags-jobs', payload);
972
+ const response = await request('POST', apiPath('mags-jobs'), payload);
947
973
  if (!response.request_id) {
948
974
  log('red', 'Failed to create VM:');
949
975
  console.log(JSON.stringify(response, null, 2));
@@ -978,7 +1004,7 @@ async function syncWorkspace(nameOrId) {
978
1004
  }
979
1005
 
980
1006
  log('blue', `Syncing workspace...`);
981
- const resp = await request('POST', `/api/v1/mags-jobs/${requestId}/sync`);
1007
+ const resp = await request('POST', `${apiPath('mags-jobs')}/${requestId}/sync`);
982
1008
  if (resp.success) {
983
1009
  log('green', 'Workspace synced to S3 successfully');
984
1010
  } else {
@@ -1098,7 +1124,7 @@ async function uploadFile(filePath) {
1098
1124
  const body = Buffer.concat([header, fileData, footer]);
1099
1125
 
1100
1126
  return new Promise((resolve, reject) => {
1101
- const url = new URL('/api/v1/mags-files', API_URL);
1127
+ const url = new URL(apiPath('mags-files'), API_URL);
1102
1128
  const isHttps = url.protocol === 'https:';
1103
1129
  const lib = isHttps ? https : http;
1104
1130
 
@@ -1235,7 +1261,7 @@ async function cronAdd(args) {
1235
1261
  };
1236
1262
  if (workspace) payload.workspace_id = workspace;
1237
1263
 
1238
- const resp = await request('POST', '/api/v1/mags-cron', payload);
1264
+ const resp = await request('POST', apiPath('mags-cron'), payload);
1239
1265
  if (resp.id) {
1240
1266
  log('green', `Cron job created: ${resp.id}`);
1241
1267
  log('blue', `Name: ${cronName}`);
@@ -1249,7 +1275,7 @@ async function cronAdd(args) {
1249
1275
  }
1250
1276
 
1251
1277
  async function cronList() {
1252
- const resp = await request('GET', '/api/v1/mags-cron');
1278
+ const resp = await request('GET', apiPath('mags-cron'));
1253
1279
  if (resp.cron_jobs && resp.cron_jobs.length > 0) {
1254
1280
  log('cyan', 'Cron Jobs:\n');
1255
1281
  resp.cron_jobs.forEach(cron => {
@@ -1271,7 +1297,7 @@ async function cronList() {
1271
1297
  }
1272
1298
 
1273
1299
  async function cronRemove(id) {
1274
- const resp = await request('DELETE', `/api/v1/mags-cron/${id}`);
1300
+ const resp = await request('DELETE', `${apiPath('mags-cron')}/${id}`);
1275
1301
  if (resp.success) {
1276
1302
  log('green', 'Cron job deleted');
1277
1303
  } else {
@@ -1281,7 +1307,7 @@ async function cronRemove(id) {
1281
1307
  }
1282
1308
 
1283
1309
  async function cronToggle(id, enabled) {
1284
- const resp = await request('PATCH', `/api/v1/mags-cron/${id}`, { enabled });
1310
+ const resp = await request('PATCH', `${apiPath('mags-cron')}/${id}`, { enabled });
1285
1311
  if (resp.id) {
1286
1312
  log('green', `Cron job ${enabled ? 'enabled' : 'disabled'}`);
1287
1313
  if (resp.next_run_at) log('blue', `Next run: ${resp.next_run_at}`);
@@ -1328,7 +1354,7 @@ async function workspaceCommand(args) {
1328
1354
  }
1329
1355
 
1330
1356
  async function workspaceList() {
1331
- const resp = await request('GET', '/api/v1/mags-workspaces');
1357
+ const resp = await request('GET', apiPath('mags-workspaces'));
1332
1358
  if (resp.workspaces && resp.workspaces.length > 0) {
1333
1359
  log('cyan', 'Workspaces:\n');
1334
1360
  resp.workspaces.forEach(ws => {
@@ -1346,7 +1372,7 @@ async function workspaceList() {
1346
1372
 
1347
1373
  async function workspaceDelete(workspaceId) {
1348
1374
  log('blue', `Deleting workspace '${workspaceId}'...`);
1349
- const resp = await request('DELETE', `/api/v1/mags-workspaces/${workspaceId}`);
1375
+ const resp = await request('DELETE', `${apiPath('mags-workspaces')}/${workspaceId}`);
1350
1376
  if (resp.success) {
1351
1377
  log('green', resp.message || `Workspace '${workspaceId}' deleted`);
1352
1378
  } else {
@@ -1377,7 +1403,7 @@ async function browserSession(args) {
1377
1403
 
1378
1404
  if (existingJob && existingJob.status === 'running') {
1379
1405
  // Check if it's already a browser session
1380
- const status = await request('GET', `/api/v1/mags-jobs/${existingJob.request_id}/status`);
1406
+ const status = await request('GET', `${apiPath('mags-jobs')}/${existingJob.request_id}/status`);
1381
1407
  if (status.browser_mode && status.debug_ws_url) {
1382
1408
  log('green', 'Found existing browser session');
1383
1409
  console.log('');
@@ -1405,7 +1431,7 @@ async function browserSession(args) {
1405
1431
  if (name) payload.name = name;
1406
1432
  if (!name && workspace) payload.name = `browser-${workspace}`;
1407
1433
 
1408
- const response = await request('POST', '/api/v1/mags-jobs', payload);
1434
+ const response = await request('POST', apiPath('mags-jobs'), payload);
1409
1435
 
1410
1436
  if (!response.request_id) {
1411
1437
  log('red', 'Failed to start browser session:');
@@ -1420,7 +1446,7 @@ async function browserSession(args) {
1420
1446
  log('blue', 'Waiting for Chromium to start...');
1421
1447
  let status;
1422
1448
  for (let i = 0; i < 90; i++) {
1423
- status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
1449
+ status = await request('GET', `${apiPath('mags-jobs')}/${requestId}/status`);
1424
1450
  if (status.status === 'running' && status.debug_ws_url) {
1425
1451
  break;
1426
1452
  }
@@ -1469,7 +1495,7 @@ async function browserSession(args) {
1469
1495
  // WebSocket tunnel proxy — pipes stdin/stdout through a WebSocket to the agent SSH proxy.
1470
1496
  // Used as SSH ProxyCommand: ssh -o ProxyCommand="mags proxy <jobID>" root@mags-vm
1471
1497
  async function proxyTunnel(jobID) {
1472
- const url = new URL(`/api/v1/mags-jobs/${jobID}/tunnel`, API_URL);
1498
+ const url = new URL(`${apiPath('mags-jobs')}/${jobID}/tunnel`, API_URL);
1473
1499
  const wsUrl = url.toString().replace(/^http/, 'ws');
1474
1500
 
1475
1501
  // Dynamic import for WebSocket
@@ -1508,6 +1534,11 @@ async function proxyTunnel(jobID) {
1508
1534
 
1509
1535
  ws.on('open', () => {
1510
1536
  // Don't flush yet — wait for first message (SSH key)
1537
+ // Send pings every 30s to keep the connection alive through proxies/CDNs
1538
+ const pingInterval = setInterval(() => {
1539
+ if (ws.readyState === WebSocket.OPEN) ws.ping();
1540
+ }, 30000);
1541
+ ws.on('close', () => clearInterval(pingInterval));
1511
1542
  });
1512
1543
 
1513
1544
  ws.on('message', (data, isBinary) => {
@@ -1547,7 +1578,7 @@ async function proxyTunnel(jobID) {
1547
1578
  // Fetch SSH key from tunnel endpoint (quick WS handshake)
1548
1579
  async function fetchTunnelKey(jobID) {
1549
1580
  const WebSocket = require('ws');
1550
- const url = new URL(`/api/v1/mags-jobs/${jobID}/tunnel`, API_URL);
1581
+ const url = new URL(`${apiPath('mags-jobs')}/${jobID}/tunnel`, API_URL);
1551
1582
  const wsUrl = url.toString().replace(/^http/, 'ws');
1552
1583
 
1553
1584
  return new Promise((resolve, reject) => {
@@ -1607,7 +1638,7 @@ async function resolveOrCreateJob(nameOrId) {
1607
1638
  startup_command: 'sleep 3600'
1608
1639
  };
1609
1640
 
1610
- const response = await request('POST', '/api/v1/mags-jobs', payload);
1641
+ const response = await request('POST', apiPath('mags-jobs'), payload);
1611
1642
  if (!response.request_id) {
1612
1643
  log('red', 'Failed to start VM:');
1613
1644
  console.log(JSON.stringify(response, null, 2));
@@ -1619,7 +1650,7 @@ async function resolveOrCreateJob(nameOrId) {
1619
1650
 
1620
1651
  log('blue', 'Waiting for VM...');
1621
1652
  for (let i = 0; i < 60; i++) {
1622
- const status = await request('GET', `/api/v1/mags-jobs/${jobID}/status`);
1653
+ const status = await request('GET', `${apiPath('mags-jobs')}/${jobID}/status`);
1623
1654
  if (status.status === 'running' && status.vm_id) break;
1624
1655
  if (status.status === 'error') {
1625
1656
  log('red', 'VM failed to start');
@@ -1634,7 +1665,7 @@ async function resolveOrCreateJob(nameOrId) {
1634
1665
 
1635
1666
  // Get SSH key for a job (calls EnableAccess to set up agent proxy and get key)
1636
1667
  async function getSSHKey(jobID) {
1637
- const accessResp = await request('POST', `/api/v1/mags-jobs/${jobID}/access`, { port: 22 });
1668
+ const accessResp = await request('POST', `${apiPath('mags-jobs')}/${jobID}/access`, { port: 22 });
1638
1669
  if (!accessResp.success) {
1639
1670
  log('red', 'Failed to enable SSH access');
1640
1671
  if (accessResp.error) log('red', accessResp.error);
@@ -1672,6 +1703,8 @@ async function sshToJob(nameOrId) {
1672
1703
  '-o', 'StrictHostKeyChecking=no',
1673
1704
  '-o', 'UserKnownHostsFile=/dev/null',
1674
1705
  '-o', 'LogLevel=ERROR',
1706
+ '-o', 'ServerAliveInterval=15',
1707
+ '-o', 'ServerAliveCountMax=4',
1675
1708
  '-o', `ProxyCommand=${magsPath} proxy ${jobID}`,
1676
1709
  '-i', keyFile,
1677
1710
  'root@mags-vm',
@@ -1739,6 +1772,8 @@ async function execOnJob(nameOrId, command) {
1739
1772
  '-o', 'StrictHostKeyChecking=no',
1740
1773
  '-o', 'UserKnownHostsFile=/dev/null',
1741
1774
  '-o', 'LogLevel=ERROR',
1775
+ '-o', 'ServerAliveInterval=15',
1776
+ '-o', 'ServerAliveCountMax=4',
1742
1777
  '-o', `ProxyCommand=${magsPath} proxy ${jobID}`,
1743
1778
  '-i', keyFile,
1744
1779
  ];
package/deploy-page.sh ADDED
@@ -0,0 +1,171 @@
1
+ #!/bin/bash
2
+ # Deploy an HTML page to a public Mags URL
3
+ # Usage: ./deploy-page.sh <html-file-or-content> [name]
4
+
5
+ set -e
6
+
7
+ MAGS_API_URL="${MAGS_API_URL:-https://api.magpiecloud.com}"
8
+ MAGS_API_TOKEN="${MAGS_API_TOKEN:-}"
9
+
10
+ # Colors
11
+ RED='\033[0;31m'
12
+ GREEN='\033[0;32m'
13
+ YELLOW='\033[1;33m'
14
+ BLUE='\033[0;34m'
15
+ CYAN='\033[0;36m'
16
+ NC='\033[0m'
17
+
18
+ # Load .env if exists
19
+ if [ -f ".env" ]; then
20
+ export $(grep -v '^#' .env | xargs)
21
+ fi
22
+
23
+ if [ -z "$MAGS_API_TOKEN" ]; then
24
+ echo -e "${RED}Error: MAGS_API_TOKEN not set${NC}"
25
+ echo "Set it via: export MAGS_API_TOKEN=your-token"
26
+ exit 1
27
+ fi
28
+
29
+ usage() {
30
+ echo -e "${CYAN}Deploy HTML Page to Mags${NC}"
31
+ echo ""
32
+ echo "Usage: $0 <html-file-or-content> [name]"
33
+ echo ""
34
+ echo "Arguments:"
35
+ echo " html-file-or-content Path to HTML file, or HTML content directly"
36
+ echo " name Optional name for the deployment (default: page)"
37
+ echo ""
38
+ echo "Examples:"
39
+ echo " $0 index.html"
40
+ echo " $0 index.html my-site"
41
+ echo " $0 '<h1>Hello World</h1>'"
42
+ echo ""
43
+ echo "Environment:"
44
+ echo " MAGS_API_TOKEN Your API token (required)"
45
+ echo " MAGS_API_URL API URL (default: https://api.magpiecloud.com)"
46
+ exit 1
47
+ }
48
+
49
+ [ -z "$1" ] && usage
50
+
51
+ INPUT="$1"
52
+ NAME="${2:-page}"
53
+
54
+ # Check if input is a file or raw HTML
55
+ if [ -f "$INPUT" ]; then
56
+ echo -e "${BLUE}Reading HTML from file: $INPUT${NC}"
57
+ HTML_CONTENT=$(cat "$INPUT")
58
+ else
59
+ echo -e "${BLUE}Using provided HTML content${NC}"
60
+ HTML_CONTENT="$INPUT"
61
+ fi
62
+
63
+ # Escape the HTML for JSON
64
+ HTML_ESCAPED=$(echo "$HTML_CONTENT" | jq -Rs .)
65
+
66
+ # Create the script that will serve the HTML
67
+ SCRIPT=$(cat << 'SCRIPT_EOF'
68
+ #!/bin/sh
69
+ # Create the HTML file
70
+ cat > /tmp/index.html << 'HTML_CONTENT_MARKER'
71
+ __HTML_PLACEHOLDER__
72
+ HTML_CONTENT_MARKER
73
+
74
+ # Start Python HTTP server on port 8080
75
+ cd /tmp
76
+ echo "Server running on port 8080"
77
+ python3 -m http.server 8080
78
+ SCRIPT_EOF
79
+ )
80
+
81
+ # Replace placeholder with actual HTML
82
+ SCRIPT=$(echo "$SCRIPT" | sed "s|__HTML_PLACEHOLDER__|$(echo "$HTML_CONTENT" | sed 's/|/\\|/g; s/&/\\&/g')|")
83
+
84
+ echo -e "${BLUE}Deploying page...${NC}"
85
+
86
+ # Submit the persistent job
87
+ RESPONSE=$(curl -s -X POST "${MAGS_API_URL}/api/v1/mags-jobs" \
88
+ -H "Authorization: Bearer $MAGS_API_TOKEN" \
89
+ -H "Content-Type: application/json" \
90
+ -d "$(jq -n --arg script "$SCRIPT" --arg name "$NAME" '{
91
+ script: $script,
92
+ type: "inline",
93
+ name: $name,
94
+ persistent: true
95
+ }')")
96
+
97
+ REQUEST_ID=$(echo "$RESPONSE" | jq -r '.request_id // empty')
98
+
99
+ if [ -z "$REQUEST_ID" ]; then
100
+ echo -e "${RED}Failed to deploy:${NC}"
101
+ echo "$RESPONSE" | jq .
102
+ exit 1
103
+ fi
104
+
105
+ echo -e "${GREEN}Deployment started: $REQUEST_ID${NC}"
106
+
107
+ # Wait for job to be running
108
+ echo -e "${BLUE}Waiting for VM to start...${NC}"
109
+ MAX_ATTEMPTS=30
110
+ ATTEMPT=0
111
+
112
+ while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
113
+ STATUS_RESP=$(curl -s -X GET "${MAGS_API_URL}/api/v1/mags-jobs/${REQUEST_ID}/status" \
114
+ -H "Authorization: Bearer $MAGS_API_TOKEN")
115
+
116
+ STATUS=$(echo "$STATUS_RESP" | jq -r '.status // "unknown"')
117
+ SUBDOMAIN=$(echo "$STATUS_RESP" | jq -r '.subdomain // empty')
118
+
119
+ if [ "$STATUS" = "error" ]; then
120
+ echo -e "${RED}Deployment failed!${NC}"
121
+ echo "$STATUS_RESP" | jq .
122
+ exit 1
123
+ fi
124
+
125
+ if [ "$STATUS" = "running" ]; then
126
+ echo -e " ${GREEN}VM running${NC}"
127
+ break
128
+ fi
129
+
130
+ printf "."
131
+ sleep 1
132
+ ATTEMPT=$((ATTEMPT + 1))
133
+ done
134
+
135
+ if [ "$STATUS" != "running" ]; then
136
+ echo -e "${RED}Timed out waiting for VM to start${NC}"
137
+ exit 1
138
+ fi
139
+
140
+ # Enable URL access
141
+ echo -e "${BLUE}Enabling URL access...${NC}"
142
+ ACCESS_RESP=$(curl -s -X POST "${MAGS_API_URL}/api/v1/mags-jobs/${REQUEST_ID}/access" \
143
+ -H "Authorization: Bearer $MAGS_API_TOKEN" \
144
+ -H "Content-Type: application/json" \
145
+ -d '{"access_type": "url", "port": 8080}')
146
+
147
+ URL=$(echo "$ACCESS_RESP" | jq -r '.url // empty')
148
+ SUCCESS=$(echo "$ACCESS_RESP" | jq -r '.success // false')
149
+
150
+ if [ "$SUCCESS" != "true" ] || [ -z "$URL" ]; then
151
+ echo -e "${YELLOW}Warning: Could not get URL from access endpoint${NC}"
152
+ echo "$ACCESS_RESP" | jq .
153
+ # Fallback: construct URL from subdomain
154
+ URL="https://${SUBDOMAIN}.apps.magpiecloud.com"
155
+ fi
156
+
157
+ echo ""
158
+ echo -e "${GREEN}═══════════════════════════════════════════════════════════${NC}"
159
+ echo -e "${GREEN} Page Deployed Successfully!${NC}"
160
+ echo -e "${GREEN}═══════════════════════════════════════════════════════════${NC}"
161
+ echo ""
162
+ echo -e " ${CYAN}URL:${NC} ${GREEN}${URL}${NC}"
163
+ echo -e " ${CYAN}Subdomain:${NC} ${SUBDOMAIN}"
164
+ echo -e " ${CYAN}Request ID:${NC} ${REQUEST_ID}"
165
+ echo ""
166
+ echo -e "${YELLOW}Note: The VM will sleep after idle timeout and wake on next request.${NC}"
167
+ echo ""
168
+ echo "To check status:"
169
+ echo " curl -s -H 'Authorization: Bearer \$MAGS_API_TOKEN' \\"
170
+ echo " '${MAGS_API_URL}/api/v1/mags-jobs/${REQUEST_ID}/status' | jq ."
171
+ echo ""
package/index.js CHANGED
@@ -103,7 +103,6 @@ class Mags {
103
103
  * @param {object} options.environment - Environment variables
104
104
  * @param {string[]} options.fileIds - File IDs from uploadFiles()
105
105
  * @param {number} options.diskGb - Custom disk size in GB (default 2)
106
- * @param {string} options.rootfsType - VM rootfs type: standard, claude, pi
107
106
  * @returns {Promise<{request_id: string, status: string}>}
108
107
  */
109
108
  async run(script, options = {}) {
@@ -132,7 +131,6 @@ class Mags {
132
131
  if (options.environment) payload.environment = options.environment;
133
132
  if (options.fileIds && options.fileIds.length > 0) payload.file_ids = options.fileIds;
134
133
  if (options.diskGb) payload.disk_gb = options.diskGb;
135
- if (options.rootfsType) payload.rootfs_type = options.rootfsType;
136
134
 
137
135
  return this._request('POST', '/api/v1/mags-jobs', payload);
138
136
  }
package/mags ADDED
Binary file