@magpiecloud/mags 1.8.0 → 1.8.2

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
@@ -221,6 +221,7 @@ ${colors.bold}Run Options:${colors.reset}
221
221
  --base <workspace> Mount workspace read-only as base image
222
222
  -e, --ephemeral No workspace/S3 sync (fastest execution)
223
223
  -f, --file <path> Upload file(s) to VM (repeatable)
224
+ --disk <GB> Custom disk size in GB (default: 2)
224
225
  --url Enable public URL access (requires -p)
225
226
  --port <port> Port to expose for URL (default: 8080)
226
227
  --startup-command <cmd> Command to run when VM wakes from sleep
@@ -395,10 +396,13 @@ To use Mags, you need to authenticate first.
395
396
  async function newVM(args) {
396
397
  let name = null;
397
398
  let baseWorkspace = null;
399
+ let diskGB = 0;
398
400
 
399
401
  for (let i = 0; i < args.length; i++) {
400
402
  if (args[i] === '--base' && args[i + 1]) {
401
403
  baseWorkspace = args[++i];
404
+ } else if (args[i] === '--disk' && args[i + 1]) {
405
+ diskGB = parseInt(args[++i]) || 0;
402
406
  } else if (!name) {
403
407
  name = args[i];
404
408
  }
@@ -406,7 +410,7 @@ async function newVM(args) {
406
410
 
407
411
  if (!name) {
408
412
  log('red', 'Error: Name required');
409
- console.log(`\nUsage: mags new <name> [--base <workspace>]\n`);
413
+ console.log(`\nUsage: mags new <name> [--base <workspace>] [--disk <GB>]\n`);
410
414
  process.exit(1);
411
415
  }
412
416
 
@@ -419,6 +423,7 @@ async function newVM(args) {
419
423
  startup_command: 'sleep infinity'
420
424
  };
421
425
  if (baseWorkspace) payload.base_workspace_id = baseWorkspace;
426
+ if (diskGB) payload.disk_gb = diskGB;
422
427
 
423
428
  const response = await request('POST', '/api/v1/mags-jobs', payload);
424
429
 
@@ -463,6 +468,7 @@ async function runJob(args) {
463
468
  let enableUrl = false;
464
469
  let port = 8080;
465
470
  let startupCommand = '';
471
+ let diskGB = 0;
466
472
  let fileArgs = [];
467
473
 
468
474
  // Parse flags
@@ -500,6 +506,9 @@ async function runJob(args) {
500
506
  case '--port':
501
507
  port = parseInt(args[++i]) || 8080;
502
508
  break;
509
+ case '--disk':
510
+ diskGB = parseInt(args[++i]) || 0;
511
+ break;
503
512
  case '--startup-command':
504
513
  startupCommand = args[++i];
505
514
  break;
@@ -562,6 +571,7 @@ async function runJob(args) {
562
571
  if (baseWorkspace) payload.base_workspace_id = baseWorkspace;
563
572
  if (startupCommand) payload.startup_command = startupCommand;
564
573
  if (fileIds.length > 0) payload.file_ids = fileIds;
574
+ if (diskGB) payload.disk_gb = diskGB;
565
575
 
566
576
  const response = await request('POST', '/api/v1/mags-jobs', payload);
567
577
 
@@ -589,6 +599,14 @@ async function runJob(args) {
589
599
  log('green', `Completed in ${status.script_duration_ms}ms`);
590
600
  break;
591
601
  } else if (status.status === 'running' && persistent) {
602
+ // If --url requested, wait until VM is actually assigned (vm_id populated)
603
+ if (enableUrl && !status.vm_id) {
604
+ process.stdout.write('.');
605
+ await sleep(1000);
606
+ attempt++;
607
+ continue;
608
+ }
609
+
592
610
  log('green', 'VM running');
593
611
 
594
612
  if (enableUrl && status.subdomain) {
@@ -597,7 +615,7 @@ async function runJob(args) {
597
615
  if (accessResp.success) {
598
616
  log('green', `URL: https://${status.subdomain}.apps.magpiecloud.com`);
599
617
  } else {
600
- log('yellow', 'Warning: Could not enable URL access');
618
+ log('yellow', `Warning: Could not enable URL access${accessResp.error ? ': ' + accessResp.error : ''}`);
601
619
  }
602
620
  }
603
621
  return;
@@ -1348,7 +1366,7 @@ async function main() {
1348
1366
  break;
1349
1367
  case '--version':
1350
1368
  case '-v':
1351
- console.log('mags v1.8.0');
1369
+ console.log('mags v1.8.2');
1352
1370
  process.exit(0);
1353
1371
  break;
1354
1372
  case 'new':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpiecloud/mags",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
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.0.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,196 @@ 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 new(
215
+ self,
216
+ name: str,
217
+ *,
218
+ base_workspace_id: str | None = None,
219
+ disk_gb: int | None = None,
220
+ timeout: float = 30.0,
221
+ poll_interval: float = 1.0,
222
+ ) -> dict:
223
+ """Create a new persistent VM workspace and wait until it's running.
224
+
225
+ Equivalent to ``mags new <name>``.
226
+
227
+ Returns ``{"request_id": ..., "status": "running"}``.
228
+ """
229
+ result = self.run(
230
+ "sleep infinity",
231
+ workspace_id=name,
232
+ persistent=True,
233
+ base_workspace_id=base_workspace_id,
234
+ disk_gb=disk_gb,
235
+ )
236
+ request_id = result["request_id"]
237
+
238
+ deadline = time.monotonic() + timeout
239
+ while time.monotonic() < deadline:
240
+ st = self.status(request_id)
241
+ if st["status"] == "running" and st.get("vm_id"):
242
+ return {"request_id": request_id, "status": "running"}
243
+ if st["status"] in ("completed", "error"):
244
+ raise MagsError(f"Job {request_id} ended unexpectedly: {st['status']}")
245
+ time.sleep(poll_interval)
246
+
247
+ raise MagsError(f"Job {request_id} did not start within {timeout}s")
248
+
249
+ def find_job(self, name_or_id: str) -> dict | None:
250
+ """Find a running or sleeping job by name, workspace ID, or job ID.
251
+
252
+ Uses the same resolution priority as the CLI:
253
+ running/sleeping exact name → workspace ID → any status exact name.
254
+ Returns the job dict or ``None``.
255
+ """
256
+ jobs = self.list_jobs(page_size=50).get("jobs", [])
257
+
258
+ # Priority 1: exact name match, running/sleeping
259
+ for j in jobs:
260
+ if j.get("name") == name_or_id and j.get("status") in ("running", "sleeping"):
261
+ return j
262
+
263
+ # Priority 2: workspace_id match, running/sleeping
264
+ for j in jobs:
265
+ if j.get("workspace_id") == name_or_id and j.get("status") in ("running", "sleeping"):
266
+ return j
267
+
268
+ # Priority 3: exact name match, any status
269
+ for j in jobs:
270
+ if j.get("name") == name_or_id:
271
+ return j
272
+
273
+ # Priority 4: workspace_id match, any status
274
+ for j in jobs:
275
+ if j.get("workspace_id") == name_or_id:
276
+ return j
277
+
278
+ return None
279
+
280
+ def url(self, name_or_id: str, *, port: int = 8080) -> dict:
281
+ """Enable public URL access for a job's VM.
282
+
283
+ Accepts a job ID, job name, or workspace ID.
284
+ Returns dict with ``url`` and access details.
285
+ """
286
+ request_id = self._resolve_job_id(name_or_id)
287
+ st = self.status(request_id)
288
+ resp = self.enable_access(request_id, port=port)
289
+ subdomain = st.get("subdomain") or resp.get("subdomain")
290
+ if subdomain:
291
+ resp["url"] = f"https://{subdomain}.apps.magpiecloud.com"
292
+ return resp
293
+
294
+ def exec(self, name_or_id: str, command: str, *, timeout: int = 30) -> dict:
295
+ """Execute a command on an existing running/sleeping sandbox via SSH.
296
+
297
+ Equivalent to ``mags exec <workspace> '<command>'``.
298
+
299
+ Returns ``{"exit_code": int, "output": str}``.
300
+ """
301
+ job = self.find_job(name_or_id)
302
+ if not job:
303
+ raise MagsError(f"No running or sleeping VM found for '{name_or_id}'")
304
+ if job["status"] not in ("running", "sleeping"):
305
+ raise MagsError(
306
+ f"VM for '{name_or_id}' is {job['status']}, needs to be running or sleeping"
307
+ )
308
+
309
+ request_id = job.get("request_id") or job.get("id")
310
+
311
+ # Wait for VM to be assigned (status=running doesn't guarantee vm_id yet)
312
+ for _ in range(15):
313
+ st = self.status(request_id)
314
+ if st.get("vm_id"):
315
+ break
316
+ time.sleep(1)
317
+ else:
318
+ raise MagsError(f"VM for '{name_or_id}' has no vm_id after 15s")
319
+
320
+ access = self.enable_access(request_id, port=22)
321
+
322
+ if not access.get("success") or not access.get("ssh_host"):
323
+ raise MagsError(
324
+ f"Failed to enable SSH access: {access.get('error', 'unknown error')}"
325
+ )
326
+
327
+ ssh_host = access["ssh_host"]
328
+ ssh_port = str(access["ssh_port"])
329
+ ssh_key = access.get("ssh_private_key", "")
330
+
331
+ # Wrap command to handle chroot overlay, same as CLI
332
+ escaped = command.replace("'", "'\\''")
333
+ wrapped = (
334
+ f"if [ -d /overlay/bin ]; then "
335
+ f"chroot /overlay /bin/sh -l -c 'cd /root 2>/dev/null; {escaped}'; "
336
+ f"else cd /root 2>/dev/null; {escaped}; fi"
337
+ )
338
+
339
+ key_file = None
340
+ try:
341
+ ssh_args = [
342
+ "ssh",
343
+ "-o", "StrictHostKeyChecking=no",
344
+ "-o", "UserKnownHostsFile=/dev/null",
345
+ "-o", "LogLevel=ERROR",
346
+ "-p", ssh_port,
347
+ ]
348
+ if ssh_key:
349
+ fd, key_file = tempfile.mkstemp(prefix="mags_ssh_")
350
+ os.write(fd, ssh_key.encode())
351
+ os.close(fd)
352
+ os.chmod(key_file, 0o600)
353
+ ssh_args.extend(["-i", key_file])
354
+
355
+ ssh_args.append(f"root@{ssh_host}")
356
+ ssh_args.append(wrapped)
357
+
358
+ proc = subprocess.run(
359
+ ssh_args,
360
+ capture_output=True,
361
+ text=True,
362
+ timeout=timeout,
363
+ )
364
+ return {
365
+ "exit_code": proc.returncode,
366
+ "output": proc.stdout,
367
+ "stderr": proc.stderr,
368
+ }
369
+ except subprocess.TimeoutExpired:
370
+ raise MagsError(f"Command timed out after {timeout}s")
371
+ finally:
372
+ if key_file:
373
+ try:
374
+ os.unlink(key_file)
375
+ except OSError:
376
+ pass
377
+
201
378
  def usage(self, *, window_days: int = 30) -> dict:
202
379
  """Get aggregated usage summary."""
203
380
  return self._request(
204
381
  "GET", "/mags-jobs/usage", params={"window_days": window_days}
205
382
  )
206
383
 
384
+ # ── internal helpers ─────────────────────────────────────────────
385
+
386
+ def _resolve_job_id(self, name_or_id: str) -> str:
387
+ """Resolve a job name, workspace ID, or UUID to a request_id."""
388
+ # If it looks like a UUID, use directly
389
+ if len(name_or_id) >= 32 and "-" in name_or_id:
390
+ return name_or_id
391
+ job = self.find_job(name_or_id)
392
+ if not job:
393
+ raise MagsError(f"No job found for '{name_or_id}'")
394
+ return job.get("request_id") or job.get("id")
395
+
207
396
  # ── file uploads ─────────────────────────────────────────────────
208
397
 
209
398
  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