@magpiecloud/mags 1.0.0

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 (4) hide show
  1. package/README.md +111 -0
  2. package/bin/mags.js +351 -0
  3. package/index.js +180 -0
  4. package/package.json +40 -0
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # @magpiecloud/mags
2
+
3
+ Execute scripts instantly on Magpie's microVM infrastructure. VMs boot in <100ms from a warm pool.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g @magpiecloud/mags
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ### 1. Set your API token
14
+
15
+ ```bash
16
+ export MAGS_API_TOKEN="your-token-here"
17
+ ```
18
+
19
+ ### 2. Run a script
20
+
21
+ ```bash
22
+ mags run 'echo Hello World'
23
+ ```
24
+
25
+ ## CLI Commands
26
+
27
+ ```bash
28
+ # Simple command
29
+ mags run 'echo Hello'
30
+
31
+ # With persistent workspace (S3 sync)
32
+ mags run -w my-project 'apk add nodejs && node --version'
33
+
34
+ # Persistent VM with public URL
35
+ mags run -w webapp -p --url 'python3 -m http.server 8080'
36
+
37
+ # With startup command (for auto-wake)
38
+ mags run -w webapp -p --url --startup-command 'npm start' 'npm install && npm start'
39
+
40
+ # Custom port
41
+ mags run -w webapp -p --url --port 3000 'npm start'
42
+
43
+ # Enable URL for existing job
44
+ mags url <job-id>
45
+ mags url <job-id> 8080
46
+
47
+ # Other commands
48
+ mags status <job-id>
49
+ mags logs <job-id>
50
+ mags list
51
+ mags stop <job-id>
52
+ ```
53
+
54
+ ## CLI Flags
55
+
56
+ | Flag | Description | Default |
57
+ |------|-------------|---------|
58
+ | `-w, --workspace` | Workspace ID for persistent storage | auto |
59
+ | `-p, --persistent` | Keep VM alive for URL/SSH access | false |
60
+ | `--url` | Enable public URL access (requires -p) | false |
61
+ | `--port` | Port to expose for URL access | 8080 |
62
+ | `--startup-command` | Command when VM wakes from sleep | none |
63
+
64
+ ## Node.js SDK
65
+
66
+ ```javascript
67
+ const Mags = require('@magpiecloud/mags');
68
+
69
+ const mags = new Mags({
70
+ apiToken: process.env.MAGS_API_TOKEN
71
+ });
72
+
73
+ // Run and wait for completion
74
+ const result = await mags.runAndWait('echo Hello World');
75
+ console.log(result.logs);
76
+
77
+ // Run with workspace
78
+ const { requestId } = await mags.run('python script.py', {
79
+ workspaceId: 'myproject',
80
+ persistent: true
81
+ });
82
+
83
+ // Get status
84
+ const status = await mags.status(requestId);
85
+
86
+ // Enable URL access
87
+ await mags.enableUrl(requestId, 8080);
88
+
89
+ // List jobs
90
+ const jobs = await mags.list({ page: 1, pageSize: 10 });
91
+
92
+ // Stop a job
93
+ await mags.stop(requestId);
94
+ ```
95
+
96
+ ## Environment Variables
97
+
98
+ | Variable | Description | Default |
99
+ |----------|-------------|---------|
100
+ | `MAGS_API_TOKEN` | Your API token (required) | - |
101
+ | `MAGS_API_URL` | API endpoint | https://api.magpiecloud.com |
102
+
103
+ ## Performance
104
+
105
+ - **Warm start**: <100ms (VM from pool)
106
+ - **Cold start**: ~4 seconds (new VM boot)
107
+ - **Script overhead**: ~50ms
108
+
109
+ ## License
110
+
111
+ MIT
package/bin/mags.js ADDED
@@ -0,0 +1,351 @@
1
+ #!/usr/bin/env node
2
+
3
+ const https = require('https');
4
+ const http = require('http');
5
+ const { URL } = require('url');
6
+
7
+ // Configuration
8
+ const API_URL = process.env.MAGS_API_URL || 'https://api.magpiecloud.com';
9
+ const API_TOKEN = process.env.MAGS_API_TOKEN || '';
10
+
11
+ // Colors
12
+ const colors = {
13
+ red: '\x1b[31m',
14
+ green: '\x1b[32m',
15
+ yellow: '\x1b[33m',
16
+ blue: '\x1b[34m',
17
+ cyan: '\x1b[36m',
18
+ gray: '\x1b[90m',
19
+ reset: '\x1b[0m'
20
+ };
21
+
22
+ function log(color, msg) {
23
+ console.log(`${colors[color]}${msg}${colors.reset}`);
24
+ }
25
+
26
+ function request(method, path, body = null) {
27
+ return new Promise((resolve, reject) => {
28
+ const url = new URL(path, API_URL);
29
+ const isHttps = url.protocol === 'https:';
30
+ const lib = isHttps ? https : http;
31
+
32
+ const options = {
33
+ hostname: url.hostname,
34
+ port: url.port || (isHttps ? 443 : 80),
35
+ path: url.pathname + url.search,
36
+ method,
37
+ headers: {
38
+ 'Authorization': `Bearer ${API_TOKEN}`,
39
+ 'Content-Type': 'application/json'
40
+ }
41
+ };
42
+
43
+ const req = lib.request(options, (res) => {
44
+ let data = '';
45
+ res.on('data', chunk => data += chunk);
46
+ res.on('end', () => {
47
+ try {
48
+ resolve(JSON.parse(data));
49
+ } catch {
50
+ resolve(data);
51
+ }
52
+ });
53
+ });
54
+
55
+ req.on('error', reject);
56
+ if (body) req.write(JSON.stringify(body));
57
+ req.end();
58
+ });
59
+ }
60
+
61
+ function sleep(ms) {
62
+ return new Promise(resolve => setTimeout(resolve, ms));
63
+ }
64
+
65
+ function usage() {
66
+ console.log(`
67
+ ${colors.cyan}Mags CLI - Instant VM Execution${colors.reset}
68
+
69
+ Usage: mags <command> [options] [script]
70
+
71
+ Commands:
72
+ run [options] <script> Execute a script on a microVM
73
+ status <job-id> Get job status
74
+ logs <job-id> Get job logs
75
+ list List recent jobs
76
+ url <job-id> [port] Enable URL access for a job
77
+ stop <job-id> Stop a running job
78
+
79
+ Run Options:
80
+ -w, --workspace <id> Use persistent workspace (S3 sync)
81
+ -p, --persistent Keep VM alive after script completes
82
+ --url Enable public URL access (requires -p)
83
+ --port <port> Port to expose for URL (default: 8080)
84
+ --startup-command <cmd> Command to run when VM wakes from sleep
85
+
86
+ Environment:
87
+ MAGS_API_TOKEN Your API token (required)
88
+ MAGS_API_URL API endpoint (default: https://api.magpiecloud.com)
89
+
90
+ Examples:
91
+ mags run 'echo Hello World'
92
+ mags run -w myproject 'python3 script.py'
93
+ mags run -p --url 'python3 -m http.server 8080'
94
+ mags run -w webapp -p --url --port 3000 'npm start'
95
+ mags status abc123
96
+ mags logs abc123
97
+ mags url abc123 8080
98
+ `);
99
+ process.exit(1);
100
+ }
101
+
102
+ async function runJob(args) {
103
+ let script = '';
104
+ let workspace = '';
105
+ let persistent = false;
106
+ let enableUrl = false;
107
+ let port = 8080;
108
+ let startupCommand = '';
109
+
110
+ // Parse flags
111
+ for (let i = 0; i < args.length; i++) {
112
+ switch (args[i]) {
113
+ case '-w':
114
+ case '--workspace':
115
+ workspace = args[++i];
116
+ break;
117
+ case '-p':
118
+ case '--persistent':
119
+ persistent = true;
120
+ break;
121
+ case '--url':
122
+ enableUrl = true;
123
+ break;
124
+ case '--port':
125
+ port = parseInt(args[++i]) || 8080;
126
+ break;
127
+ case '--startup-command':
128
+ startupCommand = args[++i];
129
+ break;
130
+ default:
131
+ script = args.slice(i).join(' ');
132
+ i = args.length;
133
+ }
134
+ }
135
+
136
+ if (!script) {
137
+ log('red', 'Error: No script provided');
138
+ usage();
139
+ }
140
+
141
+ log('blue', 'Submitting job...');
142
+
143
+ const payload = {
144
+ script,
145
+ type: 'inline',
146
+ persistent
147
+ };
148
+ if (workspace) payload.workspace_id = workspace;
149
+ if (startupCommand) payload.startup_command = startupCommand;
150
+
151
+ const response = await request('POST', '/api/v1/mags-jobs', payload);
152
+
153
+ if (!response.request_id) {
154
+ log('red', 'Failed to submit job:');
155
+ console.log(JSON.stringify(response, null, 2));
156
+ process.exit(1);
157
+ }
158
+
159
+ const requestId = response.request_id;
160
+ log('green', `Job submitted: ${requestId}`);
161
+ if (workspace) log('blue', `Workspace: ${workspace}`);
162
+ if (persistent) log('yellow', 'Persistent: VM will stay alive');
163
+
164
+ // Poll for completion
165
+ const maxAttempts = 120;
166
+ let attempt = 0;
167
+
168
+ while (attempt < maxAttempts) {
169
+ const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
170
+
171
+ if (status.status === 'completed') {
172
+ log('green', `Completed in ${status.script_duration_ms}ms`);
173
+ break;
174
+ } else if (status.status === 'running' && persistent) {
175
+ log('green', 'VM running');
176
+
177
+ if (enableUrl && status.subdomain) {
178
+ log('blue', `Enabling URL access on port ${port}...`);
179
+ const accessResp = await request('POST', `/api/v1/mags-jobs/${requestId}/access`, { port });
180
+ if (accessResp.success) {
181
+ log('green', `URL: https://${status.subdomain}.apps.magpiecloud.com`);
182
+ } else {
183
+ log('yellow', 'Warning: Could not enable URL access');
184
+ }
185
+ } else if (status.subdomain) {
186
+ log('cyan', `Subdomain: ${status.subdomain}`);
187
+ log('cyan', `To enable URL: mags url ${requestId} ${port}`);
188
+ }
189
+ return;
190
+ } else if (status.status === 'error') {
191
+ log('red', 'Job failed');
192
+ console.log(JSON.stringify(status, null, 2));
193
+ process.exit(1);
194
+ }
195
+
196
+ process.stdout.write('.');
197
+ await sleep(1000);
198
+ attempt++;
199
+ }
200
+
201
+ console.log('');
202
+
203
+ // Get logs
204
+ log('cyan', 'Output:');
205
+ const logsResp = await request('GET', `/api/v1/mags-jobs/${requestId}/logs`);
206
+ if (logsResp.logs) {
207
+ logsResp.logs
208
+ .filter(l => l.source === 'stdout' || l.source === 'stderr')
209
+ .forEach(l => console.log(l.message));
210
+ }
211
+ }
212
+
213
+ async function enableUrlAccess(requestId, port = 8080) {
214
+ if (!requestId) {
215
+ log('red', 'Error: Job ID required');
216
+ usage();
217
+ }
218
+
219
+ log('blue', `Enabling URL access on port ${port}...`);
220
+
221
+ const accessResp = await request('POST', `/api/v1/mags-jobs/${requestId}/access`, { port });
222
+
223
+ if (accessResp.success) {
224
+ const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
225
+ if (status.subdomain) {
226
+ log('green', `URL enabled: https://${status.subdomain}.apps.magpiecloud.com`);
227
+ } else {
228
+ log('green', 'URL access enabled');
229
+ }
230
+ } else {
231
+ log('red', 'Failed to enable URL access');
232
+ console.log(JSON.stringify(accessResp, null, 2));
233
+ process.exit(1);
234
+ }
235
+ }
236
+
237
+ async function getStatus(requestId) {
238
+ if (!requestId) {
239
+ log('red', 'Error: Job ID required');
240
+ usage();
241
+ }
242
+ const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
243
+ console.log(JSON.stringify(status, null, 2));
244
+ }
245
+
246
+ async function getLogs(requestId) {
247
+ if (!requestId) {
248
+ log('red', 'Error: Job ID required');
249
+ usage();
250
+ }
251
+ const logsResp = await request('GET', `/api/v1/mags-jobs/${requestId}/logs`);
252
+ if (logsResp.logs) {
253
+ logsResp.logs.forEach(l => {
254
+ const levelColor = l.level === 'error' ? 'red' : l.level === 'warn' ? 'yellow' : 'gray';
255
+ console.log(`${colors[levelColor]}[${l.level}]${colors.reset} ${l.message}`);
256
+ });
257
+ }
258
+ }
259
+
260
+ async function listJobs() {
261
+ const resp = await request('GET', '/api/v1/mags-jobs?page=1&page_size=10');
262
+ if (resp.jobs && resp.jobs.length > 0) {
263
+ log('cyan', 'Recent Jobs:\n');
264
+ resp.jobs.forEach(job => {
265
+ const statusColor = job.status === 'completed' ? 'green'
266
+ : job.status === 'running' ? 'blue'
267
+ : job.status === 'error' ? 'red'
268
+ : 'yellow';
269
+ console.log(`${colors.gray}${job.request_id}${colors.reset}`);
270
+ console.log(` Name: ${job.name || '-'}`);
271
+ console.log(` Status: ${colors[statusColor]}${job.status}${colors.reset}`);
272
+ console.log(` Workspace: ${job.workspace_id || '-'}`);
273
+ console.log(` Duration: ${job.script_duration_ms ? job.script_duration_ms + 'ms' : '-'}`);
274
+ console.log(` Created: ${job.created_at || '-'}`);
275
+ console.log('');
276
+ });
277
+ } else {
278
+ log('yellow', 'No jobs found');
279
+ }
280
+ }
281
+
282
+ async function stopJob(requestId) {
283
+ if (!requestId) {
284
+ log('red', 'Error: Job ID required');
285
+ usage();
286
+ }
287
+ log('blue', `Stopping job ${requestId}...`);
288
+ const resp = await request('POST', `/api/v1/mags-jobs/${requestId}/stop`);
289
+ if (resp.success) {
290
+ log('green', 'Job stopped');
291
+ } else {
292
+ log('red', 'Failed to stop job');
293
+ console.log(JSON.stringify(resp, null, 2));
294
+ }
295
+ }
296
+
297
+ async function main() {
298
+ const args = process.argv.slice(2);
299
+
300
+ if (!API_TOKEN) {
301
+ log('red', 'Error: MAGS_API_TOKEN not set');
302
+ console.log('Set it via: export MAGS_API_TOKEN=your-token');
303
+ process.exit(1);
304
+ }
305
+
306
+ const command = args[0];
307
+
308
+ try {
309
+ switch (command) {
310
+ case 'run':
311
+ await runJob(args.slice(1));
312
+ break;
313
+ case 'url':
314
+ await enableUrlAccess(args[1], parseInt(args[2]) || 8080);
315
+ break;
316
+ case 'status':
317
+ await getStatus(args[1]);
318
+ break;
319
+ case 'logs':
320
+ await getLogs(args[1]);
321
+ break;
322
+ case 'list':
323
+ await listJobs();
324
+ break;
325
+ case 'stop':
326
+ await stopJob(args[1]);
327
+ break;
328
+ case '--help':
329
+ case '-h':
330
+ case '--version':
331
+ case '-v':
332
+ if (command === '--version' || command === '-v') {
333
+ console.log('mags v1.0.0');
334
+ process.exit(0);
335
+ }
336
+ usage();
337
+ break;
338
+ default:
339
+ if (!command) {
340
+ usage();
341
+ }
342
+ log('red', `Unknown command: ${command}`);
343
+ usage();
344
+ }
345
+ } catch (err) {
346
+ log('red', `Error: ${err.message}`);
347
+ process.exit(1);
348
+ }
349
+ }
350
+
351
+ main();
package/index.js ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Mags SDK - Execute scripts on Magpie's instant VM infrastructure
3
+ * @module @magpiecloud/mags
4
+ */
5
+
6
+ const https = require('https');
7
+ const http = require('http');
8
+ const { URL } = require('url');
9
+
10
+ class Mags {
11
+ /**
12
+ * Create a Mags client
13
+ * @param {object} options - Configuration options
14
+ * @param {string} options.apiUrl - API endpoint (default: https://api.magpiecloud.com)
15
+ * @param {string} options.apiToken - API token (required, or set MAGS_API_TOKEN env var)
16
+ */
17
+ constructor(options = {}) {
18
+ this.apiUrl = options.apiUrl || process.env.MAGS_API_URL || 'https://api.magpiecloud.com';
19
+ this.apiToken = options.apiToken || process.env.MAGS_API_TOKEN;
20
+
21
+ if (!this.apiToken) {
22
+ throw new Error('API token required. Set MAGS_API_TOKEN or pass apiToken option.');
23
+ }
24
+ }
25
+
26
+ _request(method, path, body = null) {
27
+ return new Promise((resolve, reject) => {
28
+ const url = new URL(path, this.apiUrl);
29
+ const isHttps = url.protocol === 'https:';
30
+ const lib = isHttps ? https : http;
31
+
32
+ const options = {
33
+ hostname: url.hostname,
34
+ port: url.port || (isHttps ? 443 : 80),
35
+ path: url.pathname + url.search,
36
+ method,
37
+ headers: {
38
+ 'Authorization': `Bearer ${this.apiToken}`,
39
+ 'Content-Type': 'application/json'
40
+ }
41
+ };
42
+
43
+ const req = lib.request(options, (res) => {
44
+ let data = '';
45
+ res.on('data', chunk => data += chunk);
46
+ res.on('end', () => {
47
+ try {
48
+ const parsed = JSON.parse(data);
49
+ if (res.statusCode >= 400) {
50
+ reject(new Error(parsed.error || parsed.message || `HTTP ${res.statusCode}`));
51
+ } else {
52
+ resolve(parsed);
53
+ }
54
+ } catch {
55
+ resolve(data);
56
+ }
57
+ });
58
+ });
59
+
60
+ req.on('error', reject);
61
+ if (body) req.write(JSON.stringify(body));
62
+ req.end();
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Submit a job for execution
68
+ * @param {string} script - Script to execute
69
+ * @param {object} options - Job options
70
+ * @param {string} options.name - Job name
71
+ * @param {string} options.workspaceId - Persistent workspace ID
72
+ * @param {boolean} options.persistent - Keep VM alive after script
73
+ * @param {string} options.startupCommand - Command to run when waking from sleep
74
+ * @param {object} options.environment - Environment variables
75
+ * @returns {Promise<{requestId: string, status: string}>}
76
+ */
77
+ async run(script, options = {}) {
78
+ const payload = {
79
+ script,
80
+ type: 'inline',
81
+ name: options.name,
82
+ workspace_id: options.workspaceId,
83
+ persistent: options.persistent || false,
84
+ startup_command: options.startupCommand,
85
+ environment: options.environment
86
+ };
87
+
88
+ const response = await this._request('POST', '/api/v1/mags-jobs', payload);
89
+ return {
90
+ requestId: response.request_id,
91
+ status: response.status
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Get job status
97
+ * @param {string} requestId - Job request ID
98
+ * @returns {Promise<object>}
99
+ */
100
+ async status(requestId) {
101
+ return this._request('GET', `/api/v1/mags-jobs/${requestId}/status`);
102
+ }
103
+
104
+ /**
105
+ * Get job logs
106
+ * @param {string} requestId - Job request ID
107
+ * @returns {Promise<{logs: Array}>}
108
+ */
109
+ async logs(requestId) {
110
+ return this._request('GET', `/api/v1/mags-jobs/${requestId}/logs`);
111
+ }
112
+
113
+ /**
114
+ * List recent jobs
115
+ * @param {object} options - Pagination options
116
+ * @param {number} options.page - Page number (default: 1)
117
+ * @param {number} options.pageSize - Page size (default: 20)
118
+ * @returns {Promise<{jobs: Array, total: number}>}
119
+ */
120
+ async list(options = {}) {
121
+ const page = options.page || 1;
122
+ const pageSize = options.pageSize || 20;
123
+ return this._request('GET', `/api/v1/mags-jobs?page=${page}&page_size=${pageSize}`);
124
+ }
125
+
126
+ /**
127
+ * Enable URL access for a job
128
+ * @param {string} requestId - Job request ID
129
+ * @param {number} port - Port to expose (default: 8080)
130
+ * @returns {Promise<object>}
131
+ */
132
+ async enableUrl(requestId, port = 8080) {
133
+ return this._request('POST', `/api/v1/mags-jobs/${requestId}/access`, { port });
134
+ }
135
+
136
+ /**
137
+ * Stop a running job
138
+ * @param {string} requestId - Job request ID
139
+ * @returns {Promise<object>}
140
+ */
141
+ async stop(requestId) {
142
+ return this._request('POST', `/api/v1/mags-jobs/${requestId}/stop`);
143
+ }
144
+
145
+ /**
146
+ * Run a job and wait for completion
147
+ * @param {string} script - Script to execute
148
+ * @param {object} options - Job options
149
+ * @param {number} options.timeout - Timeout in ms (default: 60000)
150
+ * @returns {Promise<{status: string, exitCode: number, logs: Array}>}
151
+ */
152
+ async runAndWait(script, options = {}) {
153
+ const timeout = options.timeout || 60000;
154
+ const { requestId } = await this.run(script, options);
155
+
156
+ const startTime = Date.now();
157
+ while (Date.now() - startTime < timeout) {
158
+ const status = await this.status(requestId);
159
+
160
+ if (status.status === 'completed' || status.status === 'error') {
161
+ const logsResp = await this.logs(requestId);
162
+ return {
163
+ requestId,
164
+ status: status.status,
165
+ exitCode: status.exit_code,
166
+ durationMs: status.script_duration_ms,
167
+ logs: logsResp.logs || []
168
+ };
169
+ }
170
+
171
+ await new Promise(resolve => setTimeout(resolve, 1000));
172
+ }
173
+
174
+ throw new Error(`Job ${requestId} timed out after ${timeout}ms`);
175
+ }
176
+ }
177
+
178
+ module.exports = Mags;
179
+ module.exports.Mags = Mags;
180
+ module.exports.default = Mags;
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@magpiecloud/mags",
3
+ "version": "1.0.0",
4
+ "description": "Mags CLI - Execute scripts on Magpie's instant VM infrastructure",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "mags": "./bin/mags.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node bin/mags.js --help"
11
+ },
12
+ "keywords": [
13
+ "magpie",
14
+ "mags",
15
+ "vm",
16
+ "microvm",
17
+ "cloud",
18
+ "serverless",
19
+ "execution",
20
+ "cli"
21
+ ],
22
+ "author": "Magpie Cloud",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/magpiecloud/mags"
27
+ },
28
+ "homepage": "https://magpiecloud.com/docs/mags",
29
+ "bugs": {
30
+ "url": "https://github.com/magpiecloud/mags/issues"
31
+ },
32
+ "engines": {
33
+ "node": ">=14.0.0"
34
+ },
35
+ "files": [
36
+ "index.js",
37
+ "bin/mags.js",
38
+ "README.md"
39
+ ]
40
+ }