@magpiecloud/mags 1.8.8 → 1.8.10
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/index.js +518 -40
- package/nodejs/index.js +369 -53
- package/package.json +2 -6
package/index.js
CHANGED
|
@@ -1,24 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mags SDK - Execute scripts on Magpie's instant VM infrastructure
|
|
3
|
+
* @module @magpiecloud/mags
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
const https = require('https');
|
|
6
7
|
const http = require('http');
|
|
7
8
|
const { URL } = require('url');
|
|
8
9
|
|
|
10
|
+
class MagsError extends Error {
|
|
11
|
+
constructor(message, statusCode) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'MagsError';
|
|
14
|
+
this.statusCode = statusCode || null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
class Mags {
|
|
19
|
+
/**
|
|
20
|
+
* Create a Mags client
|
|
21
|
+
* @param {object} options - Configuration options
|
|
22
|
+
* @param {string} options.apiUrl - API endpoint (default: https://api.magpiecloud.com)
|
|
23
|
+
* @param {string} options.apiToken - API token (required, or set MAGS_API_TOKEN env var)
|
|
24
|
+
* @param {number} options.timeout - Default request timeout in ms (default: 30000)
|
|
25
|
+
*/
|
|
10
26
|
constructor(options = {}) {
|
|
11
|
-
this.apiUrl = options.apiUrl || process.env.MAGS_API_URL || 'https://api.magpiecloud.com';
|
|
12
|
-
this.apiToken = options.apiToken || process.env.MAGS_API_TOKEN;
|
|
27
|
+
this.apiUrl = (options.apiUrl || process.env.MAGS_API_URL || 'https://api.magpiecloud.com').replace(/\/+$/, '');
|
|
28
|
+
this.apiToken = options.apiToken || process.env.MAGS_API_TOKEN || process.env.MAGS_TOKEN;
|
|
29
|
+
this.timeout = options.timeout || 30000;
|
|
13
30
|
|
|
14
31
|
if (!this.apiToken) {
|
|
15
|
-
throw new
|
|
32
|
+
throw new MagsError('API token required. Set MAGS_API_TOKEN or pass apiToken option.');
|
|
16
33
|
}
|
|
17
34
|
}
|
|
18
35
|
|
|
19
|
-
_request(method, path, body = null) {
|
|
36
|
+
_request(method, path, body = null, params = null) {
|
|
20
37
|
return new Promise((resolve, reject) => {
|
|
21
38
|
const url = new URL(path, this.apiUrl);
|
|
39
|
+
if (params) {
|
|
40
|
+
for (const [k, v] of Object.entries(params)) {
|
|
41
|
+
url.searchParams.set(k, v);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
22
44
|
const isHttps = url.protocol === 'https:';
|
|
23
45
|
const lib = isHttps ? https : http;
|
|
24
46
|
|
|
@@ -30,7 +52,8 @@ class Mags {
|
|
|
30
52
|
headers: {
|
|
31
53
|
'Authorization': `Bearer ${this.apiToken}`,
|
|
32
54
|
'Content-Type': 'application/json'
|
|
33
|
-
}
|
|
55
|
+
},
|
|
56
|
+
timeout: this.timeout
|
|
34
57
|
};
|
|
35
58
|
|
|
36
59
|
const req = lib.request(options, (res) => {
|
|
@@ -40,49 +63,110 @@ class Mags {
|
|
|
40
63
|
try {
|
|
41
64
|
const parsed = JSON.parse(data);
|
|
42
65
|
if (res.statusCode >= 400) {
|
|
43
|
-
reject(new
|
|
66
|
+
reject(new MagsError(parsed.error || parsed.message || `HTTP ${res.statusCode}`, res.statusCode));
|
|
44
67
|
} else {
|
|
45
68
|
resolve(parsed);
|
|
46
69
|
}
|
|
47
70
|
} catch {
|
|
48
|
-
|
|
71
|
+
if (res.statusCode >= 400) {
|
|
72
|
+
reject(new MagsError(data || `HTTP ${res.statusCode}`, res.statusCode));
|
|
73
|
+
} else {
|
|
74
|
+
resolve(data);
|
|
75
|
+
}
|
|
49
76
|
}
|
|
50
77
|
});
|
|
51
78
|
});
|
|
52
79
|
|
|
53
80
|
req.on('error', reject);
|
|
81
|
+
req.on('timeout', () => {
|
|
82
|
+
req.destroy();
|
|
83
|
+
reject(new MagsError('Request timed out'));
|
|
84
|
+
});
|
|
54
85
|
if (body) req.write(JSON.stringify(body));
|
|
55
86
|
req.end();
|
|
56
87
|
});
|
|
57
88
|
}
|
|
58
89
|
|
|
90
|
+
// ── Jobs ──────────────────────────────────────────────────────────
|
|
91
|
+
|
|
59
92
|
/**
|
|
60
93
|
* Submit a job for execution
|
|
61
94
|
* @param {string} script - Script to execute
|
|
62
95
|
* @param {object} options - Job options
|
|
63
96
|
* @param {string} options.name - Job name
|
|
64
97
|
* @param {string} options.workspaceId - Persistent workspace ID
|
|
98
|
+
* @param {string} options.baseWorkspaceId - Read-only base workspace to mount
|
|
65
99
|
* @param {boolean} options.persistent - Keep VM alive after script
|
|
100
|
+
* @param {boolean} options.noSleep - Never auto-sleep (requires persistent)
|
|
101
|
+
* @param {boolean} options.ephemeral - No workspace/S3 sync (fastest)
|
|
66
102
|
* @param {string} options.startupCommand - Command to run when waking from sleep
|
|
67
103
|
* @param {object} options.environment - Environment variables
|
|
68
|
-
* @
|
|
104
|
+
* @param {string[]} options.fileIds - File IDs from uploadFiles()
|
|
105
|
+
* @param {number} options.diskGb - Custom disk size in GB (default 2)
|
|
106
|
+
* @returns {Promise<{request_id: string, status: string}>}
|
|
69
107
|
*/
|
|
70
108
|
async run(script, options = {}) {
|
|
109
|
+
if (options.ephemeral && options.workspaceId) {
|
|
110
|
+
throw new MagsError('Cannot use ephemeral with workspaceId');
|
|
111
|
+
}
|
|
112
|
+
if (options.ephemeral && options.persistent) {
|
|
113
|
+
throw new MagsError('Cannot use ephemeral with persistent');
|
|
114
|
+
}
|
|
115
|
+
if (options.noSleep && !options.persistent) {
|
|
116
|
+
throw new MagsError('noSleep requires persistent=true');
|
|
117
|
+
}
|
|
118
|
+
|
|
71
119
|
const payload = {
|
|
72
120
|
script,
|
|
73
121
|
type: 'inline',
|
|
74
|
-
name: options.name,
|
|
75
|
-
workspace_id: options.workspaceId,
|
|
76
122
|
persistent: options.persistent || false,
|
|
77
|
-
startup_command: options.startupCommand,
|
|
78
|
-
environment: options.environment
|
|
79
123
|
};
|
|
80
124
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
125
|
+
if (options.noSleep) payload.no_sleep = true;
|
|
126
|
+
if (options.name) payload.name = options.name;
|
|
127
|
+
if (!options.ephemeral && options.workspaceId) payload.workspace_id = options.workspaceId;
|
|
128
|
+
if (options.baseWorkspaceId) payload.base_workspace_id = options.baseWorkspaceId;
|
|
129
|
+
if (options.startupCommand) payload.startup_command = options.startupCommand;
|
|
130
|
+
if (options.environment) payload.environment = options.environment;
|
|
131
|
+
if (options.fileIds && options.fileIds.length > 0) payload.file_ids = options.fileIds;
|
|
132
|
+
if (options.diskGb) payload.disk_gb = options.diskGb;
|
|
133
|
+
|
|
134
|
+
return this._request('POST', '/api/v1/mags-jobs', payload);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Run a job and wait for completion
|
|
139
|
+
* @param {string} script - Script to execute
|
|
140
|
+
* @param {object} options - Job options (same as run() plus timeout/pollInterval)
|
|
141
|
+
* @param {number} options.timeout - Timeout in ms (default: 60000)
|
|
142
|
+
* @param {number} options.pollInterval - Poll interval in ms (default: 1000)
|
|
143
|
+
* @returns {Promise<{requestId: string, status: string, exitCode: number, durationMs: number, logs: Array}>}
|
|
144
|
+
*/
|
|
145
|
+
async runAndWait(script, options = {}) {
|
|
146
|
+
const timeout = options.timeout || 60000;
|
|
147
|
+
const pollInterval = options.pollInterval || 1000;
|
|
148
|
+
const result = await this.run(script, options);
|
|
149
|
+
const requestId = result.request_id;
|
|
150
|
+
|
|
151
|
+
const startTime = Date.now();
|
|
152
|
+
while (Date.now() - startTime < timeout) {
|
|
153
|
+
const status = await this.status(requestId);
|
|
154
|
+
|
|
155
|
+
if (status.status === 'completed' || status.status === 'error') {
|
|
156
|
+
const logsResp = await this.logs(requestId);
|
|
157
|
+
return {
|
|
158
|
+
requestId,
|
|
159
|
+
status: status.status,
|
|
160
|
+
exitCode: status.exit_code,
|
|
161
|
+
durationMs: status.script_duration_ms,
|
|
162
|
+
logs: logsResp.logs || []
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
throw new MagsError(`Job ${requestId} timed out after ${timeout}ms`);
|
|
86
170
|
}
|
|
87
171
|
|
|
88
172
|
/**
|
|
@@ -113,52 +197,446 @@ class Mags {
|
|
|
113
197
|
async list(options = {}) {
|
|
114
198
|
const page = options.page || 1;
|
|
115
199
|
const pageSize = options.pageSize || 20;
|
|
116
|
-
return this._request('GET', `/api/v1/mags-jobs
|
|
200
|
+
return this._request('GET', `/api/v1/mags-jobs`, null, { page, page_size: pageSize });
|
|
117
201
|
}
|
|
118
202
|
|
|
119
203
|
/**
|
|
120
|
-
*
|
|
204
|
+
* Update a job's settings
|
|
121
205
|
* @param {string} requestId - Job request ID
|
|
122
|
-
* @param {
|
|
206
|
+
* @param {object} options - Settings to update
|
|
207
|
+
* @param {string} options.startupCommand - Command to run when VM wakes from sleep
|
|
208
|
+
* @param {boolean} options.noSleep - If true, VM never auto-sleeps. If false, re-enables auto-sleep.
|
|
209
|
+
* @returns {Promise<object>}
|
|
210
|
+
*/
|
|
211
|
+
async updateJob(requestId, options = {}) {
|
|
212
|
+
const payload = {};
|
|
213
|
+
if (options.startupCommand !== undefined) payload.startup_command = options.startupCommand;
|
|
214
|
+
if (options.noSleep !== undefined) payload.no_sleep = options.noSleep;
|
|
215
|
+
return this._request('PATCH', `/api/v1/mags-jobs/${requestId}`, payload);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Enable URL or SSH access for a job
|
|
220
|
+
* @param {string} requestId - Job request ID
|
|
221
|
+
* @param {number} port - Port to expose (default: 8080, use 22 for SSH)
|
|
123
222
|
* @returns {Promise<object>}
|
|
124
223
|
*/
|
|
125
|
-
async
|
|
224
|
+
async enableAccess(requestId, port = 8080) {
|
|
126
225
|
return this._request('POST', `/api/v1/mags-jobs/${requestId}/access`, { port });
|
|
127
226
|
}
|
|
128
227
|
|
|
129
228
|
/**
|
|
130
|
-
*
|
|
131
|
-
* @param {string}
|
|
132
|
-
* @param {
|
|
133
|
-
* @
|
|
134
|
-
* @returns {Promise<{status: string, exitCode: number, logs: Array}>}
|
|
229
|
+
* Enable public URL access for a job's VM
|
|
230
|
+
* @param {string} nameOrId - Job name, workspace ID, or request ID
|
|
231
|
+
* @param {number} port - Port to expose (default: 8080)
|
|
232
|
+
* @returns {Promise<object>} Object with url and access details
|
|
135
233
|
*/
|
|
136
|
-
async
|
|
137
|
-
const
|
|
138
|
-
const
|
|
234
|
+
async url(nameOrId, port = 8080) {
|
|
235
|
+
const requestId = await this._resolveJobId(nameOrId);
|
|
236
|
+
const st = await this.status(requestId);
|
|
237
|
+
const resp = await this.enableAccess(requestId, port);
|
|
238
|
+
const subdomain = st.subdomain || resp.subdomain;
|
|
239
|
+
if (subdomain) {
|
|
240
|
+
resp.url = `https://${subdomain}.apps.magpiecloud.com`;
|
|
241
|
+
}
|
|
242
|
+
return resp;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Stop a running job. Accepts a job ID, job name, or workspace ID.
|
|
247
|
+
* @param {string} nameOrId - Job name, workspace ID, or request ID
|
|
248
|
+
* @returns {Promise<object>}
|
|
249
|
+
*/
|
|
250
|
+
async stop(nameOrId) {
|
|
251
|
+
const requestId = await this._resolveJobId(nameOrId);
|
|
252
|
+
return this._request('POST', `/api/v1/mags-jobs/${requestId}/stop`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Sync a running job's workspace to S3 without stopping the VM
|
|
257
|
+
* @param {string} requestId - Job request ID
|
|
258
|
+
* @returns {Promise<object>}
|
|
259
|
+
*/
|
|
260
|
+
async sync(requestId) {
|
|
261
|
+
return this._request('POST', `/api/v1/mags-jobs/${requestId}/sync`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Create a new persistent VM workspace and wait until it's running
|
|
266
|
+
* @param {string} name - Workspace name
|
|
267
|
+
* @param {object} options - Options
|
|
268
|
+
* @param {string} options.baseWorkspaceId - Read-only base workspace to mount
|
|
269
|
+
* @param {number} options.diskGb - Custom disk size in GB
|
|
270
|
+
* @param {number} options.timeout - Timeout in ms (default: 30000)
|
|
271
|
+
* @param {number} options.pollInterval - Poll interval in ms (default: 1000)
|
|
272
|
+
* @returns {Promise<{request_id: string, status: string}>}
|
|
273
|
+
*/
|
|
274
|
+
async new(name, options = {}) {
|
|
275
|
+
const timeout = options.timeout || 30000;
|
|
276
|
+
const pollInterval = options.pollInterval || 1000;
|
|
277
|
+
|
|
278
|
+
const result = await this.run('sleep infinity', {
|
|
279
|
+
workspaceId: name,
|
|
280
|
+
persistent: true,
|
|
281
|
+
baseWorkspaceId: options.baseWorkspaceId,
|
|
282
|
+
diskGb: options.diskGb,
|
|
283
|
+
});
|
|
284
|
+
const requestId = result.request_id;
|
|
139
285
|
|
|
140
286
|
const startTime = Date.now();
|
|
141
287
|
while (Date.now() - startTime < timeout) {
|
|
142
|
-
const
|
|
288
|
+
const st = await this.status(requestId);
|
|
289
|
+
if (st.status === 'running' && st.vm_id) {
|
|
290
|
+
return { request_id: requestId, status: 'running' };
|
|
291
|
+
}
|
|
292
|
+
if (st.status === 'completed' || st.status === 'error') {
|
|
293
|
+
throw new MagsError(`Job ${requestId} ended unexpectedly: ${st.status}`);
|
|
294
|
+
}
|
|
295
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
296
|
+
}
|
|
143
297
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
298
|
+
throw new MagsError(`Job ${requestId} did not start within ${timeout}ms`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Find a running or sleeping job by name, workspace ID, or job ID
|
|
303
|
+
* @param {string} nameOrId - Job name, workspace ID, or request ID
|
|
304
|
+
* @returns {Promise<object|null>} The job object, or null if not found
|
|
305
|
+
*/
|
|
306
|
+
async findJob(nameOrId) {
|
|
307
|
+
const resp = await this.list({ pageSize: 50 });
|
|
308
|
+
const jobs = resp.jobs || [];
|
|
309
|
+
|
|
310
|
+
// Priority 1: exact name match, running/sleeping
|
|
311
|
+
for (const j of jobs) {
|
|
312
|
+
if (j.name === nameOrId && (j.status === 'running' || j.status === 'sleeping')) return j;
|
|
313
|
+
}
|
|
314
|
+
// Priority 2: workspace_id match, running/sleeping
|
|
315
|
+
for (const j of jobs) {
|
|
316
|
+
if (j.workspace_id === nameOrId && (j.status === 'running' || j.status === 'sleeping')) return j;
|
|
317
|
+
}
|
|
318
|
+
// Priority 3: exact name match, any status
|
|
319
|
+
for (const j of jobs) {
|
|
320
|
+
if (j.name === nameOrId) return j;
|
|
321
|
+
}
|
|
322
|
+
// Priority 4: workspace_id match, any status
|
|
323
|
+
for (const j of jobs) {
|
|
324
|
+
if (j.workspace_id === nameOrId) return j;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Execute a command on an existing running/sleeping VM via SSH
|
|
332
|
+
* @param {string} nameOrId - Job name, workspace ID, or request ID
|
|
333
|
+
* @param {string} command - Command to execute
|
|
334
|
+
* @param {object} options - Options
|
|
335
|
+
* @param {number} options.timeout - Timeout in ms (default: 30000)
|
|
336
|
+
* @returns {Promise<{exitCode: number, output: string, stderr: string}>}
|
|
337
|
+
*/
|
|
338
|
+
async exec(nameOrId, command, options = {}) {
|
|
339
|
+
const timeout = options.timeout || 30000;
|
|
340
|
+
|
|
341
|
+
const job = await this.findJob(nameOrId);
|
|
342
|
+
if (!job) throw new MagsError(`No running or sleeping VM found for '${nameOrId}'`);
|
|
343
|
+
if (job.status !== 'running' && job.status !== 'sleeping') {
|
|
344
|
+
throw new MagsError(`VM for '${nameOrId}' is ${job.status}, needs to be running or sleeping`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const requestId = job.request_id || job.id;
|
|
348
|
+
const access = await this.enableAccess(requestId, 22);
|
|
349
|
+
|
|
350
|
+
if (!access.success || !access.ssh_host) {
|
|
351
|
+
throw new MagsError(`Failed to enable SSH access: ${access.error || 'unknown error'}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const { execFile } = require('child_process');
|
|
355
|
+
const fs = require('fs');
|
|
356
|
+
const os = require('os');
|
|
357
|
+
const path = require('path');
|
|
358
|
+
|
|
359
|
+
const escaped = command.replace(/'/g, "'\\''");
|
|
360
|
+
const wrapped =
|
|
361
|
+
`if [ -d /overlay/bin ]; then ` +
|
|
362
|
+
`chroot /overlay /bin/sh -l -c 'cd /root 2>/dev/null; ${escaped}'; ` +
|
|
363
|
+
`else cd /root 2>/dev/null; ${escaped}; fi`;
|
|
364
|
+
|
|
365
|
+
let keyFile = null;
|
|
366
|
+
try {
|
|
367
|
+
const sshArgs = [
|
|
368
|
+
'-o', 'StrictHostKeyChecking=no',
|
|
369
|
+
'-o', 'UserKnownHostsFile=/dev/null',
|
|
370
|
+
'-o', 'LogLevel=ERROR',
|
|
371
|
+
'-p', String(access.ssh_port),
|
|
372
|
+
];
|
|
373
|
+
|
|
374
|
+
if (access.ssh_private_key) {
|
|
375
|
+
keyFile = path.join(os.tmpdir(), `mags_ssh_${Date.now()}`);
|
|
376
|
+
fs.writeFileSync(keyFile, access.ssh_private_key, { mode: 0o600 });
|
|
377
|
+
sshArgs.push('-i', keyFile);
|
|
153
378
|
}
|
|
154
379
|
|
|
380
|
+
sshArgs.push(`root@${access.ssh_host}`, wrapped);
|
|
381
|
+
|
|
382
|
+
return new Promise((resolve, reject) => {
|
|
383
|
+
execFile('ssh', sshArgs, { timeout, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
384
|
+
if (keyFile) try { fs.unlinkSync(keyFile); } catch {}
|
|
385
|
+
if (err && err.killed) {
|
|
386
|
+
reject(new MagsError(`Command timed out after ${timeout}ms`));
|
|
387
|
+
} else {
|
|
388
|
+
resolve({
|
|
389
|
+
exitCode: err ? err.code || 1 : 0,
|
|
390
|
+
output: stdout,
|
|
391
|
+
stderr: stderr,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
} catch (e) {
|
|
397
|
+
if (keyFile) try { require('fs').unlinkSync(keyFile); } catch {}
|
|
398
|
+
throw e;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Resize a workspace's disk. Stops the existing VM, then creates a new one.
|
|
404
|
+
* @param {string} workspace - Workspace name
|
|
405
|
+
* @param {number} diskGb - New disk size in GB
|
|
406
|
+
* @param {object} options - Options
|
|
407
|
+
* @param {number} options.timeout - Timeout in ms (default: 30000)
|
|
408
|
+
* @param {number} options.pollInterval - Poll interval in ms (default: 1000)
|
|
409
|
+
* @returns {Promise<{request_id: string, status: string}>}
|
|
410
|
+
*/
|
|
411
|
+
async resize(workspace, diskGb, options = {}) {
|
|
412
|
+
const existing = await this.findJob(workspace);
|
|
413
|
+
if (existing && existing.status === 'running') {
|
|
414
|
+
await this._request('POST', `/api/v1/mags-jobs/${existing.request_id}/sync`);
|
|
415
|
+
await this._request('POST', `/api/v1/mags-jobs/${existing.request_id}/stop`);
|
|
155
416
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
417
|
+
} else if (existing && existing.status === 'sleeping') {
|
|
418
|
+
await this._request('POST', `/api/v1/mags-jobs/${existing.request_id}/stop`);
|
|
419
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return this.new(workspace, { diskGb, timeout: options.timeout, pollInterval: options.pollInterval });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Get aggregated usage summary
|
|
427
|
+
* @param {object} options - Options
|
|
428
|
+
* @param {number} options.windowDays - Time window in days (default: 30)
|
|
429
|
+
* @returns {Promise<object>}
|
|
430
|
+
*/
|
|
431
|
+
async usage(options = {}) {
|
|
432
|
+
return this._request('GET', '/api/v1/mags-jobs/usage', null, { window_days: options.windowDays || 30 });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── Workspaces ────────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* List all workspaces
|
|
439
|
+
* @returns {Promise<{workspaces: Array, total: number}>}
|
|
440
|
+
*/
|
|
441
|
+
async listWorkspaces() {
|
|
442
|
+
return this._request('GET', '/api/v1/mags-workspaces');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Delete a workspace and all its stored data
|
|
447
|
+
* @param {string} workspaceId - Workspace ID to delete
|
|
448
|
+
* @returns {Promise<object>}
|
|
449
|
+
*/
|
|
450
|
+
async deleteWorkspace(workspaceId) {
|
|
451
|
+
return this._request('DELETE', `/api/v1/mags-workspaces/${workspaceId}`);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── File uploads ──────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Upload files for use in a job
|
|
458
|
+
* @param {string[]} filePaths - Array of local file paths
|
|
459
|
+
* @returns {Promise<string[]>} Array of file IDs
|
|
460
|
+
*/
|
|
461
|
+
async uploadFiles(filePaths) {
|
|
462
|
+
const fs = require('fs');
|
|
463
|
+
const path = require('path');
|
|
464
|
+
const fileIds = [];
|
|
465
|
+
|
|
466
|
+
for (const filePath of filePaths) {
|
|
467
|
+
const fileName = path.basename(filePath);
|
|
468
|
+
const fileData = fs.readFileSync(filePath);
|
|
469
|
+
const boundary = '----MagsBoundary' + Date.now().toString(16);
|
|
470
|
+
|
|
471
|
+
const parts = [];
|
|
472
|
+
parts.push(`--${boundary}\r\n`);
|
|
473
|
+
parts.push(`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`);
|
|
474
|
+
parts.push(`Content-Type: application/octet-stream\r\n\r\n`);
|
|
475
|
+
const header = Buffer.from(parts.join(''));
|
|
476
|
+
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
477
|
+
const body = Buffer.concat([header, fileData, footer]);
|
|
478
|
+
|
|
479
|
+
const response = await this._multipartRequest('/api/v1/mags-files', body, boundary);
|
|
480
|
+
if (response.file_id) {
|
|
481
|
+
fileIds.push(response.file_id);
|
|
482
|
+
} else {
|
|
483
|
+
throw new MagsError(`Failed to upload file: ${fileName}`);
|
|
484
|
+
}
|
|
156
485
|
}
|
|
157
486
|
|
|
158
|
-
|
|
487
|
+
return fileIds;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
_multipartRequest(apiPath, body, boundary) {
|
|
491
|
+
return new Promise((resolve, reject) => {
|
|
492
|
+
const url = new URL(apiPath, this.apiUrl);
|
|
493
|
+
const isHttps = url.protocol === 'https:';
|
|
494
|
+
const lib = isHttps ? https : http;
|
|
495
|
+
|
|
496
|
+
const options = {
|
|
497
|
+
hostname: url.hostname,
|
|
498
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
499
|
+
path: url.pathname,
|
|
500
|
+
method: 'POST',
|
|
501
|
+
headers: {
|
|
502
|
+
'Authorization': `Bearer ${this.apiToken}`,
|
|
503
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
504
|
+
'Content-Length': body.length
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const req = lib.request(options, (res) => {
|
|
509
|
+
let data = '';
|
|
510
|
+
res.on('data', chunk => data += chunk);
|
|
511
|
+
res.on('end', () => {
|
|
512
|
+
try {
|
|
513
|
+
resolve(JSON.parse(data));
|
|
514
|
+
} catch {
|
|
515
|
+
resolve(data);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
req.on('error', reject);
|
|
521
|
+
req.write(body);
|
|
522
|
+
req.end();
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ── Cron jobs ─────────────────────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Create a cron job
|
|
530
|
+
* @param {object} options - Cron job options
|
|
531
|
+
* @param {string} options.name - Cron job name
|
|
532
|
+
* @param {string} options.cronExpression - Cron expression (e.g., "0 * * * *")
|
|
533
|
+
* @param {string} options.script - Script to execute
|
|
534
|
+
* @param {string} options.workspaceId - Workspace ID
|
|
535
|
+
* @param {object} options.environment - Environment variables
|
|
536
|
+
* @param {boolean} options.persistent - Keep VM alive
|
|
537
|
+
* @returns {Promise<object>}
|
|
538
|
+
*/
|
|
539
|
+
async cronCreate(options) {
|
|
540
|
+
const payload = {
|
|
541
|
+
name: options.name,
|
|
542
|
+
cron_expression: options.cronExpression,
|
|
543
|
+
script: options.script,
|
|
544
|
+
persistent: options.persistent || false
|
|
545
|
+
};
|
|
546
|
+
if (options.workspaceId) payload.workspace_id = options.workspaceId;
|
|
547
|
+
if (options.environment) payload.environment = options.environment;
|
|
548
|
+
return this._request('POST', '/api/v1/mags-cron', payload);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* List cron jobs
|
|
553
|
+
* @returns {Promise<{cron_jobs: Array}>}
|
|
554
|
+
*/
|
|
555
|
+
async cronList() {
|
|
556
|
+
return this._request('GET', '/api/v1/mags-cron');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Get a cron job
|
|
561
|
+
* @param {string} id - Cron job ID
|
|
562
|
+
* @returns {Promise<object>}
|
|
563
|
+
*/
|
|
564
|
+
async cronGet(id) {
|
|
565
|
+
return this._request('GET', `/api/v1/mags-cron/${id}`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Update a cron job
|
|
570
|
+
* @param {string} id - Cron job ID
|
|
571
|
+
* @param {object} updates - Fields to update
|
|
572
|
+
* @returns {Promise<object>}
|
|
573
|
+
*/
|
|
574
|
+
async cronUpdate(id, updates) {
|
|
575
|
+
return this._request('PATCH', `/api/v1/mags-cron/${id}`, updates);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Delete a cron job
|
|
580
|
+
* @param {string} id - Cron job ID
|
|
581
|
+
* @returns {Promise<object>}
|
|
582
|
+
*/
|
|
583
|
+
async cronDelete(id) {
|
|
584
|
+
return this._request('DELETE', `/api/v1/mags-cron/${id}`);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ── URL aliases ───────────────────────────────────────────────────
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Create a stable URL alias for a workspace
|
|
591
|
+
* @param {string} subdomain - Subdomain for the alias
|
|
592
|
+
* @param {string} workspaceId - Workspace to point to
|
|
593
|
+
* @param {string} domain - Domain (default: apps.magpiecloud.com)
|
|
594
|
+
* @returns {Promise<{id: string, subdomain: string, url: string}>}
|
|
595
|
+
*/
|
|
596
|
+
async urlAliasCreate(subdomain, workspaceId, domain = 'apps.magpiecloud.com') {
|
|
597
|
+
return this._request('POST', '/api/v1/mags-url-aliases', {
|
|
598
|
+
subdomain,
|
|
599
|
+
workspace_id: workspaceId,
|
|
600
|
+
domain,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* List all URL aliases
|
|
606
|
+
* @returns {Promise<{aliases: Array, total: number}>}
|
|
607
|
+
*/
|
|
608
|
+
async urlAliasList() {
|
|
609
|
+
return this._request('GET', '/api/v1/mags-url-aliases');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Delete a URL alias by subdomain
|
|
614
|
+
* @param {string} subdomain - Subdomain to delete
|
|
615
|
+
* @returns {Promise<object>}
|
|
616
|
+
*/
|
|
617
|
+
async urlAliasDelete(subdomain) {
|
|
618
|
+
return this._request('DELETE', `/api/v1/mags-url-aliases/${subdomain}`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ── Internal helpers ──────────────────────────────────────────────
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Resolve a job name, workspace ID, or UUID to a request_id
|
|
625
|
+
* @param {string} nameOrId - Name, workspace ID, or request ID
|
|
626
|
+
* @returns {Promise<string>} The resolved request_id
|
|
627
|
+
*/
|
|
628
|
+
async _resolveJobId(nameOrId) {
|
|
629
|
+
// If it looks like a UUID, use directly
|
|
630
|
+
if (nameOrId.length >= 32 && nameOrId.includes('-')) {
|
|
631
|
+
return nameOrId;
|
|
632
|
+
}
|
|
633
|
+
const job = await this.findJob(nameOrId);
|
|
634
|
+
if (!job) throw new MagsError(`No job found for '${nameOrId}'`);
|
|
635
|
+
return job.request_id || job.id;
|
|
159
636
|
}
|
|
160
637
|
}
|
|
161
638
|
|
|
162
639
|
module.exports = Mags;
|
|
163
640
|
module.exports.Mags = Mags;
|
|
641
|
+
module.exports.MagsError = MagsError;
|
|
164
642
|
module.exports.default = Mags;
|
package/nodejs/index.js
CHANGED
|
@@ -7,25 +7,40 @@ const https = require('https');
|
|
|
7
7
|
const http = require('http');
|
|
8
8
|
const { URL } = require('url');
|
|
9
9
|
|
|
10
|
+
class MagsError extends Error {
|
|
11
|
+
constructor(message, statusCode) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'MagsError';
|
|
14
|
+
this.statusCode = statusCode || null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
class Mags {
|
|
11
19
|
/**
|
|
12
20
|
* Create a Mags client
|
|
13
21
|
* @param {object} options - Configuration options
|
|
14
22
|
* @param {string} options.apiUrl - API endpoint (default: https://api.magpiecloud.com)
|
|
15
23
|
* @param {string} options.apiToken - API token (required, or set MAGS_API_TOKEN env var)
|
|
24
|
+
* @param {number} options.timeout - Default request timeout in ms (default: 30000)
|
|
16
25
|
*/
|
|
17
26
|
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;
|
|
27
|
+
this.apiUrl = (options.apiUrl || process.env.MAGS_API_URL || 'https://api.magpiecloud.com').replace(/\/+$/, '');
|
|
28
|
+
this.apiToken = options.apiToken || process.env.MAGS_API_TOKEN || process.env.MAGS_TOKEN;
|
|
29
|
+
this.timeout = options.timeout || 30000;
|
|
20
30
|
|
|
21
31
|
if (!this.apiToken) {
|
|
22
|
-
throw new
|
|
32
|
+
throw new MagsError('API token required. Set MAGS_API_TOKEN or pass apiToken option.');
|
|
23
33
|
}
|
|
24
34
|
}
|
|
25
35
|
|
|
26
|
-
_request(method, path, body = null) {
|
|
36
|
+
_request(method, path, body = null, params = null) {
|
|
27
37
|
return new Promise((resolve, reject) => {
|
|
28
38
|
const url = new URL(path, this.apiUrl);
|
|
39
|
+
if (params) {
|
|
40
|
+
for (const [k, v] of Object.entries(params)) {
|
|
41
|
+
url.searchParams.set(k, v);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
29
44
|
const isHttps = url.protocol === 'https:';
|
|
30
45
|
const lib = isHttps ? https : http;
|
|
31
46
|
|
|
@@ -37,7 +52,8 @@ class Mags {
|
|
|
37
52
|
headers: {
|
|
38
53
|
'Authorization': `Bearer ${this.apiToken}`,
|
|
39
54
|
'Content-Type': 'application/json'
|
|
40
|
-
}
|
|
55
|
+
},
|
|
56
|
+
timeout: this.timeout
|
|
41
57
|
};
|
|
42
58
|
|
|
43
59
|
const req = lib.request(options, (res) => {
|
|
@@ -47,66 +63,110 @@ class Mags {
|
|
|
47
63
|
try {
|
|
48
64
|
const parsed = JSON.parse(data);
|
|
49
65
|
if (res.statusCode >= 400) {
|
|
50
|
-
reject(new
|
|
66
|
+
reject(new MagsError(parsed.error || parsed.message || `HTTP ${res.statusCode}`, res.statusCode));
|
|
51
67
|
} else {
|
|
52
68
|
resolve(parsed);
|
|
53
69
|
}
|
|
54
70
|
} catch {
|
|
55
|
-
|
|
71
|
+
if (res.statusCode >= 400) {
|
|
72
|
+
reject(new MagsError(data || `HTTP ${res.statusCode}`, res.statusCode));
|
|
73
|
+
} else {
|
|
74
|
+
resolve(data);
|
|
75
|
+
}
|
|
56
76
|
}
|
|
57
77
|
});
|
|
58
78
|
});
|
|
59
79
|
|
|
60
80
|
req.on('error', reject);
|
|
81
|
+
req.on('timeout', () => {
|
|
82
|
+
req.destroy();
|
|
83
|
+
reject(new MagsError('Request timed out'));
|
|
84
|
+
});
|
|
61
85
|
if (body) req.write(JSON.stringify(body));
|
|
62
86
|
req.end();
|
|
63
87
|
});
|
|
64
88
|
}
|
|
65
89
|
|
|
90
|
+
// ── Jobs ──────────────────────────────────────────────────────────
|
|
91
|
+
|
|
66
92
|
/**
|
|
67
93
|
* Submit a job for execution
|
|
68
94
|
* @param {string} script - Script to execute
|
|
69
95
|
* @param {object} options - Job options
|
|
70
96
|
* @param {string} options.name - Job name
|
|
71
97
|
* @param {string} options.workspaceId - Persistent workspace ID
|
|
98
|
+
* @param {string} options.baseWorkspaceId - Read-only base workspace to mount
|
|
72
99
|
* @param {boolean} options.persistent - Keep VM alive after script
|
|
100
|
+
* @param {boolean} options.noSleep - Never auto-sleep (requires persistent)
|
|
73
101
|
* @param {boolean} options.ephemeral - No workspace/S3 sync (fastest)
|
|
74
102
|
* @param {string} options.startupCommand - Command to run when waking from sleep
|
|
75
103
|
* @param {object} options.environment - Environment variables
|
|
76
104
|
* @param {string[]} options.fileIds - File IDs from uploadFiles()
|
|
77
|
-
* @
|
|
105
|
+
* @param {number} options.diskGb - Custom disk size in GB (default 2)
|
|
106
|
+
* @returns {Promise<{request_id: string, status: string}>}
|
|
78
107
|
*/
|
|
79
108
|
async run(script, options = {}) {
|
|
80
109
|
if (options.ephemeral && options.workspaceId) {
|
|
81
|
-
throw new
|
|
110
|
+
throw new MagsError('Cannot use ephemeral with workspaceId');
|
|
82
111
|
}
|
|
83
112
|
if (options.ephemeral && options.persistent) {
|
|
84
|
-
throw new
|
|
113
|
+
throw new MagsError('Cannot use ephemeral with persistent');
|
|
114
|
+
}
|
|
115
|
+
if (options.noSleep && !options.persistent) {
|
|
116
|
+
throw new MagsError('noSleep requires persistent=true');
|
|
85
117
|
}
|
|
86
118
|
|
|
87
119
|
const payload = {
|
|
88
120
|
script,
|
|
89
121
|
type: 'inline',
|
|
90
|
-
name: options.name,
|
|
91
122
|
persistent: options.persistent || false,
|
|
92
|
-
startup_command: options.startupCommand,
|
|
93
|
-
environment: options.environment
|
|
94
123
|
};
|
|
95
124
|
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
125
|
+
if (options.noSleep) payload.no_sleep = true;
|
|
126
|
+
if (options.name) payload.name = options.name;
|
|
127
|
+
if (!options.ephemeral && options.workspaceId) payload.workspace_id = options.workspaceId;
|
|
128
|
+
if (options.baseWorkspaceId) payload.base_workspace_id = options.baseWorkspaceId;
|
|
129
|
+
if (options.startupCommand) payload.startup_command = options.startupCommand;
|
|
130
|
+
if (options.environment) payload.environment = options.environment;
|
|
131
|
+
if (options.fileIds && options.fileIds.length > 0) payload.file_ids = options.fileIds;
|
|
132
|
+
if (options.diskGb) payload.disk_gb = options.diskGb;
|
|
133
|
+
|
|
134
|
+
return this._request('POST', '/api/v1/mags-jobs', payload);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Run a job and wait for completion
|
|
139
|
+
* @param {string} script - Script to execute
|
|
140
|
+
* @param {object} options - Job options (same as run() plus timeout/pollInterval)
|
|
141
|
+
* @param {number} options.timeout - Timeout in ms (default: 60000)
|
|
142
|
+
* @param {number} options.pollInterval - Poll interval in ms (default: 1000)
|
|
143
|
+
* @returns {Promise<{requestId: string, status: string, exitCode: number, durationMs: number, logs: Array}>}
|
|
144
|
+
*/
|
|
145
|
+
async runAndWait(script, options = {}) {
|
|
146
|
+
const timeout = options.timeout || 60000;
|
|
147
|
+
const pollInterval = options.pollInterval || 1000;
|
|
148
|
+
const result = await this.run(script, options);
|
|
149
|
+
const requestId = result.request_id;
|
|
150
|
+
|
|
151
|
+
const startTime = Date.now();
|
|
152
|
+
while (Date.now() - startTime < timeout) {
|
|
153
|
+
const status = await this.status(requestId);
|
|
154
|
+
|
|
155
|
+
if (status.status === 'completed' || status.status === 'error') {
|
|
156
|
+
const logsResp = await this.logs(requestId);
|
|
157
|
+
return {
|
|
158
|
+
requestId,
|
|
159
|
+
status: status.status,
|
|
160
|
+
exitCode: status.exit_code,
|
|
161
|
+
durationMs: status.script_duration_ms,
|
|
162
|
+
logs: logsResp.logs || []
|
|
163
|
+
};
|
|
164
|
+
}
|
|
100
165
|
|
|
101
|
-
|
|
102
|
-
payload.file_ids = options.fileIds;
|
|
166
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
103
167
|
}
|
|
104
168
|
|
|
105
|
-
|
|
106
|
-
return {
|
|
107
|
-
requestId: response.request_id,
|
|
108
|
-
status: response.status
|
|
109
|
-
};
|
|
169
|
+
throw new MagsError(`Job ${requestId} timed out after ${timeout}ms`);
|
|
110
170
|
}
|
|
111
171
|
|
|
112
172
|
/**
|
|
@@ -137,60 +197,262 @@ class Mags {
|
|
|
137
197
|
async list(options = {}) {
|
|
138
198
|
const page = options.page || 1;
|
|
139
199
|
const pageSize = options.pageSize || 20;
|
|
140
|
-
return this._request('GET', `/api/v1/mags-jobs
|
|
200
|
+
return this._request('GET', `/api/v1/mags-jobs`, null, { page, page_size: pageSize });
|
|
141
201
|
}
|
|
142
202
|
|
|
143
203
|
/**
|
|
144
|
-
*
|
|
204
|
+
* Update a job's settings
|
|
145
205
|
* @param {string} requestId - Job request ID
|
|
146
|
-
* @param {
|
|
206
|
+
* @param {object} options - Settings to update
|
|
207
|
+
* @param {string} options.startupCommand - Command to run when VM wakes from sleep
|
|
208
|
+
* @param {boolean} options.noSleep - If true, VM never auto-sleeps. If false, re-enables auto-sleep.
|
|
147
209
|
* @returns {Promise<object>}
|
|
148
210
|
*/
|
|
149
|
-
async
|
|
150
|
-
|
|
211
|
+
async updateJob(requestId, options = {}) {
|
|
212
|
+
const payload = {};
|
|
213
|
+
if (options.startupCommand !== undefined) payload.startup_command = options.startupCommand;
|
|
214
|
+
if (options.noSleep !== undefined) payload.no_sleep = options.noSleep;
|
|
215
|
+
return this._request('PATCH', `/api/v1/mags-jobs/${requestId}`, payload);
|
|
151
216
|
}
|
|
152
217
|
|
|
153
218
|
/**
|
|
154
|
-
*
|
|
219
|
+
* Enable URL or SSH access for a job
|
|
155
220
|
* @param {string} requestId - Job request ID
|
|
221
|
+
* @param {number} port - Port to expose (default: 8080, use 22 for SSH)
|
|
156
222
|
* @returns {Promise<object>}
|
|
157
223
|
*/
|
|
158
|
-
async
|
|
224
|
+
async enableAccess(requestId, port = 8080) {
|
|
225
|
+
return this._request('POST', `/api/v1/mags-jobs/${requestId}/access`, { port });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Enable public URL access for a job's VM
|
|
230
|
+
* @param {string} nameOrId - Job name, workspace ID, or request ID
|
|
231
|
+
* @param {number} port - Port to expose (default: 8080)
|
|
232
|
+
* @returns {Promise<object>} Object with url and access details
|
|
233
|
+
*/
|
|
234
|
+
async url(nameOrId, port = 8080) {
|
|
235
|
+
const requestId = await this._resolveJobId(nameOrId);
|
|
236
|
+
const st = await this.status(requestId);
|
|
237
|
+
const resp = await this.enableAccess(requestId, port);
|
|
238
|
+
const subdomain = st.subdomain || resp.subdomain;
|
|
239
|
+
if (subdomain) {
|
|
240
|
+
resp.url = `https://${subdomain}.apps.magpiecloud.com`;
|
|
241
|
+
}
|
|
242
|
+
return resp;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Stop a running job. Accepts a job ID, job name, or workspace ID.
|
|
247
|
+
* @param {string} nameOrId - Job name, workspace ID, or request ID
|
|
248
|
+
* @returns {Promise<object>}
|
|
249
|
+
*/
|
|
250
|
+
async stop(nameOrId) {
|
|
251
|
+
const requestId = await this._resolveJobId(nameOrId);
|
|
159
252
|
return this._request('POST', `/api/v1/mags-jobs/${requestId}/stop`);
|
|
160
253
|
}
|
|
161
254
|
|
|
162
255
|
/**
|
|
163
|
-
*
|
|
164
|
-
* @param {string}
|
|
165
|
-
* @
|
|
166
|
-
* @param {number} options.timeout - Timeout in ms (default: 60000)
|
|
167
|
-
* @returns {Promise<{status: string, exitCode: number, logs: Array}>}
|
|
256
|
+
* Sync a running job's workspace to S3 without stopping the VM
|
|
257
|
+
* @param {string} requestId - Job request ID
|
|
258
|
+
* @returns {Promise<object>}
|
|
168
259
|
*/
|
|
169
|
-
async
|
|
170
|
-
|
|
171
|
-
|
|
260
|
+
async sync(requestId) {
|
|
261
|
+
return this._request('POST', `/api/v1/mags-jobs/${requestId}/sync`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Create a new persistent VM workspace and wait until it's running
|
|
266
|
+
* @param {string} name - Workspace name
|
|
267
|
+
* @param {object} options - Options
|
|
268
|
+
* @param {string} options.baseWorkspaceId - Read-only base workspace to mount
|
|
269
|
+
* @param {number} options.diskGb - Custom disk size in GB
|
|
270
|
+
* @param {number} options.timeout - Timeout in ms (default: 30000)
|
|
271
|
+
* @param {number} options.pollInterval - Poll interval in ms (default: 1000)
|
|
272
|
+
* @returns {Promise<{request_id: string, status: string}>}
|
|
273
|
+
*/
|
|
274
|
+
async new(name, options = {}) {
|
|
275
|
+
const timeout = options.timeout || 30000;
|
|
276
|
+
const pollInterval = options.pollInterval || 1000;
|
|
277
|
+
|
|
278
|
+
const result = await this.run('sleep infinity', {
|
|
279
|
+
workspaceId: name,
|
|
280
|
+
persistent: true,
|
|
281
|
+
baseWorkspaceId: options.baseWorkspaceId,
|
|
282
|
+
diskGb: options.diskGb,
|
|
283
|
+
});
|
|
284
|
+
const requestId = result.request_id;
|
|
172
285
|
|
|
173
286
|
const startTime = Date.now();
|
|
174
287
|
while (Date.now() - startTime < timeout) {
|
|
175
|
-
const
|
|
288
|
+
const st = await this.status(requestId);
|
|
289
|
+
if (st.status === 'running' && st.vm_id) {
|
|
290
|
+
return { request_id: requestId, status: 'running' };
|
|
291
|
+
}
|
|
292
|
+
if (st.status === 'completed' || st.status === 'error') {
|
|
293
|
+
throw new MagsError(`Job ${requestId} ended unexpectedly: ${st.status}`);
|
|
294
|
+
}
|
|
295
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
296
|
+
}
|
|
176
297
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
298
|
+
throw new MagsError(`Job ${requestId} did not start within ${timeout}ms`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Find a running or sleeping job by name, workspace ID, or job ID
|
|
303
|
+
* @param {string} nameOrId - Job name, workspace ID, or request ID
|
|
304
|
+
* @returns {Promise<object|null>} The job object, or null if not found
|
|
305
|
+
*/
|
|
306
|
+
async findJob(nameOrId) {
|
|
307
|
+
const resp = await this.list({ pageSize: 50 });
|
|
308
|
+
const jobs = resp.jobs || [];
|
|
309
|
+
|
|
310
|
+
// Priority 1: exact name match, running/sleeping
|
|
311
|
+
for (const j of jobs) {
|
|
312
|
+
if (j.name === nameOrId && (j.status === 'running' || j.status === 'sleeping')) return j;
|
|
313
|
+
}
|
|
314
|
+
// Priority 2: workspace_id match, running/sleeping
|
|
315
|
+
for (const j of jobs) {
|
|
316
|
+
if (j.workspace_id === nameOrId && (j.status === 'running' || j.status === 'sleeping')) return j;
|
|
317
|
+
}
|
|
318
|
+
// Priority 3: exact name match, any status
|
|
319
|
+
for (const j of jobs) {
|
|
320
|
+
if (j.name === nameOrId) return j;
|
|
321
|
+
}
|
|
322
|
+
// Priority 4: workspace_id match, any status
|
|
323
|
+
for (const j of jobs) {
|
|
324
|
+
if (j.workspace_id === nameOrId) return j;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Execute a command on an existing running/sleeping VM via SSH
|
|
332
|
+
* @param {string} nameOrId - Job name, workspace ID, or request ID
|
|
333
|
+
* @param {string} command - Command to execute
|
|
334
|
+
* @param {object} options - Options
|
|
335
|
+
* @param {number} options.timeout - Timeout in ms (default: 30000)
|
|
336
|
+
* @returns {Promise<{exitCode: number, output: string, stderr: string}>}
|
|
337
|
+
*/
|
|
338
|
+
async exec(nameOrId, command, options = {}) {
|
|
339
|
+
const timeout = options.timeout || 30000;
|
|
340
|
+
|
|
341
|
+
const job = await this.findJob(nameOrId);
|
|
342
|
+
if (!job) throw new MagsError(`No running or sleeping VM found for '${nameOrId}'`);
|
|
343
|
+
if (job.status !== 'running' && job.status !== 'sleeping') {
|
|
344
|
+
throw new MagsError(`VM for '${nameOrId}' is ${job.status}, needs to be running or sleeping`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const requestId = job.request_id || job.id;
|
|
348
|
+
const access = await this.enableAccess(requestId, 22);
|
|
349
|
+
|
|
350
|
+
if (!access.success || !access.ssh_host) {
|
|
351
|
+
throw new MagsError(`Failed to enable SSH access: ${access.error || 'unknown error'}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const { execFileSync, execFile } = require('child_process');
|
|
355
|
+
const fs = require('fs');
|
|
356
|
+
const os = require('os');
|
|
357
|
+
const path = require('path');
|
|
358
|
+
|
|
359
|
+
const escaped = command.replace(/'/g, "'\\''");
|
|
360
|
+
const wrapped =
|
|
361
|
+
`if [ -d /overlay/bin ]; then ` +
|
|
362
|
+
`chroot /overlay /bin/sh -l -c 'cd /root 2>/dev/null; ${escaped}'; ` +
|
|
363
|
+
`else cd /root 2>/dev/null; ${escaped}; fi`;
|
|
364
|
+
|
|
365
|
+
let keyFile = null;
|
|
366
|
+
try {
|
|
367
|
+
const sshArgs = [
|
|
368
|
+
'-o', 'StrictHostKeyChecking=no',
|
|
369
|
+
'-o', 'UserKnownHostsFile=/dev/null',
|
|
370
|
+
'-o', 'LogLevel=ERROR',
|
|
371
|
+
'-p', String(access.ssh_port),
|
|
372
|
+
];
|
|
373
|
+
|
|
374
|
+
if (access.ssh_private_key) {
|
|
375
|
+
keyFile = path.join(os.tmpdir(), `mags_ssh_${Date.now()}`);
|
|
376
|
+
fs.writeFileSync(keyFile, access.ssh_private_key, { mode: 0o600 });
|
|
377
|
+
sshArgs.push('-i', keyFile);
|
|
186
378
|
}
|
|
187
379
|
|
|
380
|
+
sshArgs.push(`root@${access.ssh_host}`, wrapped);
|
|
381
|
+
|
|
382
|
+
return new Promise((resolve, reject) => {
|
|
383
|
+
const proc = execFile('ssh', sshArgs, { timeout, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
384
|
+
if (keyFile) try { fs.unlinkSync(keyFile); } catch {}
|
|
385
|
+
if (err && err.killed) {
|
|
386
|
+
reject(new MagsError(`Command timed out after ${timeout}ms`));
|
|
387
|
+
} else {
|
|
388
|
+
resolve({
|
|
389
|
+
exitCode: err ? err.code || 1 : 0,
|
|
390
|
+
output: stdout,
|
|
391
|
+
stderr: stderr,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
} catch (e) {
|
|
397
|
+
if (keyFile) try { require('fs').unlinkSync(keyFile); } catch {}
|
|
398
|
+
throw e;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Resize a workspace's disk. Stops the existing VM, then creates a new one.
|
|
404
|
+
* @param {string} workspace - Workspace name
|
|
405
|
+
* @param {number} diskGb - New disk size in GB
|
|
406
|
+
* @param {object} options - Options
|
|
407
|
+
* @param {number} options.timeout - Timeout in ms (default: 30000)
|
|
408
|
+
* @param {number} options.pollInterval - Poll interval in ms (default: 1000)
|
|
409
|
+
* @returns {Promise<{request_id: string, status: string}>}
|
|
410
|
+
*/
|
|
411
|
+
async resize(workspace, diskGb, options = {}) {
|
|
412
|
+
const existing = await this.findJob(workspace);
|
|
413
|
+
if (existing && existing.status === 'running') {
|
|
414
|
+
await this._request('POST', `/api/v1/mags-jobs/${existing.request_id}/sync`);
|
|
415
|
+
await this._request('POST', `/api/v1/mags-jobs/${existing.request_id}/stop`);
|
|
416
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
417
|
+
} else if (existing && existing.status === 'sleeping') {
|
|
418
|
+
await this._request('POST', `/api/v1/mags-jobs/${existing.request_id}/stop`);
|
|
188
419
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
189
420
|
}
|
|
190
421
|
|
|
191
|
-
|
|
422
|
+
return this.new(workspace, { diskGb, timeout: options.timeout, pollInterval: options.pollInterval });
|
|
192
423
|
}
|
|
193
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Get aggregated usage summary
|
|
427
|
+
* @param {object} options - Options
|
|
428
|
+
* @param {number} options.windowDays - Time window in days (default: 30)
|
|
429
|
+
* @returns {Promise<object>}
|
|
430
|
+
*/
|
|
431
|
+
async usage(options = {}) {
|
|
432
|
+
return this._request('GET', '/api/v1/mags-jobs/usage', null, { window_days: options.windowDays || 30 });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── Workspaces ────────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* List all workspaces
|
|
439
|
+
* @returns {Promise<{workspaces: Array, total: number}>}
|
|
440
|
+
*/
|
|
441
|
+
async listWorkspaces() {
|
|
442
|
+
return this._request('GET', '/api/v1/mags-workspaces');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Delete a workspace and all its stored data
|
|
447
|
+
* @param {string} workspaceId - Workspace ID to delete
|
|
448
|
+
* @returns {Promise<object>}
|
|
449
|
+
*/
|
|
450
|
+
async deleteWorkspace(workspaceId) {
|
|
451
|
+
return this._request('DELETE', `/api/v1/mags-workspaces/${workspaceId}`);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── File uploads ──────────────────────────────────────────────────
|
|
455
|
+
|
|
194
456
|
/**
|
|
195
457
|
* Upload files for use in a job
|
|
196
458
|
* @param {string[]} filePaths - Array of local file paths
|
|
@@ -218,7 +480,7 @@ class Mags {
|
|
|
218
480
|
if (response.file_id) {
|
|
219
481
|
fileIds.push(response.file_id);
|
|
220
482
|
} else {
|
|
221
|
-
throw new
|
|
483
|
+
throw new MagsError(`Failed to upload file: ${fileName}`);
|
|
222
484
|
}
|
|
223
485
|
}
|
|
224
486
|
|
|
@@ -261,7 +523,7 @@ class Mags {
|
|
|
261
523
|
});
|
|
262
524
|
}
|
|
263
525
|
|
|
264
|
-
// Cron
|
|
526
|
+
// ── Cron jobs ─────────────────────────────────────────────────────
|
|
265
527
|
|
|
266
528
|
/**
|
|
267
529
|
* Create a cron job
|
|
@@ -270,6 +532,7 @@ class Mags {
|
|
|
270
532
|
* @param {string} options.cronExpression - Cron expression (e.g., "0 * * * *")
|
|
271
533
|
* @param {string} options.script - Script to execute
|
|
272
534
|
* @param {string} options.workspaceId - Workspace ID
|
|
535
|
+
* @param {object} options.environment - Environment variables
|
|
273
536
|
* @param {boolean} options.persistent - Keep VM alive
|
|
274
537
|
* @returns {Promise<object>}
|
|
275
538
|
*/
|
|
@@ -278,9 +541,10 @@ class Mags {
|
|
|
278
541
|
name: options.name,
|
|
279
542
|
cron_expression: options.cronExpression,
|
|
280
543
|
script: options.script,
|
|
281
|
-
workspace_id: options.workspaceId,
|
|
282
544
|
persistent: options.persistent || false
|
|
283
545
|
};
|
|
546
|
+
if (options.workspaceId) payload.workspace_id = options.workspaceId;
|
|
547
|
+
if (options.environment) payload.environment = options.environment;
|
|
284
548
|
return this._request('POST', '/api/v1/mags-cron', payload);
|
|
285
549
|
}
|
|
286
550
|
|
|
@@ -319,8 +583,60 @@ class Mags {
|
|
|
319
583
|
async cronDelete(id) {
|
|
320
584
|
return this._request('DELETE', `/api/v1/mags-cron/${id}`);
|
|
321
585
|
}
|
|
586
|
+
|
|
587
|
+
// ── URL aliases ───────────────────────────────────────────────────
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Create a stable URL alias for a workspace
|
|
591
|
+
* @param {string} subdomain - Subdomain for the alias
|
|
592
|
+
* @param {string} workspaceId - Workspace to point to
|
|
593
|
+
* @param {string} domain - Domain (default: apps.magpiecloud.com)
|
|
594
|
+
* @returns {Promise<{id: string, subdomain: string, url: string}>}
|
|
595
|
+
*/
|
|
596
|
+
async urlAliasCreate(subdomain, workspaceId, domain = 'apps.magpiecloud.com') {
|
|
597
|
+
return this._request('POST', '/api/v1/mags-url-aliases', {
|
|
598
|
+
subdomain,
|
|
599
|
+
workspace_id: workspaceId,
|
|
600
|
+
domain,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* List all URL aliases
|
|
606
|
+
* @returns {Promise<{aliases: Array, total: number}>}
|
|
607
|
+
*/
|
|
608
|
+
async urlAliasList() {
|
|
609
|
+
return this._request('GET', '/api/v1/mags-url-aliases');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Delete a URL alias by subdomain
|
|
614
|
+
* @param {string} subdomain - Subdomain to delete
|
|
615
|
+
* @returns {Promise<object>}
|
|
616
|
+
*/
|
|
617
|
+
async urlAliasDelete(subdomain) {
|
|
618
|
+
return this._request('DELETE', `/api/v1/mags-url-aliases/${subdomain}`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ── Internal helpers ──────────────────────────────────────────────
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Resolve a job name, workspace ID, or UUID to a request_id
|
|
625
|
+
* @param {string} nameOrId - Name, workspace ID, or request ID
|
|
626
|
+
* @returns {Promise<string>} The resolved request_id
|
|
627
|
+
*/
|
|
628
|
+
async _resolveJobId(nameOrId) {
|
|
629
|
+
// If it looks like a UUID, use directly
|
|
630
|
+
if (nameOrId.length >= 32 && nameOrId.includes('-')) {
|
|
631
|
+
return nameOrId;
|
|
632
|
+
}
|
|
633
|
+
const job = await this.findJob(nameOrId);
|
|
634
|
+
if (!job) throw new MagsError(`No job found for '${nameOrId}'`);
|
|
635
|
+
return job.request_id || job.id;
|
|
636
|
+
}
|
|
322
637
|
}
|
|
323
638
|
|
|
324
639
|
module.exports = Mags;
|
|
325
640
|
module.exports.Mags = Mags;
|
|
641
|
+
module.exports.MagsError = MagsError;
|
|
326
642
|
module.exports.default = Mags;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@magpiecloud/mags",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.10",
|
|
4
4
|
"description": "Mags CLI - Execute scripts on Magpie's instant VM infrastructure",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -20,11 +20,7 @@
|
|
|
20
20
|
],
|
|
21
21
|
"author": "Magpie Cloud",
|
|
22
22
|
"license": "MIT",
|
|
23
|
-
"
|
|
24
|
-
"type": "git",
|
|
25
|
-
"url": "https://github.com/magpiecloud/mags"
|
|
26
|
-
},
|
|
27
|
-
"homepage": "https://magpiecloud.com/docs/mags",
|
|
23
|
+
"homepage": "https://mags.run",
|
|
28
24
|
"engines": {
|
|
29
25
|
"node": ">=14.0.0"
|
|
30
26
|
}
|