@magpiecloud/mags 1.8.13 → 1.8.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +95 -378
  2. package/bin/mags.js +196 -104
  3. package/index.js +6 -52
  4. package/package.json +22 -4
  5. package/API.md +0 -388
  6. package/Mags-API.postman_collection.json +0 -374
  7. package/QUICKSTART.md +0 -295
  8. package/deploy-page.sh +0 -171
  9. package/mags +0 -0
  10. package/mags.sh +0 -270
  11. package/nodejs/README.md +0 -197
  12. package/nodejs/bin/mags.js +0 -1146
  13. package/nodejs/index.js +0 -642
  14. package/nodejs/package.json +0 -42
  15. package/python/INTEGRATION.md +0 -800
  16. package/python/README.md +0 -161
  17. package/python/dist/magpie_mags-1.3.5-py3-none-any.whl +0 -0
  18. package/python/dist/magpie_mags-1.3.5.tar.gz +0 -0
  19. package/python/examples/demo.py +0 -181
  20. package/python/pyproject.toml +0 -39
  21. package/python/src/magpie_mags.egg-info/PKG-INFO +0 -182
  22. package/python/src/magpie_mags.egg-info/SOURCES.txt +0 -9
  23. package/python/src/magpie_mags.egg-info/dependency_links.txt +0 -1
  24. package/python/src/magpie_mags.egg-info/requires.txt +0 -1
  25. package/python/src/magpie_mags.egg-info/top_level.txt +0 -1
  26. package/python/src/mags/__init__.py +0 -6
  27. package/python/src/mags/client.py +0 -573
  28. package/python/test_sdk.py +0 -78
  29. package/skill.md +0 -153
  30. package/website/api.html +0 -1095
  31. package/website/claude-skill.html +0 -481
  32. package/website/cookbook/hn-marketing.html +0 -410
  33. package/website/cookbook/hn-marketing.sh +0 -42
  34. package/website/cookbook.html +0 -282
  35. package/website/env.js +0 -4
  36. package/website/index.html +0 -801
  37. package/website/llms.txt +0 -334
  38. package/website/login.html +0 -108
  39. package/website/mags.md +0 -210
  40. package/website/script.js +0 -453
  41. package/website/styles.css +0 -908
  42. package/website/tokens.html +0 -169
  43. package/website/usage.html +0 -185
@@ -1,1146 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- const https = require('https');
4
- const http = require('http');
5
- const { URL } = require('url');
6
- const fs = require('fs');
7
- const path = require('path');
8
- const os = require('os');
9
- const readline = require('readline');
10
- const { exec, spawn } = require('child_process');
11
-
12
- // Config file path
13
- const CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE, '.mags');
14
- const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
15
- const SSH_KEY_FILE = path.join(CONFIG_DIR, 'ssh_key');
16
-
17
- // Claude skill paths
18
- const CLAUDE_GLOBAL_DIR = path.join(process.env.HOME || process.env.USERPROFILE, '.claude', 'commands');
19
- const SKILL_URL = 'https://mags.run/mags.md';
20
-
21
- // Load saved config
22
- function loadConfig() {
23
- try {
24
- if (fs.existsSync(CONFIG_FILE)) {
25
- return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
26
- }
27
- } catch (err) {
28
- // Ignore errors
29
- }
30
- return {};
31
- }
32
-
33
- // Save config
34
- function saveConfig(config) {
35
- try {
36
- if (!fs.existsSync(CONFIG_DIR)) {
37
- fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
38
- }
39
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
40
- return true;
41
- } catch (err) {
42
- return false;
43
- }
44
- }
45
-
46
- // Configuration
47
- const config = loadConfig();
48
- const API_URL = process.env.MAGS_API_URL || config.api_url || 'https://api.magpiecloud.com';
49
- let API_TOKEN = process.env.MAGS_API_TOKEN || config.api_token || '';
50
-
51
- // Colors
52
- const colors = {
53
- red: '\x1b[31m',
54
- green: '\x1b[32m',
55
- yellow: '\x1b[33m',
56
- blue: '\x1b[34m',
57
- cyan: '\x1b[36m',
58
- gray: '\x1b[90m',
59
- bold: '\x1b[1m',
60
- reset: '\x1b[0m'
61
- };
62
-
63
- function log(color, msg) {
64
- console.log(`${colors[color]}${msg}${colors.reset}`);
65
- }
66
-
67
- // Prompt for input
68
- function prompt(question) {
69
- const rl = readline.createInterface({
70
- input: process.stdin,
71
- output: process.stdout
72
- });
73
-
74
- return new Promise((resolve) => {
75
- rl.question(question, (answer) => {
76
- rl.close();
77
- resolve(answer.trim());
78
- });
79
- });
80
- }
81
-
82
- // Open URL in browser
83
- function openBrowser(url) {
84
- const platform = process.platform;
85
- let cmd;
86
-
87
- if (platform === 'darwin') {
88
- cmd = `open "${url}"`;
89
- } else if (platform === 'win32') {
90
- cmd = `start "${url}"`;
91
- } else {
92
- cmd = `xdg-open "${url}"`;
93
- }
94
-
95
- exec(cmd, (err) => {
96
- if (err) {
97
- log('yellow', `Could not open browser automatically.`);
98
- log('cyan', `Please open this URL manually: ${url}`);
99
- }
100
- });
101
- }
102
-
103
- function request(method, path, body = null) {
104
- return new Promise((resolve, reject) => {
105
- const url = new URL(path, API_URL);
106
- const isHttps = url.protocol === 'https:';
107
- const lib = isHttps ? https : http;
108
-
109
- const options = {
110
- hostname: url.hostname,
111
- port: url.port || (isHttps ? 443 : 80),
112
- path: url.pathname + url.search,
113
- method,
114
- headers: {
115
- 'Authorization': `Bearer ${API_TOKEN}`,
116
- 'Content-Type': 'application/json'
117
- }
118
- };
119
-
120
- const req = lib.request(options, (res) => {
121
- let data = '';
122
- res.on('data', chunk => data += chunk);
123
- res.on('end', () => {
124
- try {
125
- resolve(JSON.parse(data));
126
- } catch {
127
- resolve(data);
128
- }
129
- });
130
- });
131
-
132
- req.on('error', reject);
133
- if (body) req.write(JSON.stringify(body));
134
- req.end();
135
- });
136
- }
137
-
138
- function sleep(ms) {
139
- return new Promise(resolve => setTimeout(resolve, ms));
140
- }
141
-
142
- // Resolve job ID from name or ID
143
- async function resolveJobId(nameOrId) {
144
- // If it looks like a UUID, use it directly
145
- if (nameOrId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
146
- return nameOrId;
147
- }
148
-
149
- // Otherwise, search by name/workspace
150
- const resp = await request('GET', `/api/v1/mags-jobs?page=1&page_size=50`);
151
- if (resp.jobs && resp.jobs.length > 0) {
152
- // Try exact match on name first
153
- let job = resp.jobs.find(j => j.name === nameOrId);
154
- if (job) return job.request_id;
155
-
156
- // Try workspace_id match
157
- job = resp.jobs.find(j => j.workspace_id === nameOrId);
158
- if (job) return job.request_id;
159
-
160
- // Try partial name match (running/sleeping jobs only)
161
- job = resp.jobs.find(j =>
162
- (j.status === 'running' || j.status === 'sleeping') &&
163
- (j.name && j.name.includes(nameOrId))
164
- );
165
- if (job) return job.request_id;
166
- }
167
-
168
- return nameOrId; // Return as-is, let API handle error
169
- }
170
-
171
- function usage() {
172
- console.log(`
173
- ${colors.cyan}${colors.bold}Mags CLI - Instant VM Execution${colors.reset}
174
-
175
- Usage: mags <command> [options] [script]
176
-
177
- ${colors.bold}Commands:${colors.reset}
178
- login Authenticate with Magpie
179
- logout Remove saved credentials
180
- whoami Show current authenticated user
181
- new <name> Create a new persistent VM (returns ID only)
182
- run [options] <script> Execute a script on a microVM
183
- ssh <name|id> Open SSH session to a running VM
184
- status <name|id> Get job status
185
- logs <name|id> Get job logs
186
- list List recent jobs
187
- url <name|id> [port] Enable URL access for a job
188
- stop <name|id> Stop a running job
189
- setup-claude Install Mags skill for Claude Code
190
-
191
- ${colors.bold}Run Options:${colors.reset}
192
- -w, --workspace <id> Use persistent workspace (S3 sync)
193
- -n, --name <name> Set job name (for easier reference)
194
- -p, --persistent Keep VM alive after script completes
195
- -e, --ephemeral No workspace/S3 sync (fastest execution)
196
- -f, --file <path> Upload file(s) to VM (repeatable)
197
- --url Enable public URL access (requires -p)
198
- --port <port> Port to expose for URL (default: 8080)
199
- --startup-command <cmd> Command to run when VM wakes from sleep
200
-
201
- ${colors.bold}Cron Commands:${colors.reset}
202
- cron add [options] <script> Create a scheduled cron job
203
- cron list List all cron jobs
204
- cron remove <id> Delete a cron job
205
- cron enable <id> Enable a cron job
206
- cron disable <id> Disable a cron job
207
-
208
- ${colors.bold}Cron Options:${colors.reset}
209
- --name <name> Cron job name (required)
210
- --schedule <expr> Cron expression (required, e.g. "0 * * * *")
211
- -w, --workspace <id> Workspace for cron jobs
212
- -p, --persistent Keep VM alive after cron script
213
-
214
- ${colors.bold}Examples:${colors.reset}
215
- mags login
216
- mags new myvm # Create VM, get ID
217
- mags ssh myvm # SSH by name
218
- mags run 'echo Hello World'
219
- mags run -e 'echo fast' # Ephemeral (no S3 sync)
220
- mags run -f script.py 'python3 script.py' # Upload + run file
221
- mags run -w myproject 'python3 script.py'
222
- mags run -p --url 'python3 -m http.server 8080'
223
- mags run -n webapp -w webapp -p --url --port 3000 'npm start'
224
- mags cron add --name backup --schedule "0 0 * * *" 'tar czf backup.tar.gz data/'
225
- mags cron list
226
- mags status myvm
227
- mags logs myvm
228
- mags url myvm 8080
229
- mags setup-claude # Install Claude Code skill
230
- `);
231
- process.exit(1);
232
- }
233
-
234
- async function login() {
235
- console.log(`
236
- ${colors.cyan}${colors.bold}Mags Login${colors.reset}
237
-
238
- To authenticate, you need an API token from Magpie.
239
- `);
240
-
241
- log('blue', 'Opening Mags to create an API token...');
242
- console.log('');
243
-
244
- const tokenUrl = 'https://mags.run/tokens';
245
- openBrowser(tokenUrl);
246
-
247
- await sleep(1000);
248
-
249
- console.log(`${colors.gray}If the browser didn't open, visit:${colors.reset}`);
250
- console.log(`${colors.cyan}${tokenUrl}${colors.reset}`);
251
- console.log('');
252
- console.log(`${colors.gray}1. Click "Create Token"${colors.reset}`);
253
- console.log(`${colors.gray}2. Give it a name (e.g., "mags-cli")${colors.reset}`);
254
- console.log(`${colors.gray}3. Copy the token and paste it below${colors.reset}`);
255
- console.log('');
256
-
257
- const token = await prompt(`${colors.bold}Paste your API token: ${colors.reset}`);
258
-
259
- if (!token) {
260
- log('red', 'No token provided. Login cancelled.');
261
- process.exit(1);
262
- }
263
-
264
- // Validate token by making a test request
265
- API_TOKEN = token;
266
- log('blue', 'Verifying token...');
267
-
268
- try {
269
- const resp = await request('GET', '/api/v1/mags-jobs?page=1&page_size=1');
270
-
271
- if (resp.error === 'unauthorized' || resp.error === 'Unauthorized') {
272
- log('red', 'Invalid token. Please check and try again.');
273
- process.exit(1);
274
- }
275
-
276
- // Save token
277
- const newConfig = { ...config, api_token: token };
278
- if (saveConfig(newConfig)) {
279
- console.log('');
280
- log('green', 'Login successful!');
281
- log('gray', `Token saved to ${CONFIG_FILE}`);
282
- console.log('');
283
- log('cyan', 'You can now run mags commands. Try:');
284
- console.log(` ${colors.bold}mags new myvm${colors.reset}`);
285
- console.log(` ${colors.bold}mags ssh myvm${colors.reset}`);
286
- } else {
287
- log('yellow', 'Login successful, but could not save token to config file.');
288
- log('yellow', 'You may need to login again next time.');
289
- }
290
- } catch (err) {
291
- log('red', `Error validating token: ${err.message}`);
292
- process.exit(1);
293
- }
294
- }
295
-
296
- async function logout() {
297
- if (!config.api_token) {
298
- log('yellow', 'You are not logged in.');
299
- return;
300
- }
301
-
302
- const newConfig = { ...config };
303
- delete newConfig.api_token;
304
-
305
- if (saveConfig(newConfig)) {
306
- log('green', 'Logged out successfully.');
307
- log('gray', 'Token removed from config.');
308
- } else {
309
- log('red', 'Could not remove token from config file.');
310
- }
311
- }
312
-
313
- async function whoami() {
314
- if (!API_TOKEN) {
315
- log('yellow', 'Not logged in.');
316
- log('gray', 'Run `mags login` to authenticate.');
317
- return;
318
- }
319
-
320
- log('blue', 'Checking authentication...');
321
-
322
- try {
323
- const resp = await request('GET', '/api/v1/mags-jobs?page=1&page_size=1');
324
-
325
- if (resp.error === 'unauthorized' || resp.error === 'Unauthorized') {
326
- log('red', 'Token is invalid or expired.');
327
- log('gray', 'Run `mags login` to re-authenticate.');
328
- return;
329
- }
330
-
331
- log('green', 'Authenticated');
332
- if (process.env.MAGS_API_TOKEN) {
333
- log('gray', 'Using token from MAGS_API_TOKEN environment variable');
334
- } else {
335
- log('gray', `Using token from ${CONFIG_FILE}`);
336
- }
337
- } catch (err) {
338
- log('red', `Error: ${err.message}`);
339
- }
340
- }
341
-
342
- async function requireAuth() {
343
- if (API_TOKEN) return true;
344
-
345
- console.log(`
346
- ${colors.yellow}You are not logged in.${colors.reset}
347
-
348
- To use Mags, you need to authenticate first.
349
- `);
350
-
351
- const answer = await prompt(`${colors.bold}Would you like to login now? (Y/n): ${colors.reset}`);
352
-
353
- if (answer.toLowerCase() === 'n' || answer.toLowerCase() === 'no') {
354
- log('gray', 'You can login later with: mags login');
355
- process.exit(0);
356
- }
357
-
358
- await login();
359
- return true;
360
- }
361
-
362
- // Create a new persistent VM
363
- async function newVM(name) {
364
- if (!name) {
365
- log('red', 'Error: Name required');
366
- console.log(`\nUsage: mags new <name>\n`);
367
- process.exit(1);
368
- }
369
-
370
- const payload = {
371
- script: 'sleep infinity',
372
- type: 'inline',
373
- persistent: true,
374
- name: name,
375
- workspace_id: name,
376
- startup_command: 'sleep infinity'
377
- };
378
-
379
- const response = await request('POST', '/api/v1/mags-jobs', payload);
380
-
381
- if (!response.request_id) {
382
- log('red', 'Failed to create VM:');
383
- console.log(JSON.stringify(response, null, 2));
384
- process.exit(1);
385
- }
386
-
387
- // Wait for VM to be ready
388
- const maxAttempts = 60;
389
- let attempt = 0;
390
-
391
- while (attempt < maxAttempts) {
392
- const status = await request('GET', `/api/v1/mags-jobs/${response.request_id}/status`);
393
-
394
- if (status.status === 'running') {
395
- // Just output the ID
396
- console.log(response.request_id);
397
- return;
398
- } else if (status.status === 'error') {
399
- log('red', `VM creation failed: ${status.error_message || 'Unknown error'}`);
400
- process.exit(1);
401
- }
402
-
403
- await sleep(500);
404
- attempt++;
405
- }
406
-
407
- log('yellow', 'VM creation timed out, but may still be starting');
408
- console.log(response.request_id);
409
- }
410
-
411
- async function runJob(args) {
412
- let script = '';
413
- let workspace = '';
414
- let name = '';
415
- let persistent = false;
416
- let ephemeral = false;
417
- let enableUrl = false;
418
- let port = 8080;
419
- let startupCommand = '';
420
- let fileArgs = [];
421
-
422
- // Parse flags
423
- for (let i = 0; i < args.length; i++) {
424
- switch (args[i]) {
425
- case '-w':
426
- case '--workspace':
427
- workspace = args[++i];
428
- break;
429
- case '-n':
430
- case '--name':
431
- name = args[++i];
432
- break;
433
- case '-p':
434
- case '--persistent':
435
- persistent = true;
436
- break;
437
- case '-e':
438
- case '--ephemeral':
439
- ephemeral = true;
440
- break;
441
- case '-f':
442
- case '--file':
443
- fileArgs.push(args[++i]);
444
- break;
445
- case '--url':
446
- enableUrl = true;
447
- break;
448
- case '--port':
449
- port = parseInt(args[++i]) || 8080;
450
- break;
451
- case '--startup-command':
452
- startupCommand = args[++i];
453
- break;
454
- default:
455
- script = args.slice(i).join(' ');
456
- i = args.length;
457
- }
458
- }
459
-
460
- if (!script) {
461
- log('red', 'Error: No script provided');
462
- usage();
463
- }
464
-
465
- // Validate flag combinations
466
- if (ephemeral && workspace) {
467
- log('red', 'Error: Cannot use --ephemeral with --workspace; ephemeral VMs have no persistent storage');
468
- process.exit(1);
469
- }
470
- if (ephemeral && persistent) {
471
- log('red', 'Error: Cannot use --ephemeral with --persistent; ephemeral VMs are destroyed after execution');
472
- process.exit(1);
473
- }
474
-
475
- // Upload files if any
476
- let fileIds = [];
477
- if (fileArgs.length > 0) {
478
- for (const filePath of fileArgs) {
479
- log('blue', `Uploading ${filePath}...`);
480
- const fileId = await uploadFile(filePath);
481
- if (fileId) {
482
- fileIds.push(fileId);
483
- log('green', `Uploaded: ${filePath} (${fileId})`);
484
- } else {
485
- log('red', `Failed to upload: ${filePath}`);
486
- process.exit(1);
487
- }
488
- }
489
- }
490
-
491
- log('blue', 'Submitting job...');
492
-
493
- const payload = {
494
- script,
495
- type: 'inline',
496
- persistent
497
- };
498
- // Only set workspace_id if not ephemeral
499
- if (!ephemeral && workspace) payload.workspace_id = workspace;
500
- if (name) payload.name = name;
501
- if (startupCommand) payload.startup_command = startupCommand;
502
- if (fileIds.length > 0) payload.file_ids = fileIds;
503
-
504
- const response = await request('POST', '/api/v1/mags-jobs', payload);
505
-
506
- if (!response.request_id) {
507
- log('red', 'Failed to submit job:');
508
- console.log(JSON.stringify(response, null, 2));
509
- process.exit(1);
510
- }
511
-
512
- const requestId = response.request_id;
513
- log('green', `Job submitted: ${requestId}`);
514
- if (name) log('blue', `Name: ${name}`);
515
- if (workspace) log('blue', `Workspace: ${workspace}`);
516
- if (persistent) log('yellow', 'Persistent: VM will stay alive');
517
-
518
- // Poll for completion
519
- const maxAttempts = 120;
520
- let attempt = 0;
521
-
522
- while (attempt < maxAttempts) {
523
- const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
524
-
525
- if (status.status === 'completed') {
526
- log('green', `Completed in ${status.script_duration_ms}ms`);
527
- break;
528
- } else if (status.status === 'running' && persistent) {
529
- log('green', 'VM running');
530
-
531
- if (enableUrl && status.subdomain) {
532
- log('blue', `Enabling URL access on port ${port}...`);
533
- const accessResp = await request('POST', `/api/v1/mags-jobs/${requestId}/access`, { port });
534
- if (accessResp.success) {
535
- log('green', `URL: https://${status.subdomain}.apps.magpiecloud.com`);
536
- } else {
537
- log('yellow', 'Warning: Could not enable URL access');
538
- }
539
- }
540
- return;
541
- } else if (status.status === 'error') {
542
- log('red', 'Job failed');
543
- console.log(JSON.stringify(status, null, 2));
544
- process.exit(1);
545
- }
546
-
547
- process.stdout.write('.');
548
- await sleep(1000);
549
- attempt++;
550
- }
551
-
552
- console.log('');
553
-
554
- // Get logs
555
- log('cyan', 'Output:');
556
- const logsResp = await request('GET', `/api/v1/mags-jobs/${requestId}/logs`);
557
- if (logsResp.logs) {
558
- logsResp.logs
559
- .filter(l => l.source === 'stdout' || l.source === 'stderr')
560
- .forEach(l => console.log(l.message));
561
- }
562
- }
563
-
564
- async function enableUrlAccess(nameOrId, port = 8080) {
565
- if (!nameOrId) {
566
- log('red', 'Error: Job name or ID required');
567
- usage();
568
- }
569
-
570
- const requestId = await resolveJobId(nameOrId);
571
- log('blue', `Enabling URL access on port ${port}...`);
572
-
573
- const accessResp = await request('POST', `/api/v1/mags-jobs/${requestId}/access`, { port });
574
-
575
- if (accessResp.success) {
576
- const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
577
- if (status.subdomain) {
578
- log('green', `URL enabled: https://${status.subdomain}.apps.magpiecloud.com`);
579
- } else {
580
- log('green', 'URL access enabled');
581
- }
582
- } else {
583
- log('red', 'Failed to enable URL access');
584
- console.log(JSON.stringify(accessResp, null, 2));
585
- process.exit(1);
586
- }
587
- }
588
-
589
- async function getStatus(nameOrId) {
590
- if (!nameOrId) {
591
- log('red', 'Error: Job name or ID required');
592
- usage();
593
- }
594
- const requestId = await resolveJobId(nameOrId);
595
- const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
596
- console.log(JSON.stringify(status, null, 2));
597
- }
598
-
599
- async function getLogs(nameOrId) {
600
- if (!nameOrId) {
601
- log('red', 'Error: Job name or ID required');
602
- usage();
603
- }
604
- const requestId = await resolveJobId(nameOrId);
605
- const logsResp = await request('GET', `/api/v1/mags-jobs/${requestId}/logs`);
606
- if (logsResp.logs) {
607
- logsResp.logs.forEach(l => {
608
- const levelColor = l.level === 'error' ? 'red' : l.level === 'warn' ? 'yellow' : 'gray';
609
- console.log(`${colors[levelColor]}[${l.level}]${colors.reset} ${l.message}`);
610
- });
611
- }
612
- }
613
-
614
- async function listJobs() {
615
- const resp = await request('GET', '/api/v1/mags-jobs?page=1&page_size=10');
616
- if (resp.jobs && resp.jobs.length > 0) {
617
- log('cyan', 'Recent Jobs:\n');
618
- resp.jobs.forEach(job => {
619
- const statusColor = job.status === 'completed' ? 'green'
620
- : job.status === 'running' ? 'blue'
621
- : job.status === 'error' ? 'red'
622
- : 'yellow';
623
- console.log(`${colors.gray}${job.request_id}${colors.reset}`);
624
- console.log(` Name: ${job.name || '-'}`);
625
- console.log(` Status: ${colors[statusColor]}${job.status}${colors.reset}`);
626
- console.log(` Workspace: ${job.workspace_id || '-'}`);
627
- console.log(` Duration: ${job.script_duration_ms ? job.script_duration_ms + 'ms' : '-'}`);
628
- console.log(` Created: ${job.created_at || '-'}`);
629
- console.log('');
630
- });
631
- } else {
632
- log('yellow', 'No jobs found');
633
- }
634
- }
635
-
636
- async function stopJob(nameOrId) {
637
- if (!nameOrId) {
638
- log('red', 'Error: Job name or ID required');
639
- usage();
640
- }
641
- const requestId = await resolveJobId(nameOrId);
642
- log('blue', `Stopping job ${requestId}...`);
643
- const resp = await request('POST', `/api/v1/mags-jobs/${requestId}/stop`);
644
- if (resp.success) {
645
- log('green', 'Job stopped');
646
- } else {
647
- log('red', 'Failed to stop job');
648
- console.log(JSON.stringify(resp, null, 2));
649
- }
650
- }
651
-
652
- // Download a file from URL
653
- function downloadFile(url) {
654
- return new Promise((resolve, reject) => {
655
- const urlObj = new URL(url);
656
- const lib = urlObj.protocol === 'https:' ? https : http;
657
-
658
- lib.get(url, (res) => {
659
- // Handle redirects
660
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
661
- downloadFile(res.headers.location).then(resolve).catch(reject);
662
- return;
663
- }
664
-
665
- if (res.statusCode !== 200) {
666
- reject(new Error(`Failed to download: HTTP ${res.statusCode}`));
667
- return;
668
- }
669
-
670
- let data = '';
671
- res.on('data', chunk => data += chunk);
672
- res.on('end', () => resolve(data));
673
- }).on('error', reject);
674
- });
675
- }
676
-
677
- async function setupClaude() {
678
- console.log(`
679
- ${colors.cyan}${colors.bold}Mags Claude Code Skill Setup${colors.reset}
680
-
681
- This will install the Mags skill for Claude Code, allowing you to run
682
- scripts on instant VMs directly from Claude.
683
- `);
684
-
685
- // Ask where to install
686
- console.log(`${colors.bold}Installation options:${colors.reset}`);
687
- console.log(` 1. Global (all projects) - ~/.claude/commands/`);
688
- console.log(` 2. Current project only - ./.claude/commands/`);
689
- console.log('');
690
-
691
- const choice = await prompt(`${colors.bold}Choose installation type [1/2]: ${colors.reset}`);
692
-
693
- let targetDir;
694
- if (choice === '2') {
695
- targetDir = path.join(process.cwd(), '.claude', 'commands');
696
- log('blue', 'Installing to current project...');
697
- } else {
698
- targetDir = CLAUDE_GLOBAL_DIR;
699
- log('blue', 'Installing globally...');
700
- }
701
-
702
- // Create directory if it doesn't exist
703
- try {
704
- if (!fs.existsSync(targetDir)) {
705
- fs.mkdirSync(targetDir, { recursive: true });
706
- }
707
- } catch (err) {
708
- log('red', `Failed to create directory: ${err.message}`);
709
- process.exit(1);
710
- }
711
-
712
- // Download skill file
713
- log('blue', `Downloading skill from ${SKILL_URL}...`);
714
-
715
- try {
716
- const skillContent = await downloadFile(SKILL_URL);
717
-
718
- const targetFile = path.join(targetDir, 'mags.md');
719
- fs.writeFileSync(targetFile, skillContent);
720
-
721
- console.log('');
722
- log('green', 'Mags skill installed successfully!');
723
- log('gray', `Location: ${targetFile}`);
724
- console.log('');
725
- log('cyan', 'Next steps:');
726
- console.log(` 1. Make sure you're logged in: ${colors.bold}mags login${colors.reset}`);
727
- console.log(` 2. In Claude Code, use: ${colors.bold}/mags <your request>${colors.reset}`);
728
- console.log('');
729
- log('cyan', 'Example prompts:');
730
- console.log(' /mags run echo Hello World');
731
- console.log(' /mags create a python environment with numpy');
732
- console.log(' /mags run a flask server and give me the URL');
733
-
734
- } catch (err) {
735
- log('red', `Failed to download skill: ${err.message}`);
736
- log('gray', `You can manually download from: ${SKILL_URL}`);
737
- process.exit(1);
738
- }
739
- }
740
-
741
- // Upload file via multipart form data
742
- async function uploadFile(filePath) {
743
- if (!fs.existsSync(filePath)) {
744
- log('red', `File not found: ${filePath}`);
745
- return null;
746
- }
747
-
748
- const fileName = path.basename(filePath);
749
- const fileData = fs.readFileSync(filePath);
750
- const boundary = '----MagsBoundary' + Date.now().toString(16);
751
-
752
- // Build multipart body
753
- const parts = [];
754
- parts.push(`--${boundary}\r\n`);
755
- parts.push(`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`);
756
- parts.push(`Content-Type: application/octet-stream\r\n\r\n`);
757
- const header = Buffer.from(parts.join(''));
758
- const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
759
- const body = Buffer.concat([header, fileData, footer]);
760
-
761
- return new Promise((resolve, reject) => {
762
- const url = new URL('/api/v1/mags-files', API_URL);
763
- const isHttps = url.protocol === 'https:';
764
- const lib = isHttps ? https : http;
765
-
766
- const options = {
767
- hostname: url.hostname,
768
- port: url.port || (isHttps ? 443 : 80),
769
- path: url.pathname,
770
- method: 'POST',
771
- headers: {
772
- 'Authorization': `Bearer ${API_TOKEN}`,
773
- 'Content-Type': `multipart/form-data; boundary=${boundary}`,
774
- 'Content-Length': body.length
775
- }
776
- };
777
-
778
- const req = lib.request(options, (res) => {
779
- let data = '';
780
- res.on('data', chunk => data += chunk);
781
- res.on('end', () => {
782
- try {
783
- const parsed = JSON.parse(data);
784
- if (parsed.file_id) {
785
- resolve(parsed.file_id);
786
- } else {
787
- resolve(null);
788
- }
789
- } catch {
790
- resolve(null);
791
- }
792
- });
793
- });
794
-
795
- req.on('error', () => resolve(null));
796
- req.write(body);
797
- req.end();
798
- });
799
- }
800
-
801
- // Cron job management
802
- async function cronCommand(args) {
803
- if (args.length === 0) {
804
- log('red', 'Error: Cron subcommand required (add, list, remove, enable, disable)');
805
- usage();
806
- return;
807
- }
808
-
809
- const subcommand = args[0];
810
- const subArgs = args.slice(1);
811
-
812
- switch (subcommand) {
813
- case 'add':
814
- await cronAdd(subArgs);
815
- break;
816
- case 'list':
817
- case 'ls':
818
- await cronList();
819
- break;
820
- case 'remove':
821
- case 'rm':
822
- case 'delete':
823
- if (!subArgs[0]) {
824
- log('red', 'Error: Cron job ID required');
825
- process.exit(1);
826
- }
827
- await cronRemove(subArgs[0]);
828
- break;
829
- case 'enable':
830
- if (!subArgs[0]) {
831
- log('red', 'Error: Cron job ID required');
832
- process.exit(1);
833
- }
834
- await cronToggle(subArgs[0], true);
835
- break;
836
- case 'disable':
837
- if (!subArgs[0]) {
838
- log('red', 'Error: Cron job ID required');
839
- process.exit(1);
840
- }
841
- await cronToggle(subArgs[0], false);
842
- break;
843
- default:
844
- log('red', `Unknown cron subcommand: ${subcommand}`);
845
- usage();
846
- }
847
- }
848
-
849
- async function cronAdd(args) {
850
- let cronName = '';
851
- let schedule = '';
852
- let workspace = '';
853
- let persistent = false;
854
- let script = '';
855
-
856
- for (let i = 0; i < args.length; i++) {
857
- switch (args[i]) {
858
- case '--name':
859
- cronName = args[++i];
860
- break;
861
- case '--schedule':
862
- schedule = args[++i];
863
- break;
864
- case '-w':
865
- case '--workspace':
866
- workspace = args[++i];
867
- break;
868
- case '-p':
869
- case '--persistent':
870
- persistent = true;
871
- break;
872
- default:
873
- script = args.slice(i).join(' ');
874
- i = args.length;
875
- }
876
- }
877
-
878
- if (!cronName) {
879
- log('red', 'Error: --name is required for cron jobs');
880
- process.exit(1);
881
- }
882
- if (!schedule) {
883
- log('red', 'Error: --schedule is required (e.g. "0 * * * *")');
884
- process.exit(1);
885
- }
886
- if (!script) {
887
- log('red', 'Error: Script is required');
888
- process.exit(1);
889
- }
890
-
891
- const payload = {
892
- name: cronName,
893
- cron_expression: schedule,
894
- script,
895
- persistent
896
- };
897
- if (workspace) payload.workspace_id = workspace;
898
-
899
- const resp = await request('POST', '/api/v1/mags-cron', payload);
900
- if (resp.id) {
901
- log('green', `Cron job created: ${resp.id}`);
902
- log('blue', `Name: ${cronName}`);
903
- log('blue', `Schedule: ${schedule}`);
904
- if (resp.next_run_at) log('blue', `Next run: ${resp.next_run_at}`);
905
- } else {
906
- log('red', 'Failed to create cron job:');
907
- console.log(JSON.stringify(resp, null, 2));
908
- process.exit(1);
909
- }
910
- }
911
-
912
- async function cronList() {
913
- const resp = await request('GET', '/api/v1/mags-cron');
914
- if (resp.cron_jobs && resp.cron_jobs.length > 0) {
915
- log('cyan', 'Cron Jobs:\n');
916
- resp.cron_jobs.forEach(cron => {
917
- const statusColor = cron.enabled ? 'green' : 'yellow';
918
- console.log(`${colors.gray}${cron.id}${colors.reset}`);
919
- console.log(` Name: ${cron.name}`);
920
- console.log(` Schedule: ${cron.cron_expression}`);
921
- console.log(` Enabled: ${colors[statusColor]}${cron.enabled}${colors.reset}`);
922
- console.log(` Workspace: ${cron.workspace_id || '-'}`);
923
- console.log(` Runs: ${cron.run_count || 0}`);
924
- console.log(` Last Run: ${cron.last_run_at || '-'}`);
925
- console.log(` Next Run: ${cron.next_run_at || '-'}`);
926
- console.log(` Last Status: ${cron.last_status || '-'}`);
927
- console.log('');
928
- });
929
- } else {
930
- log('yellow', 'No cron jobs found');
931
- }
932
- }
933
-
934
- async function cronRemove(id) {
935
- const resp = await request('DELETE', `/api/v1/mags-cron/${id}`);
936
- if (resp.success) {
937
- log('green', 'Cron job deleted');
938
- } else {
939
- log('red', 'Failed to delete cron job');
940
- console.log(JSON.stringify(resp, null, 2));
941
- }
942
- }
943
-
944
- async function cronToggle(id, enabled) {
945
- const resp = await request('PATCH', `/api/v1/mags-cron/${id}`, { enabled });
946
- if (resp.id) {
947
- log('green', `Cron job ${enabled ? 'enabled' : 'disabled'}`);
948
- if (resp.next_run_at) log('blue', `Next run: ${resp.next_run_at}`);
949
- } else {
950
- log('red', `Failed to ${enabled ? 'enable' : 'disable'} cron job`);
951
- console.log(JSON.stringify(resp, null, 2));
952
- }
953
- }
954
-
955
- async function sshToJob(nameOrId) {
956
- if (!nameOrId) {
957
- log('red', 'Error: Job name or ID required');
958
- console.log(`\nUsage: mags ssh <name|id>\n`);
959
- console.log('Examples:');
960
- console.log(' mags ssh myvm');
961
- console.log(' mags ssh 7bd12031-25ff-497f-b753-7ae73ce10317');
962
- console.log('');
963
- console.log('Get job names/IDs with: mags list');
964
- process.exit(1);
965
- }
966
-
967
- // Resolve name to ID
968
- const requestId = await resolveJobId(nameOrId);
969
-
970
- // First check job status
971
- log('blue', 'Checking job status...');
972
- const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
973
-
974
- if (status.error) {
975
- log('red', `Error: ${status.error}`);
976
- process.exit(1);
977
- }
978
-
979
- if (status.status !== 'running' && status.status !== 'sleeping') {
980
- log('red', `Cannot SSH to job with status: ${status.status}`);
981
- log('gray', 'Job must be running or sleeping (persistent)');
982
- process.exit(1);
983
- }
984
-
985
- // Enable SSH access (port 22)
986
- log('blue', 'Enabling SSH access...');
987
- const accessResp = await request('POST', `/api/v1/mags-jobs/${requestId}/access`, { port: 22 });
988
-
989
- if (!accessResp.success) {
990
- log('red', 'Failed to enable SSH access');
991
- if (accessResp.error) {
992
- log('red', accessResp.error);
993
- }
994
- process.exit(1);
995
- }
996
-
997
- const sshHost = accessResp.ssh_host;
998
- const sshPort = accessResp.ssh_port;
999
- const sshKey = accessResp.ssh_private_key;
1000
-
1001
- if (!sshHost || !sshPort) {
1002
- log('red', 'SSH access enabled but no connection details returned');
1003
- console.log(JSON.stringify(accessResp, null, 2));
1004
- process.exit(1);
1005
- }
1006
-
1007
- log('green', `Connecting to ${sshHost}:${sshPort}...`);
1008
- console.log(`${colors.gray}(Use Ctrl+D or 'exit' to disconnect)${colors.reset}\n`);
1009
-
1010
- // Build SSH arguments
1011
- const sshArgs = [
1012
- '-tt', // Force TTY allocation even when stdin isn't a terminal
1013
- '-o', 'StrictHostKeyChecking=no',
1014
- '-o', 'UserKnownHostsFile=/dev/null',
1015
- '-o', 'LogLevel=ERROR',
1016
- '-p', sshPort.toString()
1017
- ];
1018
-
1019
- // If API returned an SSH key, write it to a temp file and use it
1020
- let keyFile = null;
1021
- if (sshKey) {
1022
- keyFile = path.join(os.tmpdir(), `mags_ssh_${Date.now()}`);
1023
- fs.writeFileSync(keyFile, sshKey, { mode: 0o600 });
1024
- sshArgs.push('-i', keyFile);
1025
- }
1026
-
1027
- sshArgs.push(`root@${sshHost}`);
1028
-
1029
- const ssh = spawn('ssh', sshArgs, {
1030
- stdio: 'inherit' // Inherit stdin/stdout/stderr for interactive session
1031
- });
1032
-
1033
- ssh.on('error', (err) => {
1034
- if (keyFile) {
1035
- try { fs.unlinkSync(keyFile); } catch (e) {}
1036
- }
1037
- if (err.code === 'ENOENT') {
1038
- log('red', 'SSH client not found. Please install OpenSSH.');
1039
- log('gray', 'On macOS/Linux: ssh is usually pre-installed');
1040
- log('gray', 'On Windows: Install OpenSSH or use WSL');
1041
- } else {
1042
- log('red', `SSH error: ${err.message}`);
1043
- }
1044
- process.exit(1);
1045
- });
1046
-
1047
- ssh.on('close', (code) => {
1048
- // Clean up temp key file
1049
- if (keyFile) {
1050
- try { fs.unlinkSync(keyFile); } catch (e) {}
1051
- }
1052
- if (code === 0) {
1053
- log('green', '\nSSH session ended');
1054
- } else {
1055
- log('yellow', `\nSSH session ended with code ${code}`);
1056
- }
1057
- process.exit(code || 0);
1058
- });
1059
- }
1060
-
1061
- async function main() {
1062
- const args = process.argv.slice(2);
1063
- const command = args[0];
1064
-
1065
- // Commands that don't require auth
1066
- const noAuthCommands = ['login', '--help', '-h', '--version', '-v'];
1067
-
1068
- try {
1069
- switch (command) {
1070
- case 'login':
1071
- await login();
1072
- break;
1073
- case 'logout':
1074
- await logout();
1075
- break;
1076
- case 'whoami':
1077
- await whoami();
1078
- break;
1079
- case '--help':
1080
- case '-h':
1081
- usage();
1082
- break;
1083
- case '--version':
1084
- case '-v':
1085
- console.log('mags v1.5.1');
1086
- process.exit(0);
1087
- break;
1088
- case 'new':
1089
- await requireAuth();
1090
- await newVM(args[1]);
1091
- break;
1092
- case 'run':
1093
- await requireAuth();
1094
- await runJob(args.slice(1));
1095
- break;
1096
- case 'ssh':
1097
- await requireAuth();
1098
- await sshToJob(args[1]);
1099
- break;
1100
- case 'url':
1101
- await requireAuth();
1102
- await enableUrlAccess(args[1], parseInt(args[2]) || 8080);
1103
- break;
1104
- case 'status':
1105
- await requireAuth();
1106
- await getStatus(args[1]);
1107
- break;
1108
- case 'logs':
1109
- await requireAuth();
1110
- await getLogs(args[1]);
1111
- break;
1112
- case 'list':
1113
- await requireAuth();
1114
- await listJobs();
1115
- break;
1116
- case 'stop':
1117
- await requireAuth();
1118
- await stopJob(args[1]);
1119
- break;
1120
- case 'cron':
1121
- await requireAuth();
1122
- await cronCommand(args.slice(1));
1123
- break;
1124
- case 'setup-claude':
1125
- await setupClaude();
1126
- break;
1127
- default:
1128
- if (!command) {
1129
- // No command - check if logged in
1130
- if (!API_TOKEN) {
1131
- await requireAuth();
1132
- } else {
1133
- usage();
1134
- }
1135
- } else {
1136
- log('red', `Unknown command: ${command}`);
1137
- usage();
1138
- }
1139
- }
1140
- } catch (err) {
1141
- log('red', `Error: ${err.message}`);
1142
- process.exit(1);
1143
- }
1144
- }
1145
-
1146
- main();