@magpiecloud/mags 1.8.1 → 1.8.3

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
@@ -208,6 +208,7 @@ ${colors.bold}Commands:${colors.reset}
208
208
  list List recent jobs
209
209
  url <name|id> [port] Enable URL access for a job
210
210
  stop <name|id> Stop a running job
211
+ resize <workspace> --disk <GB> Resize a workspace's disk (restarts VM)
211
212
  sync <workspace|id> Sync workspace to S3 (without stopping)
212
213
  workspace list List persistent workspaces
213
214
  workspace delete <id> Delete a workspace and its S3 data
@@ -221,6 +222,7 @@ ${colors.bold}Run Options:${colors.reset}
221
222
  --base <workspace> Mount workspace read-only as base image
222
223
  -e, --ephemeral No workspace/S3 sync (fastest execution)
223
224
  -f, --file <path> Upload file(s) to VM (repeatable)
225
+ --disk <GB> Custom disk size in GB (default: 2)
224
226
  --url Enable public URL access (requires -p)
225
227
  --port <port> Port to expose for URL (default: 8080)
226
228
  --startup-command <cmd> Command to run when VM wakes from sleep
@@ -395,10 +397,13 @@ To use Mags, you need to authenticate first.
395
397
  async function newVM(args) {
396
398
  let name = null;
397
399
  let baseWorkspace = null;
400
+ let diskGB = 0;
398
401
 
399
402
  for (let i = 0; i < args.length; i++) {
400
403
  if (args[i] === '--base' && args[i + 1]) {
401
404
  baseWorkspace = args[++i];
405
+ } else if (args[i] === '--disk' && args[i + 1]) {
406
+ diskGB = parseInt(args[++i]) || 0;
402
407
  } else if (!name) {
403
408
  name = args[i];
404
409
  }
@@ -406,7 +411,7 @@ async function newVM(args) {
406
411
 
407
412
  if (!name) {
408
413
  log('red', 'Error: Name required');
409
- console.log(`\nUsage: mags new <name> [--base <workspace>]\n`);
414
+ console.log(`\nUsage: mags new <name> [--base <workspace>] [--disk <GB>]\n`);
410
415
  process.exit(1);
411
416
  }
412
417
 
@@ -419,6 +424,7 @@ async function newVM(args) {
419
424
  startup_command: 'sleep infinity'
420
425
  };
421
426
  if (baseWorkspace) payload.base_workspace_id = baseWorkspace;
427
+ if (diskGB) payload.disk_gb = diskGB;
422
428
 
423
429
  const response = await request('POST', '/api/v1/mags-jobs', payload);
424
430
 
@@ -463,6 +469,7 @@ async function runJob(args) {
463
469
  let enableUrl = false;
464
470
  let port = 8080;
465
471
  let startupCommand = '';
472
+ let diskGB = 0;
466
473
  let fileArgs = [];
467
474
 
468
475
  // Parse flags
@@ -500,6 +507,9 @@ async function runJob(args) {
500
507
  case '--port':
501
508
  port = parseInt(args[++i]) || 8080;
502
509
  break;
510
+ case '--disk':
511
+ diskGB = parseInt(args[++i]) || 0;
512
+ break;
503
513
  case '--startup-command':
504
514
  startupCommand = args[++i];
505
515
  break;
@@ -562,6 +572,7 @@ async function runJob(args) {
562
572
  if (baseWorkspace) payload.base_workspace_id = baseWorkspace;
563
573
  if (startupCommand) payload.startup_command = startupCommand;
564
574
  if (fileIds.length > 0) payload.file_ids = fileIds;
575
+ if (diskGB) payload.disk_gb = diskGB;
565
576
 
566
577
  const response = await request('POST', '/api/v1/mags-jobs', payload);
567
578
 
@@ -720,6 +731,61 @@ async function stopJob(nameOrId) {
720
731
  }
721
732
  }
722
733
 
734
+ async function resizeVM(args) {
735
+ let name = null;
736
+ let diskGB = 0;
737
+
738
+ for (let i = 0; i < args.length; i++) {
739
+ if (args[i] === '--disk' && args[i + 1]) {
740
+ diskGB = parseInt(args[++i]) || 0;
741
+ } else if (!name) {
742
+ name = args[i];
743
+ }
744
+ }
745
+
746
+ if (!name || !diskGB) {
747
+ log('red', 'Error: Workspace name and --disk <GB> required');
748
+ console.log('\nUsage: mags resize <workspace> --disk <GB>\n');
749
+ process.exit(1);
750
+ }
751
+
752
+ // Find existing job for this workspace
753
+ const existingJob = await findWorkspaceJob(name);
754
+ if (existingJob) {
755
+ // Sync workspace before stopping (preserve files)
756
+ if (existingJob.status === 'running') {
757
+ log('blue', 'Syncing workspace before resize...');
758
+ await request('POST', `/api/v1/mags-jobs/${existingJob.request_id}/sync`);
759
+ }
760
+ log('blue', `Stopping existing VM...`);
761
+ await request('POST', `/api/v1/mags-jobs/${existingJob.request_id}/stop`);
762
+ // Brief wait for the stop to complete
763
+ await new Promise(r => setTimeout(r, 1000));
764
+ }
765
+
766
+ // Create new VM with the same workspace name and new disk size
767
+ log('blue', `Creating new VM with ${diskGB}GB disk...`);
768
+ const payload = {
769
+ script: 'sleep infinity',
770
+ type: 'inline',
771
+ persistent: true,
772
+ name: name,
773
+ workspace_id: name,
774
+ startup_command: 'sleep infinity',
775
+ disk_gb: diskGB,
776
+ };
777
+
778
+ const response = await request('POST', '/api/v1/mags-jobs', payload);
779
+ if (!response.request_id) {
780
+ log('red', 'Failed to create VM:');
781
+ console.log(JSON.stringify(response, null, 2));
782
+ process.exit(1);
783
+ }
784
+
785
+ log('green', `Resized '${name}' to ${diskGB}GB disk`);
786
+ log('gray', `Job: ${response.request_id}`);
787
+ }
788
+
723
789
  async function syncWorkspace(nameOrId) {
724
790
  if (!nameOrId) {
725
791
  log('red', 'Error: Workspace name or job ID required');
@@ -1356,7 +1422,7 @@ async function main() {
1356
1422
  break;
1357
1423
  case '--version':
1358
1424
  case '-v':
1359
- console.log('mags v1.8.1');
1425
+ console.log('mags v1.8.3');
1360
1426
  process.exit(0);
1361
1427
  break;
1362
1428
  case 'new':
@@ -1395,6 +1461,10 @@ async function main() {
1395
1461
  await requireAuth();
1396
1462
  await stopJob(args[1]);
1397
1463
  break;
1464
+ case 'resize':
1465
+ await requireAuth();
1466
+ await resizeVM(args.slice(1));
1467
+ break;
1398
1468
  case 'sync':
1399
1469
  await requireAuth();
1400
1470
  await syncWorkspace(args[1]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpiecloud/mags",
3
- "version": "1.8.1",
3
+ "version": "1.8.3",
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.1.0"
7
+ version = "1.3.0"
8
8
  description = "Mags SDK - Execute scripts on Magpie's instant VM infrastructure"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: magpie-mags
3
- Version: 1.1.0
3
+ Version: 1.3.0
4
4
  Summary: Mags SDK - Execute scripts on Magpie's instant VM infrastructure
5
5
  Author: Magpie Cloud
6
6
  License: MIT
@@ -3,4 +3,4 @@
3
3
  from .client import Mags
4
4
 
5
5
  __all__ = ["Mags"]
6
- __version__ = "1.1.0"
6
+ __version__ = "1.3.0"
@@ -3,6 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import subprocess
7
+ import tempfile
6
8
  import time
7
9
  from pathlib import Path
8
10
  from typing import Any, Dict, List, Optional
@@ -103,6 +105,7 @@ class Mags:
103
105
  startup_command: str | None = None,
104
106
  environment: Dict[str, str] | None = None,
105
107
  file_ids: List[str] | None = None,
108
+ disk_gb: int | None = None,
106
109
  ) -> dict:
107
110
  """Submit a job for execution.
108
111
 
@@ -134,6 +137,8 @@ class Mags:
134
137
  payload["environment"] = environment
135
138
  if file_ids:
136
139
  payload["file_ids"] = file_ids
140
+ if disk_gb:
141
+ payload["disk_gb"] = disk_gb
137
142
 
138
143
  return self._request("POST", "/mags-jobs", json=payload)
139
144
 
@@ -198,12 +203,220 @@ class Mags:
198
203
  "POST", f"/mags-jobs/{request_id}/access", json={"port": port}
199
204
  )
200
205
 
206
+ def stop(self, name_or_id: str) -> dict:
207
+ """Stop a running job.
208
+
209
+ Accepts a job ID, job name, or workspace ID.
210
+ """
211
+ request_id = self._resolve_job_id(name_or_id)
212
+ return self._request("POST", f"/mags-jobs/{request_id}/stop")
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
+
238
+ def new(
239
+ self,
240
+ name: str,
241
+ *,
242
+ base_workspace_id: str | None = None,
243
+ disk_gb: int | None = None,
244
+ timeout: float = 30.0,
245
+ poll_interval: float = 1.0,
246
+ ) -> dict:
247
+ """Create a new persistent VM workspace and wait until it's running.
248
+
249
+ Equivalent to ``mags new <name>``.
250
+
251
+ Returns ``{"request_id": ..., "status": "running"}``.
252
+ """
253
+ result = self.run(
254
+ "sleep infinity",
255
+ workspace_id=name,
256
+ persistent=True,
257
+ base_workspace_id=base_workspace_id,
258
+ disk_gb=disk_gb,
259
+ )
260
+ request_id = result["request_id"]
261
+
262
+ deadline = time.monotonic() + timeout
263
+ while time.monotonic() < deadline:
264
+ st = self.status(request_id)
265
+ if st["status"] == "running" and st.get("vm_id"):
266
+ return {"request_id": request_id, "status": "running"}
267
+ if st["status"] in ("completed", "error"):
268
+ raise MagsError(f"Job {request_id} ended unexpectedly: {st['status']}")
269
+ time.sleep(poll_interval)
270
+
271
+ raise MagsError(f"Job {request_id} did not start within {timeout}s")
272
+
273
+ def find_job(self, name_or_id: str) -> dict | None:
274
+ """Find a running or sleeping job by name, workspace ID, or job ID.
275
+
276
+ Uses the same resolution priority as the CLI:
277
+ running/sleeping exact name → workspace ID → any status exact name.
278
+ Returns the job dict or ``None``.
279
+ """
280
+ jobs = self.list_jobs(page_size=50).get("jobs", [])
281
+
282
+ # Priority 1: exact name match, running/sleeping
283
+ for j in jobs:
284
+ if j.get("name") == name_or_id and j.get("status") in ("running", "sleeping"):
285
+ return j
286
+
287
+ # Priority 2: workspace_id match, running/sleeping
288
+ for j in jobs:
289
+ if j.get("workspace_id") == name_or_id and j.get("status") in ("running", "sleeping"):
290
+ return j
291
+
292
+ # Priority 3: exact name match, any status
293
+ for j in jobs:
294
+ if j.get("name") == name_or_id:
295
+ return j
296
+
297
+ # Priority 4: workspace_id match, any status
298
+ for j in jobs:
299
+ if j.get("workspace_id") == name_or_id:
300
+ return j
301
+
302
+ return None
303
+
304
+ def url(self, name_or_id: str, *, port: int = 8080) -> dict:
305
+ """Enable public URL access for a job's VM.
306
+
307
+ Accepts a job ID, job name, or workspace ID.
308
+ Returns dict with ``url`` and access details.
309
+ """
310
+ request_id = self._resolve_job_id(name_or_id)
311
+ st = self.status(request_id)
312
+ resp = self.enable_access(request_id, port=port)
313
+ subdomain = st.get("subdomain") or resp.get("subdomain")
314
+ if subdomain:
315
+ resp["url"] = f"https://{subdomain}.apps.magpiecloud.com"
316
+ return resp
317
+
318
+ def exec(self, name_or_id: str, command: str, *, timeout: int = 30) -> dict:
319
+ """Execute a command on an existing running/sleeping sandbox via SSH.
320
+
321
+ Equivalent to ``mags exec <workspace> '<command>'``.
322
+
323
+ Returns ``{"exit_code": int, "output": str}``.
324
+ """
325
+ job = self.find_job(name_or_id)
326
+ if not job:
327
+ raise MagsError(f"No running or sleeping VM found for '{name_or_id}'")
328
+ if job["status"] not in ("running", "sleeping"):
329
+ raise MagsError(
330
+ f"VM for '{name_or_id}' is {job['status']}, needs to be running or sleeping"
331
+ )
332
+
333
+ request_id = job.get("request_id") or job.get("id")
334
+
335
+ # Wait for VM to be assigned (status=running doesn't guarantee vm_id yet)
336
+ for _ in range(15):
337
+ st = self.status(request_id)
338
+ if st.get("vm_id"):
339
+ break
340
+ time.sleep(1)
341
+ else:
342
+ raise MagsError(f"VM for '{name_or_id}' has no vm_id after 15s")
343
+
344
+ access = self.enable_access(request_id, port=22)
345
+
346
+ if not access.get("success") or not access.get("ssh_host"):
347
+ raise MagsError(
348
+ f"Failed to enable SSH access: {access.get('error', 'unknown error')}"
349
+ )
350
+
351
+ ssh_host = access["ssh_host"]
352
+ ssh_port = str(access["ssh_port"])
353
+ ssh_key = access.get("ssh_private_key", "")
354
+
355
+ # Wrap command to handle chroot overlay, same as CLI
356
+ escaped = command.replace("'", "'\\''")
357
+ wrapped = (
358
+ f"if [ -d /overlay/bin ]; then "
359
+ f"chroot /overlay /bin/sh -l -c 'cd /root 2>/dev/null; {escaped}'; "
360
+ f"else cd /root 2>/dev/null; {escaped}; fi"
361
+ )
362
+
363
+ key_file = None
364
+ try:
365
+ ssh_args = [
366
+ "ssh",
367
+ "-o", "StrictHostKeyChecking=no",
368
+ "-o", "UserKnownHostsFile=/dev/null",
369
+ "-o", "LogLevel=ERROR",
370
+ "-p", ssh_port,
371
+ ]
372
+ if ssh_key:
373
+ fd, key_file = tempfile.mkstemp(prefix="mags_ssh_")
374
+ os.write(fd, ssh_key.encode())
375
+ os.close(fd)
376
+ os.chmod(key_file, 0o600)
377
+ ssh_args.extend(["-i", key_file])
378
+
379
+ ssh_args.append(f"root@{ssh_host}")
380
+ ssh_args.append(wrapped)
381
+
382
+ proc = subprocess.run(
383
+ ssh_args,
384
+ capture_output=True,
385
+ text=True,
386
+ timeout=timeout,
387
+ )
388
+ return {
389
+ "exit_code": proc.returncode,
390
+ "output": proc.stdout,
391
+ "stderr": proc.stderr,
392
+ }
393
+ except subprocess.TimeoutExpired:
394
+ raise MagsError(f"Command timed out after {timeout}s")
395
+ finally:
396
+ if key_file:
397
+ try:
398
+ os.unlink(key_file)
399
+ except OSError:
400
+ pass
401
+
201
402
  def usage(self, *, window_days: int = 30) -> dict:
202
403
  """Get aggregated usage summary."""
203
404
  return self._request(
204
405
  "GET", "/mags-jobs/usage", params={"window_days": window_days}
205
406
  )
206
407
 
408
+ # ── internal helpers ─────────────────────────────────────────────
409
+
410
+ def _resolve_job_id(self, name_or_id: str) -> str:
411
+ """Resolve a job name, workspace ID, or UUID to a request_id."""
412
+ # If it looks like a UUID, use directly
413
+ if len(name_or_id) >= 32 and "-" in name_or_id:
414
+ return name_or_id
415
+ job = self.find_job(name_or_id)
416
+ if not job:
417
+ raise MagsError(f"No job found for '{name_or_id}'")
418
+ return job.get("request_id") or job.get("id")
419
+
207
420
  # ── file uploads ─────────────────────────────────────────────────
208
421
 
209
422
  def upload_file(self, file_path: str) -> str:
@@ -0,0 +1,78 @@
1
+ """Test the Mags Python SDK: new, exec, url."""
2
+
3
+ import os
4
+ import sys
5
+ import time
6
+
7
+ os.environ["MAGS_API_TOKEN"] = "da214df72c164bda47970491fd839247a864c2599305ce90b38512d43ed034ea"
8
+
9
+ # Use local src/ instead of installed package
10
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
11
+
12
+ from mags import Mags
13
+
14
+ m = Mags()
15
+ WS = "sdk-test-" + str(int(time.time()))
16
+
17
+ print(f"=== Test 1: Create new VM ({WS}) ===")
18
+ result = m.new(WS)
19
+ print(f" request_id: {result['request_id']}")
20
+ print(f" status: {result['status']}")
21
+ assert result["status"] == "running"
22
+ print(" PASS\n")
23
+
24
+ print("=== Test 2: find_job ===")
25
+ job = m.find_job(WS)
26
+ print(f" found: workspace={job.get('workspace_id')} status={job.get('status')}")
27
+ assert job is not None
28
+ assert job["status"] == "running"
29
+ print(" PASS\n")
30
+
31
+ print("=== Test 3: exec - simple command ===")
32
+ try:
33
+ result = m.exec(WS, "echo HELLO_FROM_SDK && uname -a", timeout=15)
34
+ print(f" exit_code: {result['exit_code']}")
35
+ print(f" output: {result['output'].strip()}")
36
+ if result["stderr"]:
37
+ print(f" stderr: {result['stderr'].strip()}")
38
+ assert "HELLO_FROM_SDK" in result["output"]
39
+ print(" PASS\n")
40
+ except Exception as e:
41
+ print(f" FAIL: {e}\n")
42
+
43
+ print("=== Test 4: exec - create HTML file ===")
44
+ html = "<html><body><h1>Mags SDK Test</h1><p>Served from a microVM!</p></body></html>"
45
+ try:
46
+ result = m.exec(WS, f"echo '{html}' > /root/index.html", timeout=15)
47
+ print(f" exit_code: {result['exit_code']}")
48
+ result = m.exec(WS, "cat /root/index.html", timeout=15)
49
+ print(f" content: {result['output'].strip()[:80]}...")
50
+ print(" PASS\n")
51
+ except Exception as e:
52
+ print(f" FAIL: {e}\n")
53
+
54
+ print("=== Test 5: exec - start python HTTP server ===")
55
+ try:
56
+ result = m.exec(WS, "nohup python3 -m http.server 8080 --directory /root > /tmp/srv.log 2>&1 & echo PID=$!", timeout=15)
57
+ print(f" output: {result['output'].strip()}")
58
+ time.sleep(1)
59
+ # Verify it's running
60
+ result = m.exec(WS, "curl -s http://localhost:8080/index.html | head -1", timeout=15)
61
+ print(f" curl: {result['output'].strip()[:80]}")
62
+ print(" PASS\n")
63
+ except Exception as e:
64
+ print(f" FAIL: {e}\n")
65
+
66
+ print("=== Test 6: url - enable public URL ===")
67
+ try:
68
+ info = m.url(WS, port=8080)
69
+ print(f" success: {info.get('success')}")
70
+ print(f" url: {info.get('url', 'N/A')}")
71
+ if info.get("url"):
72
+ print(f"\n >>> Visit: {info['url']} <<<")
73
+ print(" PASS\n")
74
+ except Exception as e:
75
+ print(f" FAIL: {e}\n")
76
+
77
+ print("=== All tests complete ===")
78
+ print(f"Workspace: {WS}")
package/website/api.html CHANGED
@@ -360,6 +360,12 @@ m = Mags(api_token="your-token")
360
360
  <td>no</td>
361
361
  <td>File IDs from the upload endpoint. Files are downloaded into <code>/root/</code> before script runs.</td>
362
362
  </tr>
363
+ <tr>
364
+ <td><code>disk_gb</code></td>
365
+ <td>int</td>
366
+ <td>no</td>
367
+ <td>Custom disk size in GB. Default is 2GB. The rootfs is resized on-the-fly after VM boot.</td>
368
+ </tr>
363
369
  </tbody>
364
370
  </table>
365
371
 
@@ -200,6 +200,7 @@ mags login</code></pre>
200
200
  <tbody>
201
201
  <tr><td><code>mags run &lt;script&gt;</code></td><td>Execute a script in a fresh sandbox</td></tr>
202
202
  <tr><td><code>mags ssh &lt;workspace&gt;</code></td><td>SSH into a sandbox (auto-starts if needed)</td></tr>
203
+ <tr><td><code>mags exec &lt;workspace&gt; &lt;cmd&gt;</code></td><td>Run a command on an existing sandbox</td></tr>
203
204
  <tr><td><code>mags list</code></td><td>List recent jobs</td></tr>
204
205
  <tr><td><code>mags status &lt;id&gt;</code></td><td>Get job status</td></tr>
205
206
  <tr><td><code>mags logs &lt;id&gt;</code></td><td>Get job output</td></tr>
@@ -231,6 +232,7 @@ mags login</code></pre>
231
232
  <tr><td><code>-n, --name &lt;name&gt;</code></td><td>Name the job for easy reference</td></tr>
232
233
  <tr><td><code>--url</code></td><td>Enable public HTTPS URL (requires <code>-p</code>)</td></tr>
233
234
  <tr><td><code>--port &lt;port&gt;</code></td><td>Port to expose for URL (default: 8080)</td></tr>
235
+ <tr><td><code>--disk &lt;GB&gt;</code></td><td>Custom disk size in GB (default: 2)</td></tr>
234
236
  <tr><td><code>--startup-command &lt;cmd&gt;</code></td><td>Command to run when sandbox wakes from sleep</td></tr>
235
237
  </tbody>
236
238
  </table>
@@ -255,6 +257,9 @@ mags run --base golden -w fork-1 'npm test' # fork: load golden, save to for
255
257
  # SSH into a workspace (auto-starts sandbox if needed)
256
258
  mags ssh myproject
257
259
 
260
+ # Run a command on an existing sandbox
261
+ mags exec myproject 'node --version'
262
+
258
263
  # Persistent sandbox with public URL
259
264
  mags run -w webapp -p --url --port 8080 \
260
265
  --startup-command 'python3 -m http.server 8080' \
@@ -291,13 +296,19 @@ export MAGS_API_TOKEN="your-token"</code></pre>
291
296
  <tbody>
292
297
  <tr><td><code>run(script, **opts)</code></td><td>Submit a job (returns immediately)</td></tr>
293
298
  <tr><td><code>run_and_wait(script, **opts)</code></td><td>Submit + block until complete</td></tr>
299
+ <tr><td><code>new(name)</code></td><td>Create persistent VM workspace</td></tr>
300
+ <tr><td><code>exec(name, command)</code></td><td>Run command on existing sandbox</td></tr>
301
+ <tr><td><code>stop(name_or_id)</code></td><td>Stop a running job</td></tr>
302
+ <tr><td><code>find_job(name_or_id)</code></td><td>Find job by name or workspace</td></tr>
303
+ <tr><td><code>url(name_or_id, port)</code></td><td>Enable public URL access</td></tr>
294
304
  <tr><td><code>status(request_id)</code></td><td>Get job status</td></tr>
295
305
  <tr><td><code>logs(request_id)</code></td><td>Get job logs</td></tr>
296
306
  <tr><td><code>list_jobs()</code></td><td>List recent jobs</td></tr>
297
- <tr><td><code>enable_access(id, port)</code></td><td>Enable URL or SSH access</td></tr>
307
+ <tr><td><code>enable_access(id, port)</code></td><td>Enable URL or SSH access (low-level)</td></tr>
298
308
  <tr><td><code>upload_files(paths)</code></td><td>Upload files, returns file IDs</td></tr>
299
309
  <tr><td><code>list_workspaces()</code></td><td>List persistent workspaces</td></tr>
300
310
  <tr><td><code>delete_workspace(id)</code></td><td>Delete workspace + cloud data</td></tr>
311
+ <tr><td><code>sync(request_id)</code></td><td>Sync workspace to S3 now</td></tr>
301
312
  <tr><td><code>cron_create(**opts)</code></td><td>Create a cron job</td></tr>
302
313
  <tr><td><code>cron_list()</code></td><td>List cron jobs</td></tr>
303
314
  <tr><td><code>cron_delete(id)</code></td><td>Delete a cron job</td></tr>
@@ -334,27 +345,28 @@ m = Mags() # reads MAGS_API_TOKEN from env
334
345
  result = m.run_and_wait("echo Hello World")
335
346
  print(result["status"]) # "completed"
336
347
 
337
- # Persistent workspace
338
- m.run_and_wait("pip install flask", workspace_id="myproject")
339
- m.run_and_wait("python3 app.py", workspace_id="myproject")
348
+ # Create a persistent workspace
349
+ m.new("my-project")
350
+
351
+ # Execute commands on existing sandbox
352
+ result = m.exec("my-project", "ls -la /root")
353
+ print(result["output"])
354
+ m.exec("my-project", "pip install flask")
340
355
 
341
- # Base image
342
- m.run_and_wait("npm test", base_workspace_id="golden")
343
- m.run_and_wait("npm test", base_workspace_id="golden", workspace_id="fork-1")
356
+ # Stop a job
357
+ m.stop("my-project")
344
358
 
345
- # SSH access
346
- job = m.run("sleep 3600", workspace_id="dev", persistent=True)
347
- ssh = m.enable_access(job["request_id"], port=22)
348
- print(f"ssh root@{ssh['ssh_host']} -p {ssh['ssh_port']}")
359
+ # Find a job by name or workspace
360
+ job = m.find_job("my-project")
361
+ print(job["status"]) # "running", "sleeping", etc.
349
362
 
350
363
  # Public URL
351
- job = m.run("python3 -m http.server 8080",
352
- workspace_id="webapp", persistent=True,
353
- startup_command="python3 -m http.server 8080")
354
- access = m.enable_access(job["request_id"], port=8080)
364
+ m.new("webapp")
365
+ info = m.url("webapp", port=3000)
366
+ print(info["url"]) # https://xyz.apps.magpiecloud.com
355
367
 
356
368
  # Always-on sandbox (never auto-sleeps)
357
- job = m.run("python3 worker.py",
369
+ m.run("python3 worker.py",
358
370
  workspace_id="worker", persistent=True, no_sleep=True)
359
371
 
360
372
  # Upload files
@@ -524,30 +536,30 @@ await mags.cronCreate({
524
536
 
525
537
  m = Mags() # reads MAGS_API_TOKEN from env
526
538
 
527
- # Run and wait
528
- result = m.run_and_wait(
529
- "echo Hello from a sandbox!",
530
- workspace_id="demo",
531
- )
532
- print(result["status"])
533
- for log in result["logs"]:
534
- print(log["message"])</code></pre>
539
+ # Create a workspace, run commands on it
540
+ m.new("demo")
541
+ result = m.exec("demo", "uname -a")
542
+ print(result["output"])
543
+
544
+ # Or run a one-shot script
545
+ result = m.run_and_wait("echo Hello!")
546
+ print(result["status"]) # "completed"</code></pre>
535
547
  </article>
536
548
  <article class="panel" data-reveal>
537
549
  <h3>Available methods</h3>
538
550
  <ul class="list">
539
551
  <li><code>run(script, **opts)</code> &mdash; submit a job</li>
540
552
  <li><code>run_and_wait(script, **opts)</code> &mdash; submit + block</li>
541
- <li><code>status(request_id)</code> &mdash; get job status</li>
542
- <li><code>logs(request_id)</code> &mdash; get job logs</li>
543
- <li><code>list_jobs()</code> &mdash; list recent jobs</li>
544
- <li><code>enable_access(id, port)</code> &mdash; URL or SSH</li>
553
+ <li><code>new(name)</code> &mdash; create persistent VM</li>
554
+ <li><code>exec(name, command)</code> &mdash; run on existing sandbox</li>
555
+ <li><code>stop(name_or_id)</code> &mdash; stop a job</li>
556
+ <li><code>find_job(name_or_id)</code> &mdash; find by name/workspace</li>
557
+ <li><code>url(name_or_id, port)</code> &mdash; enable public URL</li>
558
+ <li><code>status(id)</code> / <code>logs(id)</code> / <code>list_jobs()</code></li>
545
559
  <li><code>upload_files(paths)</code> &mdash; upload files</li>
546
- <li><code>list_workspaces()</code> &mdash; list workspaces</li>
547
- <li><code>delete_workspace(id)</code> &mdash; delete workspace</li>
548
- <li><code>cron_create(**opts)</code> &mdash; create cron</li>
549
- <li><code>cron_list()</code> / <code>cron_delete(id)</code></li>
550
- <li><code>usage(window_days)</code> &mdash; usage stats</li>
560
+ <li><code>list_workspaces()</code> / <code>delete_workspace(id)</code></li>
561
+ <li><code>sync(id)</code> &mdash; sync workspace to S3</li>
562
+ <li><code>cron_create(**opts)</code> / <code>cron_list()</code> / <code>cron_delete(id)</code></li>
551
563
  </ul>
552
564
  <p style="margin-top:1rem"><a class="text-link" href="https://pypi.org/project/magpie-mags/" rel="noreferrer">PyPI &rarr;</a></p>
553
565
  </article>
package/website/llms.txt CHANGED
@@ -29,6 +29,7 @@ mags run 'echo Hello World'
29
29
  - `mags new <workspace>` — Create a persistent VM workspace
30
30
  - `mags run <script>` — Execute a script on a microVM
31
31
  - `mags ssh <workspace>` — Open an SSH session into a workspace
32
+ - `mags exec <workspace> <command>` — Run a command on an existing sandbox
32
33
  - `mags url <job-id>` — Enable public URL access for a running job
33
34
  - `mags list` — List recent jobs
34
35
  - `mags logs <job-id>` — View job logs
@@ -42,10 +43,12 @@ mags run 'echo Hello World'
42
43
  | `-w, --workspace <id>` | Persist files with a named workspace |
43
44
  | `-n, --name <name>` | Job name for easier reference |
44
45
  | `-p, --persistent` | Keep VM alive after script for URL or SSH |
46
+ | `--no-sleep` | Never auto-sleep this VM (requires -p) |
45
47
  | `-e, --ephemeral` | No workspace, no S3 sync (fastest). Cannot combine with -w or -p |
46
48
  | `-f, --file <path>` | Upload a local file into /root/ in the VM (repeatable) |
47
49
  | `--url` | Enable public URL (requires -p) |
48
50
  | `--port <port>` | Port to expose (default: 8080) |
51
+ | `--disk <GB>` | Custom disk size in GB (default: 2) |
49
52
  | `--startup-command <cmd>` | Command to run when a VM wakes |
50
53
 
51
54
  ## File Upload
@@ -173,6 +176,48 @@ curl -X POST https://api.magpiecloud.com/api/v1/mags-jobs \
173
176
  }'
174
177
  ```
175
178
 
179
+ ## Python SDK
180
+
181
+ ```
182
+ pip install magpie-mags
183
+ ```
184
+
185
+ ```python
186
+ from mags import Mags
187
+
188
+ mags = Mags() # uses MAGS_API_TOKEN env var
189
+
190
+ # Run a script and wait for result
191
+ result = mags.run_and_wait('echo Hello World')
192
+ print(result['logs'])
193
+
194
+ # Create a persistent workspace
195
+ mags.new('my-project')
196
+
197
+ # Execute a command on an existing sandbox
198
+ result = mags.exec('my-project', 'ls -la /root')
199
+ print(result['output'])
200
+
201
+ # Stop a running job
202
+ mags.stop('my-project')
203
+
204
+ # Enable public URL
205
+ info = mags.url('my-project', port=3000)
206
+ print(info['url'])
207
+
208
+ # Find a job by name or workspace
209
+ job = mags.find_job('my-project')
210
+
211
+ # Upload files and run
212
+ file_ids = mags.upload_files(['script.py', 'data.csv'])
213
+ mags.run('python3 script.py', file_ids=file_ids)
214
+
215
+ # Cron jobs
216
+ mags.cron_create(name='daily-backup', cron_expression='0 0 * * *',
217
+ script='tar czf backup.tar.gz /data', workspace_id='my-ws')
218
+ mags.cron_delete(cron_id)
219
+ ```
220
+
176
221
  ## Node.js SDK
177
222
 
178
223
  ```
package/website/mags.md CHANGED
@@ -36,6 +36,11 @@ mags run -w <workspace> -p --url --port 3000 '<script>'
36
36
  mags new <name>
37
37
  ```
38
38
 
39
+ ### Execute a command on an existing sandbox
40
+ ```bash
41
+ mags exec <name-or-id> '<command>'
42
+ ```
43
+
39
44
  ### SSH into a running VM
40
45
  ```bash
41
46
  mags ssh <name-or-id>
@@ -92,10 +97,12 @@ mags cron disable <id>
92
97
  | `-w, --workspace <id>` | Persistent workspace (S3 sync) |
93
98
  | `-n, --name <name>` | Job name for easier reference |
94
99
  | `-p, --persistent` | Keep VM alive after script |
100
+ | `--no-sleep` | Never auto-sleep this VM (requires -p) |
95
101
  | `-e, --ephemeral` | No workspace, no S3 sync (fastest). Cannot combine with -w or -p |
96
102
  | `-f, --file <path>` | Upload a file into /root/ in the VM (repeatable) |
97
103
  | `--url` | Enable public URL (requires -p) |
98
104
  | `--port <port>` | Port to expose (default: 8080) |
105
+ | `--disk <GB>` | Custom disk size in GB (default: 2) |
99
106
  | `--startup-command <cmd>` | Command to run when VM wakes |
100
107
 
101
108
  ## Workflow
@@ -129,7 +136,19 @@ mags run -p --url 'python3 -m http.server 8080'
129
136
  ### Persistent workspace
130
137
  User: `/mags set up a node project in my-app workspace`
131
138
  ```bash
132
- mags run -w my-app 'apk add nodejs npm && mkdir -p /workspace/my-app && cd /workspace/my-app && npm init -y'
139
+ mags run -w my-app 'cd /root && npm init -y && npm install express'
140
+ ```
141
+
142
+ ### Run more commands on existing workspace
143
+ User: `/mags run my server in the my-app workspace`
144
+ ```bash
145
+ mags run -w my-app -p --url --port 3000 'cd /root && node index.js'
146
+ ```
147
+
148
+ ### Exec into existing workspace sandbox
149
+ User: `/mags install lodash in my-app`
150
+ ```bash
151
+ mags exec my-app 'cd /root && npm install lodash'
133
152
  ```
134
153
 
135
154
  ### Ephemeral (no workspace, fastest)
@@ -150,6 +169,18 @@ User: `/mags run my analysis with the data file`
150
169
  mags run -f analyze.py -f data.csv 'python3 analyze.py'
151
170
  ```
152
171
 
172
+ ### Run a command on an existing sandbox
173
+ User: `/mags check what's running on my-app`
174
+ ```bash
175
+ mags exec my-app 'ps aux'
176
+ ```
177
+
178
+ ### Keep a VM always running (never auto-sleep)
179
+ User: `/mags start a persistent server that never sleeps`
180
+ ```bash
181
+ mags run -w my-server -p --no-sleep --url --port 3000 'cd /root && node server.js'
182
+ ```
183
+
153
184
  ### Schedule a cron job
154
185
  User: `/mags schedule a health check every hour`
155
186
  ```bash
@@ -169,3 +200,4 @@ Do NOT hardcode tokens or use environment variables for auth.
169
200
  3. The CLI handles job submission, polling, and log retrieval automatically
170
201
  4. For multi-line scripts, use semicolons or `&&` to chain commands
171
202
  5. If the user wants to create a VM and SSH in, use `mags new <name>` then `mags ssh <name>`
203
+ 6. To run a command on an already-running sandbox, use `mags exec <name> '<command>'` instead of starting a new run