@magpiecloud/mags 1.8.4 → 1.8.5

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/API.md CHANGED
@@ -39,6 +39,7 @@ Creates a VM, runs a script, and returns. The VM boots in ~300ms from a pool.
39
39
  "workspace_id": "my-project",
40
40
  "base_workspace_id": "my-base",
41
41
  "persistent": false,
42
+ "no_sleep": false,
42
43
  "startup_command": "python3 -m http.server 8080",
43
44
  "environment": { "FOO": "bar" },
44
45
  "file_ids": ["file-uuid-1", "file-uuid-2"]
@@ -52,6 +53,7 @@ Creates a VM, runs a script, and returns. The VM boots in ~300ms from a pool.
52
53
  | `workspace_id` | string | no | Persistent workspace name. Filesystem changes (apt install, files, configs) are synced to S3 and restored on next run. Omit for truly ephemeral (no sync). |
53
54
  | `base_workspace_id` | string | no | Mount an existing workspace **read-only** as the starting filesystem. Changes are discarded unless `workspace_id` is also set (fork pattern). |
54
55
  | `persistent` | bool | no | If `true`, VM stays alive after script finishes. Use for long-running servers, SSH access, or URL-exposed services. |
56
+ | `no_sleep` | bool | no | If `true` with `persistent`, VM never auto-sleeps. Stays running 24/7 and auto-recovers if the host goes down. |
55
57
  | `startup_command` | string | no | Command to run when a sleeping persistent VM wakes up (on URL access). |
56
58
  | `environment` | object | no | Key-value env vars injected into the VM. |
57
59
  | `file_ids` | string[] | no | File IDs from the upload endpoint. Files are downloaded into `/root/` before script runs. |
package/QUICKSTART.md CHANGED
@@ -148,6 +148,7 @@ mags run -w webapp-prod "..."
148
148
  | `-p, --persistent` | Keep VM alive for URL/SSH access | false |
149
149
  | `--url` | Enable public URL access (requires -p) | false |
150
150
  | `--port` | Port to expose for URL access | 8080 |
151
+ | `--no-sleep` | Keep VM always running, never auto-sleep (requires -p) | false |
151
152
  | `--startup-command` | Command when VM wakes from sleep | none |
152
153
  | `-e, --ephemeral` | No S3 sync (faster, truly ephemeral) | false |
153
154
  | `-t, --timeout` | Timeout in seconds | 300 |
@@ -205,6 +206,16 @@ mags run -w myapp -p --url --startup-command "npm start" "npm install && npm sta
205
206
  # The app wakes automatically when someone visits the URL!
206
207
  ```
207
208
 
209
+ ### Always-On Server (Never Sleeps)
210
+
211
+ ```bash
212
+ # Deploy a server that stays running 24/7
213
+ mags run -w my-api -p --no-sleep --url --port 3000 "npm start"
214
+
215
+ # VM never auto-sleeps, even when idle
216
+ # Auto-recovers if the host goes down
217
+ ```
218
+
208
219
  ## Timeouts
209
220
 
210
221
  | Command | Default | Max | Flag |
package/README.md CHANGED
@@ -116,6 +116,7 @@ mags run -p --url 'python3 -m http.server 8080'
116
116
  | `-p, --persistent` | Keep VM alive after script completes | false |
117
117
  | `--url` | Enable public URL access (requires -p) | false |
118
118
  | `--port <port>` | Port to expose for URL access | 8080 |
119
+ | `--no-sleep` | Keep VM always running, never auto-sleep (requires -p) | false |
119
120
  | `--startup-command <cmd>` | Command to run when VM wakes from sleep | none |
120
121
 
121
122
  ## SSH Access
@@ -219,6 +220,20 @@ mags run -w flask-app -p --url 'pip install flask && python app.py'
219
220
  mags run -w api -p --url --startup-command 'python server.py' 'pip install -r requirements.txt && python server.py'
220
221
  ```
221
222
 
223
+ ### Always-On VMs
224
+
225
+ By default, persistent VMs auto-sleep after 10 minutes of inactivity and wake on the next request. Use `--no-sleep` to keep a VM running 24/7:
226
+
227
+ ```bash
228
+ # Always-on API server (never auto-sleeps)
229
+ mags run -w my-api -p --no-sleep --url --port 3000 'npm start'
230
+
231
+ # Always-on background worker
232
+ mags run -w worker -p --no-sleep 'python worker.py'
233
+ ```
234
+
235
+ If an always-on VM's host becomes unhealthy, the orchestrator automatically re-provisions it on a healthy agent within ~60 seconds.
236
+
222
237
  ### Interactive Development
223
238
 
224
239
  ```bash
@@ -257,6 +272,13 @@ const { requestId } = await mags.run('python script.py', {
257
272
  startupCommand: 'python server.py'
258
273
  });
259
274
 
275
+ // Always-on VM (never auto-sleeps)
276
+ await mags.run('node server.js', {
277
+ workspaceId: 'my-api',
278
+ persistent: true,
279
+ noSleep: true
280
+ });
281
+
260
282
  // Get status
261
283
  const status = await mags.status(requestId);
262
284
  console.log(status);
@@ -298,7 +320,8 @@ curl -X POST https://api.magpiecloud.com/api/v1/mags-jobs \
298
320
  "script": "echo Hello World",
299
321
  "type": "inline",
300
322
  "workspace_id": "myproject",
301
- "persistent": true
323
+ "persistent": true,
324
+ "no_sleep": true
302
325
  }'
303
326
  ```
304
327
 
package/bin/mags.js CHANGED
@@ -202,6 +202,7 @@ ${colors.bold}Commands:${colors.reset}
202
202
  new <name> Create a new persistent VM (returns ID only)
203
203
  run [options] <script> Execute a script on a microVM
204
204
  ssh <workspace|name|id> Open SSH session (auto-starts VM if needed)
205
+ browser [workspace] Start a Chromium browser session (CDP access)
205
206
  exec <workspace> <command> Run a command on an existing VM
206
207
  status <name|id> Get job status
207
208
  logs <name|id> Get job logs
@@ -218,8 +219,7 @@ ${colors.bold}Commands:${colors.reset}
218
219
  setup-claude Install Mags skill for Claude Code
219
220
 
220
221
  ${colors.bold}Run Options:${colors.reset}
221
- -w, --workspace <id> Use persistent workspace (S3 sync)
222
- -n, --name <name> Set job name (for easier reference)
222
+ -n, --name <name> Set job/workspace name (used as both)
223
223
  -p, --persistent Keep VM alive after script completes
224
224
  --no-sleep Never auto-sleep this VM (requires -p)
225
225
  --base <workspace> Mount workspace read-only as base image
@@ -240,7 +240,7 @@ ${colors.bold}Cron Commands:${colors.reset}
240
240
  ${colors.bold}Cron Options:${colors.reset}
241
241
  --name <name> Cron job name (required)
242
242
  --schedule <expr> Cron expression (required, e.g. "0 * * * *")
243
- -w, --workspace <id> Workspace for cron jobs
243
+ -w, --workspace <id> Workspace for cron jobs (alias for --name)
244
244
  -p, --persistent Keep VM alive after cron script
245
245
 
246
246
  ${colors.bold}Examples:${colors.reset}
@@ -251,11 +251,12 @@ ${colors.bold}Examples:${colors.reset}
251
251
  mags run 'echo Hello World'
252
252
  mags run -e 'echo fast' # Ephemeral (no S3 sync)
253
253
  mags run -f script.py 'python3 script.py' # Upload + run file
254
- mags run -w myproject 'python3 script.py'
254
+ mags run -n myproject 'python3 script.py'
255
255
  mags run --base golden 'npm test' # Use golden as read-only base
256
- mags run --base golden -w fork-1 'npm test' # Base + save changes to fork-1
256
+ mags run --base golden -n fork-1 'npm test' # Base + save changes to fork-1
257
257
  mags run -p --url 'python3 -m http.server 8080'
258
- mags run -n webapp -w webapp -p --url --port 3000 'npm start'
258
+ mags run -n webapp -p --url --port 3000 'npm start'
259
+ mags browser myproject # Start browser with workspace
259
260
  mags workspace list # List workspaces
260
261
  mags workspace delete myproject # Delete workspace
261
262
  mags cron add --name backup --schedule "0 0 * * *" 'tar czf backup.tar.gz data/'
@@ -467,7 +468,6 @@ async function newVM(args) {
467
468
 
468
469
  async function runJob(args) {
469
470
  let script = '';
470
- let workspace = '';
471
471
  let baseWorkspace = '';
472
472
  let name = '';
473
473
  let persistent = false;
@@ -484,8 +484,6 @@ async function runJob(args) {
484
484
  switch (args[i]) {
485
485
  case '-w':
486
486
  case '--workspace':
487
- workspace = args[++i];
488
- break;
489
487
  case '-n':
490
488
  case '--name':
491
489
  name = args[++i];
@@ -532,8 +530,8 @@ async function runJob(args) {
532
530
  }
533
531
 
534
532
  // Validate flag combinations
535
- if (ephemeral && workspace) {
536
- log('red', 'Error: Cannot use --ephemeral with --workspace; ephemeral VMs have no persistent storage');
533
+ if (ephemeral && name) {
534
+ log('red', 'Error: Cannot use --ephemeral with --name; ephemeral VMs have no persistent storage');
537
535
  process.exit(1);
538
536
  }
539
537
  if (ephemeral && persistent) {
@@ -573,9 +571,11 @@ async function runJob(args) {
573
571
  persistent
574
572
  };
575
573
  if (noSleep) payload.no_sleep = true;
576
- // Only set workspace_id if not ephemeral
577
- if (!ephemeral && workspace) payload.workspace_id = workspace;
578
- if (name) payload.name = name;
574
+ // name and workspace_id are always the same
575
+ if (name) {
576
+ payload.name = name;
577
+ if (!ephemeral) payload.workspace_id = name;
578
+ }
579
579
  if (baseWorkspace) payload.base_workspace_id = baseWorkspace;
580
580
  if (startupCommand) payload.startup_command = startupCommand;
581
581
  if (fileIds.length > 0) payload.file_ids = fileIds;
@@ -592,7 +592,6 @@ async function runJob(args) {
592
592
  const requestId = response.request_id;
593
593
  log('green', `Job submitted: ${requestId}`);
594
594
  if (name) log('blue', `Name: ${name}`);
595
- if (workspace) log('blue', `Workspace: ${workspace}`);
596
595
  if (baseWorkspace) log('blue', `Base workspace: ${baseWorkspace} (read-only)`);
597
596
  if (persistent) log('yellow', 'Persistent: VM will stay alive');
598
597
 
@@ -1289,6 +1288,114 @@ async function workspaceDelete(workspaceId) {
1289
1288
  }
1290
1289
  }
1291
1290
 
1291
+ async function browserSession(args) {
1292
+ let workspace = '';
1293
+ let name = '';
1294
+
1295
+ // Parse args: mags browser [workspace] [--name <n>]
1296
+ for (let i = 0; i < args.length; i++) {
1297
+ if ((args[i] === '-n' || args[i] === '--name') && args[i + 1]) {
1298
+ name = args[++i];
1299
+ } else if (!workspace) {
1300
+ workspace = args[i];
1301
+ }
1302
+ }
1303
+
1304
+ // Check for existing running/sleeping browser session with this workspace
1305
+ if (workspace) {
1306
+ const existingJob = await findWorkspaceJob(workspace);
1307
+
1308
+ if (existingJob && existingJob.status === 'running') {
1309
+ // Check if it's already a browser session
1310
+ const status = await request('GET', `/api/v1/mags-jobs/${existingJob.request_id}/status`);
1311
+ if (status.browser_mode && status.debug_ws_url) {
1312
+ log('green', 'Found existing browser session');
1313
+ console.log('');
1314
+ log('cyan', `CDP Endpoint: ${status.debug_ws_url}`);
1315
+ console.log('');
1316
+ log('gray', 'Connect with Playwright:');
1317
+ console.log(` const browser = await chromium.connectOverCDP('${status.debug_ws_url}');`);
1318
+ console.log('');
1319
+ log('gray', 'Connect with Puppeteer:');
1320
+ console.log(` const browser = await puppeteer.connect({ browserWSEndpoint: '${status.debug_ws_url}' });`);
1321
+ return;
1322
+ }
1323
+ }
1324
+ }
1325
+
1326
+ log('blue', 'Starting browser session...');
1327
+
1328
+ const payload = {
1329
+ script: 'echo "Browser session ready"',
1330
+ type: 'inline',
1331
+ browser_mode: true,
1332
+ persistent: true
1333
+ };
1334
+ if (workspace) payload.workspace_id = workspace;
1335
+ if (name) payload.name = name;
1336
+ if (!name && workspace) payload.name = `browser-${workspace}`;
1337
+
1338
+ const response = await request('POST', '/api/v1/mags-jobs', payload);
1339
+
1340
+ if (!response.request_id) {
1341
+ log('red', 'Failed to start browser session:');
1342
+ console.log(JSON.stringify(response, null, 2));
1343
+ process.exit(1);
1344
+ }
1345
+
1346
+ const requestId = response.request_id;
1347
+ log('gray', `Job: ${requestId}`);
1348
+
1349
+ // Wait for VM + Chromium to be ready
1350
+ log('blue', 'Waiting for Chromium to start...');
1351
+ let status;
1352
+ for (let i = 0; i < 90; i++) {
1353
+ status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
1354
+ if (status.status === 'running' && status.debug_ws_url) {
1355
+ break;
1356
+ }
1357
+ if (status.status === 'error') {
1358
+ log('red', `Browser session failed: ${status.error_message || 'Unknown error'}`);
1359
+ process.exit(1);
1360
+ }
1361
+ process.stdout.write('.');
1362
+ await sleep(1000);
1363
+ }
1364
+ console.log('');
1365
+
1366
+ if (!status || !status.debug_ws_url) {
1367
+ // VM is running but no CDP URL yet - it may still be initializing
1368
+ if (status && status.status === 'running' && status.subdomain) {
1369
+ const wsUrl = `wss://${status.subdomain}.apps.magpiecloud.com`;
1370
+ log('green', 'Browser session ready!');
1371
+ console.log('');
1372
+ log('cyan', `CDP Endpoint: ${wsUrl}`);
1373
+ console.log('');
1374
+ log('gray', 'Connect with Playwright:');
1375
+ console.log(` const browser = await chromium.connectOverCDP('${wsUrl}');`);
1376
+ console.log('');
1377
+ log('gray', 'Connect with Puppeteer:');
1378
+ console.log(` const browser = await puppeteer.connect({ browserWSEndpoint: '${wsUrl}' });`);
1379
+ } else {
1380
+ log('yellow', 'Browser session started but CDP URL not yet available.');
1381
+ log('yellow', `Check status with: mags status ${requestId}`);
1382
+ }
1383
+ return;
1384
+ }
1385
+
1386
+ log('green', 'Browser session ready!');
1387
+ console.log('');
1388
+ log('cyan', `CDP Endpoint: ${status.debug_ws_url}`);
1389
+ console.log('');
1390
+ log('gray', 'Connect with Playwright:');
1391
+ console.log(` const browser = await chromium.connectOverCDP('${status.debug_ws_url}');`);
1392
+ console.log('');
1393
+ log('gray', 'Connect with Puppeteer:');
1394
+ console.log(` const browser = await puppeteer.connect({ browserWSEndpoint: '${status.debug_ws_url}' });`);
1395
+ console.log('');
1396
+ log('gray', `Stop with: mags stop ${workspace || requestId}`);
1397
+ }
1398
+
1292
1399
  async function sshToJob(nameOrId) {
1293
1400
  if (!nameOrId) {
1294
1401
  log('red', 'Error: Workspace, job name, or job ID required');
@@ -1335,11 +1442,11 @@ async function sshToJob(nameOrId) {
1335
1442
  jobID = response.request_id;
1336
1443
  log('gray', `Job: ${jobID}`);
1337
1444
 
1338
- // Wait for VM to be ready
1445
+ // Wait for VM to be ready (status=running AND vm_id assigned)
1339
1446
  log('blue', 'Waiting for VM...');
1340
- for (let i = 0; i < 30; i++) {
1447
+ for (let i = 0; i < 60; i++) {
1341
1448
  const status = await request('GET', `/api/v1/mags-jobs/${jobID}/status`);
1342
- if (status.status === 'running') break;
1449
+ if (status.status === 'running' && status.vm_id) break;
1343
1450
  if (status.status === 'error') {
1344
1451
  log('red', 'VM failed to start');
1345
1452
  process.exit(1);
@@ -1521,7 +1628,7 @@ async function main() {
1521
1628
  break;
1522
1629
  case '--version':
1523
1630
  case '-v':
1524
- console.log('mags v1.8.3');
1631
+ console.log('mags v1.8.5');
1525
1632
  process.exit(0);
1526
1633
  break;
1527
1634
  case 'new':
@@ -1536,6 +1643,10 @@ async function main() {
1536
1643
  await requireAuth();
1537
1644
  await sshToJob(args[1]);
1538
1645
  break;
1646
+ case 'browser':
1647
+ await requireAuth();
1648
+ await browserSession(args.slice(1));
1649
+ break;
1539
1650
  case 'exec':
1540
1651
  await requireAuth();
1541
1652
  await execOnJob(args[1], args.slice(2).join(' '));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpiecloud/mags",
3
- "version": "1.8.4",
3
+ "version": "1.8.5",
4
4
  "description": "Mags CLI - Execute scripts on Magpie's instant VM infrastructure",
5
5
  "main": "index.js",
6
6
  "bin": {
package/python/README.md CHANGED
@@ -66,6 +66,19 @@ m.run_and_wait(
66
66
  )
67
67
  ```
68
68
 
69
+ ### Always-On VMs
70
+
71
+ ```python
72
+ # VM that never auto-sleeps — stays running 24/7
73
+ job = m.run(
74
+ "python3 server.py",
75
+ workspace_id="my-api",
76
+ persistent=True,
77
+ no_sleep=True,
78
+ )
79
+ # Auto-recovers if the host goes down
80
+ ```
81
+
69
82
  ### Enable URL / SSH Access
70
83
 
71
84
  ```python
@@ -116,8 +129,13 @@ print(f"Jobs: {usage['total_jobs']}, VM seconds: {usage['vm_seconds']:.0f}")
116
129
 
117
130
  | Method | Description |
118
131
  |--------|-------------|
119
- | `run(script, **opts)` | Submit a job (returns immediately) |
132
+ | `run(script, **opts)` | Submit a job (`persistent`, `no_sleep`, `workspace_id`, ...) |
120
133
  | `run_and_wait(script, **opts)` | Submit and block until done |
134
+ | `new(name, **opts)` | Create a persistent VM workspace |
135
+ | `find_job(name_or_id)` | Find a running/sleeping job by name or workspace |
136
+ | `exec(name_or_id, command)` | Run a command on an existing VM via SSH |
137
+ | `stop(name_or_id)` | Stop a running job |
138
+ | `resize(workspace, disk_gb)` | Resize a workspace's disk |
121
139
  | `status(request_id)` | Get job status |
122
140
  | `logs(request_id)` | Get job logs |
123
141
  | `list_jobs(page, page_size)` | List recent jobs |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "magpie-mags"
7
- version = "1.3.1"
7
+ version = "1.3.3"
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.3.0
3
+ Version: 1.3.2
4
4
  Summary: Mags SDK - Execute scripts on Magpie's instant VM infrastructure
5
5
  Author: Magpie Cloud
6
6
  License: MIT
@@ -109,6 +109,20 @@ class Mags:
109
109
  ) -> dict:
110
110
  """Submit a job for execution.
111
111
 
112
+ Args:
113
+ script: Shell script to execute inside the VM.
114
+ name: Optional job name.
115
+ workspace_id: Persistent workspace name (synced to S3).
116
+ base_workspace_id: Read-only base workspace to mount.
117
+ persistent: Keep VM alive after script finishes.
118
+ no_sleep: Never auto-sleep this VM (requires persistent=True).
119
+ The VM stays running 24/7 and auto-recovers if its host goes down.
120
+ ephemeral: No S3 sync (faster, truly ephemeral).
121
+ startup_command: Command to run when VM wakes from sleep.
122
+ environment: Key-value env vars injected into the VM.
123
+ file_ids: File IDs to download into VM before script runs.
124
+ disk_gb: Custom disk size in GB (default 2GB).
125
+
112
126
  Returns ``{"request_id": ..., "status": "accepted"}``.
113
127
  """
114
128
  if ephemeral and workspace_id:
@@ -332,15 +346,7 @@ class Mags:
332
346
 
333
347
  request_id = job.get("request_id") or job.get("id")
334
348
 
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
-
349
+ # Call /access directly for sleeping VMs this triggers the wake
344
350
  access = self.enable_access(request_id, port=22)
345
351
 
346
352
  if not access.get("success") or not access.get("ssh_host"):