@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.
Files changed (3) hide show
  1. package/index.js +518 -40
  2. package/nodejs/index.js +369 -53
  3. 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 Error('API token required. Set MAGS_API_TOKEN or pass apiToken option.');
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 Error(parsed.error || parsed.message || `HTTP ${res.statusCode}`));
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
- resolve(data);
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
- * @returns {Promise<{requestId: string, status: string}>}
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
- const response = await this._request('POST', '/api/v1/mags-jobs', payload);
82
- return {
83
- requestId: response.request_id,
84
- status: response.status
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?page=${page}&page_size=${pageSize}`);
200
+ return this._request('GET', `/api/v1/mags-jobs`, null, { page, page_size: pageSize });
117
201
  }
118
202
 
119
203
  /**
120
- * Enable URL access for a job
204
+ * Update a job's settings
121
205
  * @param {string} requestId - Job request ID
122
- * @param {number} port - Port to expose (default: 8080)
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 enableUrl(requestId, port = 8080) {
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
- * Run a job and wait for completion
131
- * @param {string} script - Script to execute
132
- * @param {object} options - Job options
133
- * @param {number} options.timeout - Timeout in ms (default: 60000)
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 runAndWait(script, options = {}) {
137
- const timeout = options.timeout || 60000;
138
- const { requestId } = await this.run(script, options);
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 status = await this.status(requestId);
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
- if (status.status === 'completed' || status.status === 'error') {
145
- const logsResp = await this.logs(requestId);
146
- return {
147
- requestId,
148
- status: status.status,
149
- exitCode: status.exit_code,
150
- durationMs: status.script_duration_ms,
151
- logs: logsResp.logs || []
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
- throw new Error(`Job ${requestId} timed out after ${timeout}ms`);
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 Error('API token required. Set MAGS_API_TOKEN or pass apiToken option.');
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 Error(parsed.error || parsed.message || `HTTP ${res.statusCode}`));
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
- resolve(data);
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
- * @returns {Promise<{requestId: string, status: string}>}
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 Error('Cannot use ephemeral with workspaceId');
110
+ throw new MagsError('Cannot use ephemeral with workspaceId');
82
111
  }
83
112
  if (options.ephemeral && options.persistent) {
84
- throw new Error('Cannot use ephemeral with 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');
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
- // Only set workspace_id if not ephemeral
97
- if (!options.ephemeral) {
98
- payload.workspace_id = options.workspaceId;
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
- if (options.fileIds && options.fileIds.length > 0) {
102
- payload.file_ids = options.fileIds;
166
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
103
167
  }
104
168
 
105
- const response = await this._request('POST', '/api/v1/mags-jobs', payload);
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?page=${page}&page_size=${pageSize}`);
200
+ return this._request('GET', `/api/v1/mags-jobs`, null, { page, page_size: pageSize });
141
201
  }
142
202
 
143
203
  /**
144
- * Enable URL access for a job
204
+ * Update a job's settings
145
205
  * @param {string} requestId - Job request ID
146
- * @param {number} port - Port to expose (default: 8080)
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 enableUrl(requestId, port = 8080) {
150
- return this._request('POST', `/api/v1/mags-jobs/${requestId}/access`, { port });
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
- * Stop a running job
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 stop(requestId) {
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
- * Run a job and wait for completion
164
- * @param {string} script - Script to execute
165
- * @param {object} options - Job options
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 runAndWait(script, options = {}) {
170
- const timeout = options.timeout || 60000;
171
- const { requestId } = await this.run(script, options);
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 status = await this.status(requestId);
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
- if (status.status === 'completed' || status.status === 'error') {
178
- const logsResp = await this.logs(requestId);
179
- return {
180
- requestId,
181
- status: status.status,
182
- exitCode: status.exit_code,
183
- durationMs: status.script_duration_ms,
184
- logs: logsResp.logs || []
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
- throw new Error(`Job ${requestId} timed out after ${timeout}ms`);
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 Error(`Failed to upload file: ${fileName}`);
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 job methods
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.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
- "repository": {
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
  }