@magpiecloud/mags 1.8.3 → 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 +2 -0
- package/QUICKSTART.md +11 -0
- package/README.md +24 -1
- package/bin/mags.js +234 -20
- package/package.json +1 -1
- package/python/README.md +19 -1
- package/python/dist/magpie_mags-1.3.2-py3-none-any.whl +0 -0
- package/python/dist/magpie_mags-1.3.2.tar.gz +0 -0
- package/python/pyproject.toml +1 -1
- package/python/src/magpie_mags.egg-info/PKG-INFO +1 -1
- package/python/src/mags/client.py +48 -9
- package/python/dist/magpie_mags-1.2.0-py3-none-any.whl +0 -0
- package/python/dist/magpie_mags-1.2.0.tar.gz +0 -0
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,11 +202,15 @@ ${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
|
|
208
209
|
list List recent jobs
|
|
209
210
|
url <name|id> [port] Enable URL access for a job
|
|
211
|
+
url alias <sub> <workspace> Create a stable URL alias for a workspace
|
|
212
|
+
url alias list List your URL aliases
|
|
213
|
+
url alias remove <subdomain> Delete a URL alias
|
|
210
214
|
stop <name|id> Stop a running job
|
|
211
215
|
resize <workspace> --disk <GB> Resize a workspace's disk (restarts VM)
|
|
212
216
|
sync <workspace|id> Sync workspace to S3 (without stopping)
|
|
@@ -215,8 +219,7 @@ ${colors.bold}Commands:${colors.reset}
|
|
|
215
219
|
setup-claude Install Mags skill for Claude Code
|
|
216
220
|
|
|
217
221
|
${colors.bold}Run Options:${colors.reset}
|
|
218
|
-
-
|
|
219
|
-
-n, --name <name> Set job name (for easier reference)
|
|
222
|
+
-n, --name <name> Set job/workspace name (used as both)
|
|
220
223
|
-p, --persistent Keep VM alive after script completes
|
|
221
224
|
--no-sleep Never auto-sleep this VM (requires -p)
|
|
222
225
|
--base <workspace> Mount workspace read-only as base image
|
|
@@ -237,7 +240,7 @@ ${colors.bold}Cron Commands:${colors.reset}
|
|
|
237
240
|
${colors.bold}Cron Options:${colors.reset}
|
|
238
241
|
--name <name> Cron job name (required)
|
|
239
242
|
--schedule <expr> Cron expression (required, e.g. "0 * * * *")
|
|
240
|
-
-w, --workspace <id> Workspace for cron jobs
|
|
243
|
+
-w, --workspace <id> Workspace for cron jobs (alias for --name)
|
|
241
244
|
-p, --persistent Keep VM alive after cron script
|
|
242
245
|
|
|
243
246
|
${colors.bold}Examples:${colors.reset}
|
|
@@ -248,11 +251,12 @@ ${colors.bold}Examples:${colors.reset}
|
|
|
248
251
|
mags run 'echo Hello World'
|
|
249
252
|
mags run -e 'echo fast' # Ephemeral (no S3 sync)
|
|
250
253
|
mags run -f script.py 'python3 script.py' # Upload + run file
|
|
251
|
-
mags run -
|
|
254
|
+
mags run -n myproject 'python3 script.py'
|
|
252
255
|
mags run --base golden 'npm test' # Use golden as read-only base
|
|
253
|
-
mags run --base golden -
|
|
256
|
+
mags run --base golden -n fork-1 'npm test' # Base + save changes to fork-1
|
|
254
257
|
mags run -p --url 'python3 -m http.server 8080'
|
|
255
|
-
mags run -n webapp -
|
|
258
|
+
mags run -n webapp -p --url --port 3000 'npm start'
|
|
259
|
+
mags browser myproject # Start browser with workspace
|
|
256
260
|
mags workspace list # List workspaces
|
|
257
261
|
mags workspace delete myproject # Delete workspace
|
|
258
262
|
mags cron add --name backup --schedule "0 0 * * *" 'tar czf backup.tar.gz data/'
|
|
@@ -260,6 +264,10 @@ ${colors.bold}Examples:${colors.reset}
|
|
|
260
264
|
mags status myvm
|
|
261
265
|
mags logs myvm
|
|
262
266
|
mags url myvm 8080
|
|
267
|
+
mags url alias my-api myvm # Stable URL: my-api.apps.magpiecloud.com
|
|
268
|
+
mags url alias my-api myvm --lfg # Stable URL: my-api.app.lfg.run
|
|
269
|
+
mags url alias list # List all aliases
|
|
270
|
+
mags url alias remove my-api # Remove alias
|
|
263
271
|
mags setup-claude # Install Claude Code skill
|
|
264
272
|
`);
|
|
265
273
|
process.exit(1);
|
|
@@ -460,7 +468,6 @@ async function newVM(args) {
|
|
|
460
468
|
|
|
461
469
|
async function runJob(args) {
|
|
462
470
|
let script = '';
|
|
463
|
-
let workspace = '';
|
|
464
471
|
let baseWorkspace = '';
|
|
465
472
|
let name = '';
|
|
466
473
|
let persistent = false;
|
|
@@ -477,8 +484,6 @@ async function runJob(args) {
|
|
|
477
484
|
switch (args[i]) {
|
|
478
485
|
case '-w':
|
|
479
486
|
case '--workspace':
|
|
480
|
-
workspace = args[++i];
|
|
481
|
-
break;
|
|
482
487
|
case '-n':
|
|
483
488
|
case '--name':
|
|
484
489
|
name = args[++i];
|
|
@@ -525,8 +530,8 @@ async function runJob(args) {
|
|
|
525
530
|
}
|
|
526
531
|
|
|
527
532
|
// Validate flag combinations
|
|
528
|
-
if (ephemeral &&
|
|
529
|
-
log('red', 'Error: Cannot use --ephemeral with --
|
|
533
|
+
if (ephemeral && name) {
|
|
534
|
+
log('red', 'Error: Cannot use --ephemeral with --name; ephemeral VMs have no persistent storage');
|
|
530
535
|
process.exit(1);
|
|
531
536
|
}
|
|
532
537
|
if (ephemeral && persistent) {
|
|
@@ -566,9 +571,11 @@ async function runJob(args) {
|
|
|
566
571
|
persistent
|
|
567
572
|
};
|
|
568
573
|
if (noSleep) payload.no_sleep = true;
|
|
569
|
-
//
|
|
570
|
-
if (
|
|
571
|
-
|
|
574
|
+
// name and workspace_id are always the same
|
|
575
|
+
if (name) {
|
|
576
|
+
payload.name = name;
|
|
577
|
+
if (!ephemeral) payload.workspace_id = name;
|
|
578
|
+
}
|
|
572
579
|
if (baseWorkspace) payload.base_workspace_id = baseWorkspace;
|
|
573
580
|
if (startupCommand) payload.startup_command = startupCommand;
|
|
574
581
|
if (fileIds.length > 0) payload.file_ids = fileIds;
|
|
@@ -585,7 +592,6 @@ async function runJob(args) {
|
|
|
585
592
|
const requestId = response.request_id;
|
|
586
593
|
log('green', `Job submitted: ${requestId}`);
|
|
587
594
|
if (name) log('blue', `Name: ${name}`);
|
|
588
|
-
if (workspace) log('blue', `Workspace: ${workspace}`);
|
|
589
595
|
if (baseWorkspace) log('blue', `Base workspace: ${baseWorkspace} (read-only)`);
|
|
590
596
|
if (persistent) log('yellow', 'Persistent: VM will stay alive');
|
|
591
597
|
|
|
@@ -668,6 +674,98 @@ async function enableUrlAccess(nameOrId, port = 8080) {
|
|
|
668
674
|
}
|
|
669
675
|
}
|
|
670
676
|
|
|
677
|
+
async function urlAliasCommand(args) {
|
|
678
|
+
if (args.length === 0) {
|
|
679
|
+
log('red', 'Error: URL alias subcommand required');
|
|
680
|
+
console.log('\nUsage:');
|
|
681
|
+
console.log(' mags url alias <subdomain> <workspace> [--lfg]');
|
|
682
|
+
console.log(' mags url alias list');
|
|
683
|
+
console.log(' mags url alias remove <subdomain>');
|
|
684
|
+
process.exit(1);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const subcommand = args[0];
|
|
688
|
+
|
|
689
|
+
switch (subcommand) {
|
|
690
|
+
case 'list':
|
|
691
|
+
case 'ls':
|
|
692
|
+
await urlAliasList();
|
|
693
|
+
break;
|
|
694
|
+
case 'remove':
|
|
695
|
+
case 'rm':
|
|
696
|
+
case 'delete':
|
|
697
|
+
if (!args[1]) {
|
|
698
|
+
log('red', 'Error: Subdomain required');
|
|
699
|
+
console.log('\nUsage: mags url alias remove <subdomain>');
|
|
700
|
+
process.exit(1);
|
|
701
|
+
}
|
|
702
|
+
await urlAliasRemove(args[1]);
|
|
703
|
+
break;
|
|
704
|
+
default:
|
|
705
|
+
// mags url alias <subdomain> <workspace> [--lfg]
|
|
706
|
+
const subdomain = args[0];
|
|
707
|
+
const workspace = args[1];
|
|
708
|
+
if (!workspace) {
|
|
709
|
+
log('red', 'Error: Workspace required');
|
|
710
|
+
console.log('\nUsage: mags url alias <subdomain> <workspace> [--lfg]');
|
|
711
|
+
process.exit(1);
|
|
712
|
+
}
|
|
713
|
+
const useLfg = args.includes('--lfg');
|
|
714
|
+
await urlAliasCreate(subdomain, workspace, useLfg);
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function urlAliasCreate(subdomain, workspaceId, useLfg) {
|
|
720
|
+
const domain = useLfg ? 'app.lfg.run' : 'apps.magpiecloud.com';
|
|
721
|
+
log('blue', `Creating URL alias: ${subdomain}.${domain} → workspace '${workspaceId}'...`);
|
|
722
|
+
|
|
723
|
+
const resp = await request('POST', '/api/v1/mags-url-aliases', {
|
|
724
|
+
subdomain,
|
|
725
|
+
workspace_id: workspaceId,
|
|
726
|
+
domain,
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
if (resp.error) {
|
|
730
|
+
log('red', `Error: ${resp.error}`);
|
|
731
|
+
process.exit(1);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (resp.url) {
|
|
735
|
+
log('green', `URL alias created: ${resp.url}`);
|
|
736
|
+
} else {
|
|
737
|
+
log('green', `URL alias created: https://${subdomain}.${domain}`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async function urlAliasList() {
|
|
742
|
+
const resp = await request('GET', '/api/v1/mags-url-aliases');
|
|
743
|
+
const aliases = resp.aliases || [];
|
|
744
|
+
|
|
745
|
+
if (aliases.length > 0) {
|
|
746
|
+
log('cyan', 'URL Aliases:\n');
|
|
747
|
+
aliases.forEach(a => {
|
|
748
|
+
console.log(` ${colors.bold}${a.subdomain}${colors.reset}`);
|
|
749
|
+
console.log(` URL: ${colors.green}${a.url}${colors.reset}`);
|
|
750
|
+
console.log(` Workspace: ${a.workspace_id} Domain: ${a.domain}`);
|
|
751
|
+
console.log('');
|
|
752
|
+
});
|
|
753
|
+
log('gray', `Total: ${aliases.length} alias(es)`);
|
|
754
|
+
} else {
|
|
755
|
+
log('yellow', 'No URL aliases found');
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function urlAliasRemove(subdomain) {
|
|
760
|
+
log('blue', `Removing URL alias '${subdomain}'...`);
|
|
761
|
+
const resp = await request('DELETE', `/api/v1/mags-url-aliases/${subdomain}`);
|
|
762
|
+
if (resp.error) {
|
|
763
|
+
log('red', `Error: ${resp.error}`);
|
|
764
|
+
process.exit(1);
|
|
765
|
+
}
|
|
766
|
+
log('green', resp.message || `URL alias '${subdomain}' removed`);
|
|
767
|
+
}
|
|
768
|
+
|
|
671
769
|
async function getStatus(nameOrId) {
|
|
672
770
|
if (!nameOrId) {
|
|
673
771
|
log('red', 'Error: Job name or ID required');
|
|
@@ -1190,6 +1288,114 @@ async function workspaceDelete(workspaceId) {
|
|
|
1190
1288
|
}
|
|
1191
1289
|
}
|
|
1192
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
|
+
|
|
1193
1399
|
async function sshToJob(nameOrId) {
|
|
1194
1400
|
if (!nameOrId) {
|
|
1195
1401
|
log('red', 'Error: Workspace, job name, or job ID required');
|
|
@@ -1236,11 +1442,11 @@ async function sshToJob(nameOrId) {
|
|
|
1236
1442
|
jobID = response.request_id;
|
|
1237
1443
|
log('gray', `Job: ${jobID}`);
|
|
1238
1444
|
|
|
1239
|
-
// Wait for VM to be ready
|
|
1445
|
+
// Wait for VM to be ready (status=running AND vm_id assigned)
|
|
1240
1446
|
log('blue', 'Waiting for VM...');
|
|
1241
|
-
for (let i = 0; i <
|
|
1447
|
+
for (let i = 0; i < 60; i++) {
|
|
1242
1448
|
const status = await request('GET', `/api/v1/mags-jobs/${jobID}/status`);
|
|
1243
|
-
if (status.status === 'running') break;
|
|
1449
|
+
if (status.status === 'running' && status.vm_id) break;
|
|
1244
1450
|
if (status.status === 'error') {
|
|
1245
1451
|
log('red', 'VM failed to start');
|
|
1246
1452
|
process.exit(1);
|
|
@@ -1422,7 +1628,7 @@ async function main() {
|
|
|
1422
1628
|
break;
|
|
1423
1629
|
case '--version':
|
|
1424
1630
|
case '-v':
|
|
1425
|
-
console.log('mags v1.8.
|
|
1631
|
+
console.log('mags v1.8.5');
|
|
1426
1632
|
process.exit(0);
|
|
1427
1633
|
break;
|
|
1428
1634
|
case 'new':
|
|
@@ -1437,13 +1643,21 @@ async function main() {
|
|
|
1437
1643
|
await requireAuth();
|
|
1438
1644
|
await sshToJob(args[1]);
|
|
1439
1645
|
break;
|
|
1646
|
+
case 'browser':
|
|
1647
|
+
await requireAuth();
|
|
1648
|
+
await browserSession(args.slice(1));
|
|
1649
|
+
break;
|
|
1440
1650
|
case 'exec':
|
|
1441
1651
|
await requireAuth();
|
|
1442
1652
|
await execOnJob(args[1], args.slice(2).join(' '));
|
|
1443
1653
|
break;
|
|
1444
1654
|
case 'url':
|
|
1445
1655
|
await requireAuth();
|
|
1446
|
-
|
|
1656
|
+
if (args[1] === 'alias') {
|
|
1657
|
+
await urlAliasCommand(args.slice(2));
|
|
1658
|
+
} else {
|
|
1659
|
+
await enableUrlAccess(args[1], parseInt(args[2]) || 8080);
|
|
1660
|
+
}
|
|
1447
1661
|
break;
|
|
1448
1662
|
case 'status':
|
|
1449
1663
|
await requireAuth();
|
package/package.json
CHANGED
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 (
|
|
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 |
|
|
Binary file
|
|
Binary file
|
package/python/pyproject.toml
CHANGED
|
@@ -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
|
-
#
|
|
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"):
|
|
@@ -507,3 +513,36 @@ class Mags:
|
|
|
507
513
|
def cron_delete(self, cron_id: str) -> dict:
|
|
508
514
|
"""Delete a cron job."""
|
|
509
515
|
return self._request("DELETE", f"/mags-cron/{cron_id}")
|
|
516
|
+
|
|
517
|
+
# ── URL aliases ──────────────────────────────────────────────────
|
|
518
|
+
|
|
519
|
+
def url_alias_create(
|
|
520
|
+
self,
|
|
521
|
+
subdomain: str,
|
|
522
|
+
workspace_id: str,
|
|
523
|
+
domain: str = "apps.magpiecloud.com",
|
|
524
|
+
) -> dict:
|
|
525
|
+
"""Create a stable URL alias for a workspace.
|
|
526
|
+
|
|
527
|
+
The alias maps ``subdomain.<domain>`` to the active job in the workspace.
|
|
528
|
+
Use ``domain="app.lfg.run"`` for the LFG domain.
|
|
529
|
+
|
|
530
|
+
Returns ``{"id": ..., "subdomain": ..., "url": ...}``.
|
|
531
|
+
"""
|
|
532
|
+
return self._request(
|
|
533
|
+
"POST",
|
|
534
|
+
"/mags-url-aliases",
|
|
535
|
+
json={
|
|
536
|
+
"subdomain": subdomain,
|
|
537
|
+
"workspace_id": workspace_id,
|
|
538
|
+
"domain": domain,
|
|
539
|
+
},
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
def url_alias_list(self) -> dict:
|
|
543
|
+
"""List all URL aliases. Returns ``{"aliases": [...], "total": N}``."""
|
|
544
|
+
return self._request("GET", "/mags-url-aliases")
|
|
545
|
+
|
|
546
|
+
def url_alias_delete(self, subdomain: str) -> dict:
|
|
547
|
+
"""Delete a URL alias by subdomain."""
|
|
548
|
+
return self._request("DELETE", f"/mags-url-aliases/{subdomain}")
|
|
Binary file
|
|
Binary file
|