@magpiecloud/mags 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/bin/mags.js +277 -2
  2. package/index.js +147 -1
  3. package/package.json +1 -1
package/bin/mags.js CHANGED
@@ -192,18 +192,37 @@ ${colors.bold}Run Options:${colors.reset}
192
192
  -w, --workspace <id> Use persistent workspace (S3 sync)
193
193
  -n, --name <name> Set job name (for easier reference)
194
194
  -p, --persistent Keep VM alive after script completes
195
+ -e, --ephemeral No workspace/S3 sync (fastest execution)
196
+ -f, --file <path> Upload file(s) to VM (repeatable)
195
197
  --url Enable public URL access (requires -p)
196
198
  --port <port> Port to expose for URL (default: 8080)
197
199
  --startup-command <cmd> Command to run when VM wakes from sleep
198
200
 
201
+ ${colors.bold}Cron Commands:${colors.reset}
202
+ cron add [options] <script> Create a scheduled cron job
203
+ cron list List all cron jobs
204
+ cron remove <id> Delete a cron job
205
+ cron enable <id> Enable a cron job
206
+ cron disable <id> Disable a cron job
207
+
208
+ ${colors.bold}Cron Options:${colors.reset}
209
+ --name <name> Cron job name (required)
210
+ --schedule <expr> Cron expression (required, e.g. "0 * * * *")
211
+ -w, --workspace <id> Workspace for cron jobs
212
+ -p, --persistent Keep VM alive after cron script
213
+
199
214
  ${colors.bold}Examples:${colors.reset}
200
215
  mags login
201
216
  mags new myvm # Create VM, get ID
202
217
  mags ssh myvm # SSH by name
203
218
  mags run 'echo Hello World'
219
+ mags run -e 'echo fast' # Ephemeral (no S3 sync)
220
+ mags run -f script.py 'python3 script.py' # Upload + run file
204
221
  mags run -w myproject 'python3 script.py'
205
222
  mags run -p --url 'python3 -m http.server 8080'
206
223
  mags run -n webapp -w webapp -p --url --port 3000 'npm start'
224
+ mags cron add --name backup --schedule "0 0 * * *" 'tar czf backup.tar.gz data/'
225
+ mags cron list
207
226
  mags status myvm
208
227
  mags logs myvm
209
228
  mags url myvm 8080
@@ -394,9 +413,11 @@ async function runJob(args) {
394
413
  let workspace = '';
395
414
  let name = '';
396
415
  let persistent = false;
416
+ let ephemeral = false;
397
417
  let enableUrl = false;
398
418
  let port = 8080;
399
419
  let startupCommand = '';
420
+ let fileArgs = [];
400
421
 
401
422
  // Parse flags
402
423
  for (let i = 0; i < args.length; i++) {
@@ -413,6 +434,14 @@ async function runJob(args) {
413
434
  case '--persistent':
414
435
  persistent = true;
415
436
  break;
437
+ case '-e':
438
+ case '--ephemeral':
439
+ ephemeral = true;
440
+ break;
441
+ case '-f':
442
+ case '--file':
443
+ fileArgs.push(args[++i]);
444
+ break;
416
445
  case '--url':
417
446
  enableUrl = true;
418
447
  break;
@@ -433,6 +462,32 @@ async function runJob(args) {
433
462
  usage();
434
463
  }
435
464
 
465
+ // Validate flag combinations
466
+ if (ephemeral && workspace) {
467
+ log('red', 'Error: Cannot use --ephemeral with --workspace; ephemeral VMs have no persistent storage');
468
+ process.exit(1);
469
+ }
470
+ if (ephemeral && persistent) {
471
+ log('red', 'Error: Cannot use --ephemeral with --persistent; ephemeral VMs are destroyed after execution');
472
+ process.exit(1);
473
+ }
474
+
475
+ // Upload files if any
476
+ let fileIds = [];
477
+ if (fileArgs.length > 0) {
478
+ for (const filePath of fileArgs) {
479
+ log('blue', `Uploading ${filePath}...`);
480
+ const fileId = await uploadFile(filePath);
481
+ if (fileId) {
482
+ fileIds.push(fileId);
483
+ log('green', `Uploaded: ${filePath} (${fileId})`);
484
+ } else {
485
+ log('red', `Failed to upload: ${filePath}`);
486
+ process.exit(1);
487
+ }
488
+ }
489
+ }
490
+
436
491
  log('blue', 'Submitting job...');
437
492
 
438
493
  const payload = {
@@ -440,9 +495,11 @@ async function runJob(args) {
440
495
  type: 'inline',
441
496
  persistent
442
497
  };
443
- if (workspace) payload.workspace_id = workspace;
498
+ // Only set workspace_id if not ephemeral
499
+ if (!ephemeral && workspace) payload.workspace_id = workspace;
444
500
  if (name) payload.name = name;
445
501
  if (startupCommand) payload.startup_command = startupCommand;
502
+ if (fileIds.length > 0) payload.file_ids = fileIds;
446
503
 
447
504
  const response = await request('POST', '/api/v1/mags-jobs', payload);
448
505
 
@@ -681,6 +738,220 @@ scripts on instant VMs directly from Claude.
681
738
  }
682
739
  }
683
740
 
741
+ // Upload file via multipart form data
742
+ async function uploadFile(filePath) {
743
+ if (!fs.existsSync(filePath)) {
744
+ log('red', `File not found: ${filePath}`);
745
+ return null;
746
+ }
747
+
748
+ const fileName = path.basename(filePath);
749
+ const fileData = fs.readFileSync(filePath);
750
+ const boundary = '----MagsBoundary' + Date.now().toString(16);
751
+
752
+ // Build multipart body
753
+ const parts = [];
754
+ parts.push(`--${boundary}\r\n`);
755
+ parts.push(`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`);
756
+ parts.push(`Content-Type: application/octet-stream\r\n\r\n`);
757
+ const header = Buffer.from(parts.join(''));
758
+ const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
759
+ const body = Buffer.concat([header, fileData, footer]);
760
+
761
+ return new Promise((resolve, reject) => {
762
+ const url = new URL('/api/v1/mags-files', API_URL);
763
+ const isHttps = url.protocol === 'https:';
764
+ const lib = isHttps ? https : http;
765
+
766
+ const options = {
767
+ hostname: url.hostname,
768
+ port: url.port || (isHttps ? 443 : 80),
769
+ path: url.pathname,
770
+ method: 'POST',
771
+ headers: {
772
+ 'Authorization': `Bearer ${API_TOKEN}`,
773
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
774
+ 'Content-Length': body.length
775
+ }
776
+ };
777
+
778
+ const req = lib.request(options, (res) => {
779
+ let data = '';
780
+ res.on('data', chunk => data += chunk);
781
+ res.on('end', () => {
782
+ try {
783
+ const parsed = JSON.parse(data);
784
+ if (parsed.file_id) {
785
+ resolve(parsed.file_id);
786
+ } else {
787
+ resolve(null);
788
+ }
789
+ } catch {
790
+ resolve(null);
791
+ }
792
+ });
793
+ });
794
+
795
+ req.on('error', () => resolve(null));
796
+ req.write(body);
797
+ req.end();
798
+ });
799
+ }
800
+
801
+ // Cron job management
802
+ async function cronCommand(args) {
803
+ if (args.length === 0) {
804
+ log('red', 'Error: Cron subcommand required (add, list, remove, enable, disable)');
805
+ usage();
806
+ return;
807
+ }
808
+
809
+ const subcommand = args[0];
810
+ const subArgs = args.slice(1);
811
+
812
+ switch (subcommand) {
813
+ case 'add':
814
+ await cronAdd(subArgs);
815
+ break;
816
+ case 'list':
817
+ case 'ls':
818
+ await cronList();
819
+ break;
820
+ case 'remove':
821
+ case 'rm':
822
+ case 'delete':
823
+ if (!subArgs[0]) {
824
+ log('red', 'Error: Cron job ID required');
825
+ process.exit(1);
826
+ }
827
+ await cronRemove(subArgs[0]);
828
+ break;
829
+ case 'enable':
830
+ if (!subArgs[0]) {
831
+ log('red', 'Error: Cron job ID required');
832
+ process.exit(1);
833
+ }
834
+ await cronToggle(subArgs[0], true);
835
+ break;
836
+ case 'disable':
837
+ if (!subArgs[0]) {
838
+ log('red', 'Error: Cron job ID required');
839
+ process.exit(1);
840
+ }
841
+ await cronToggle(subArgs[0], false);
842
+ break;
843
+ default:
844
+ log('red', `Unknown cron subcommand: ${subcommand}`);
845
+ usage();
846
+ }
847
+ }
848
+
849
+ async function cronAdd(args) {
850
+ let cronName = '';
851
+ let schedule = '';
852
+ let workspace = '';
853
+ let persistent = false;
854
+ let script = '';
855
+
856
+ for (let i = 0; i < args.length; i++) {
857
+ switch (args[i]) {
858
+ case '--name':
859
+ cronName = args[++i];
860
+ break;
861
+ case '--schedule':
862
+ schedule = args[++i];
863
+ break;
864
+ case '-w':
865
+ case '--workspace':
866
+ workspace = args[++i];
867
+ break;
868
+ case '-p':
869
+ case '--persistent':
870
+ persistent = true;
871
+ break;
872
+ default:
873
+ script = args.slice(i).join(' ');
874
+ i = args.length;
875
+ }
876
+ }
877
+
878
+ if (!cronName) {
879
+ log('red', 'Error: --name is required for cron jobs');
880
+ process.exit(1);
881
+ }
882
+ if (!schedule) {
883
+ log('red', 'Error: --schedule is required (e.g. "0 * * * *")');
884
+ process.exit(1);
885
+ }
886
+ if (!script) {
887
+ log('red', 'Error: Script is required');
888
+ process.exit(1);
889
+ }
890
+
891
+ const payload = {
892
+ name: cronName,
893
+ cron_expression: schedule,
894
+ script,
895
+ persistent
896
+ };
897
+ if (workspace) payload.workspace_id = workspace;
898
+
899
+ const resp = await request('POST', '/api/v1/mags-cron', payload);
900
+ if (resp.id) {
901
+ log('green', `Cron job created: ${resp.id}`);
902
+ log('blue', `Name: ${cronName}`);
903
+ log('blue', `Schedule: ${schedule}`);
904
+ if (resp.next_run_at) log('blue', `Next run: ${resp.next_run_at}`);
905
+ } else {
906
+ log('red', 'Failed to create cron job:');
907
+ console.log(JSON.stringify(resp, null, 2));
908
+ process.exit(1);
909
+ }
910
+ }
911
+
912
+ async function cronList() {
913
+ const resp = await request('GET', '/api/v1/mags-cron');
914
+ if (resp.cron_jobs && resp.cron_jobs.length > 0) {
915
+ log('cyan', 'Cron Jobs:\n');
916
+ resp.cron_jobs.forEach(cron => {
917
+ const statusColor = cron.enabled ? 'green' : 'yellow';
918
+ console.log(`${colors.gray}${cron.id}${colors.reset}`);
919
+ console.log(` Name: ${cron.name}`);
920
+ console.log(` Schedule: ${cron.cron_expression}`);
921
+ console.log(` Enabled: ${colors[statusColor]}${cron.enabled}${colors.reset}`);
922
+ console.log(` Workspace: ${cron.workspace_id || '-'}`);
923
+ console.log(` Runs: ${cron.run_count || 0}`);
924
+ console.log(` Last Run: ${cron.last_run_at || '-'}`);
925
+ console.log(` Next Run: ${cron.next_run_at || '-'}`);
926
+ console.log(` Last Status: ${cron.last_status || '-'}`);
927
+ console.log('');
928
+ });
929
+ } else {
930
+ log('yellow', 'No cron jobs found');
931
+ }
932
+ }
933
+
934
+ async function cronRemove(id) {
935
+ const resp = await request('DELETE', `/api/v1/mags-cron/${id}`);
936
+ if (resp.success) {
937
+ log('green', 'Cron job deleted');
938
+ } else {
939
+ log('red', 'Failed to delete cron job');
940
+ console.log(JSON.stringify(resp, null, 2));
941
+ }
942
+ }
943
+
944
+ async function cronToggle(id, enabled) {
945
+ const resp = await request('PATCH', `/api/v1/mags-cron/${id}`, { enabled });
946
+ if (resp.id) {
947
+ log('green', `Cron job ${enabled ? 'enabled' : 'disabled'}`);
948
+ if (resp.next_run_at) log('blue', `Next run: ${resp.next_run_at}`);
949
+ } else {
950
+ log('red', `Failed to ${enabled ? 'enable' : 'disable'} cron job`);
951
+ console.log(JSON.stringify(resp, null, 2));
952
+ }
953
+ }
954
+
684
955
  async function sshToJob(nameOrId) {
685
956
  if (!nameOrId) {
686
957
  log('red', 'Error: Job name or ID required');
@@ -811,7 +1082,7 @@ async function main() {
811
1082
  break;
812
1083
  case '--version':
813
1084
  case '-v':
814
- console.log('mags v1.4.2');
1085
+ console.log('mags v1.4.3');
815
1086
  process.exit(0);
816
1087
  break;
817
1088
  case 'new':
@@ -846,6 +1117,10 @@ async function main() {
846
1117
  await requireAuth();
847
1118
  await stopJob(args[1]);
848
1119
  break;
1120
+ case 'cron':
1121
+ await requireAuth();
1122
+ await cronCommand(args.slice(1));
1123
+ break;
849
1124
  case 'setup-claude':
850
1125
  await setupClaude();
851
1126
  break;
package/index.js CHANGED
@@ -70,21 +70,38 @@ class Mags {
70
70
  * @param {string} options.name - Job name
71
71
  * @param {string} options.workspaceId - Persistent workspace ID
72
72
  * @param {boolean} options.persistent - Keep VM alive after script
73
+ * @param {boolean} options.ephemeral - No workspace/S3 sync (fastest)
73
74
  * @param {string} options.startupCommand - Command to run when waking from sleep
74
75
  * @param {object} options.environment - Environment variables
76
+ * @param {string[]} options.fileIds - File IDs from uploadFiles()
75
77
  * @returns {Promise<{requestId: string, status: string}>}
76
78
  */
77
79
  async run(script, options = {}) {
80
+ if (options.ephemeral && options.workspaceId) {
81
+ throw new Error('Cannot use ephemeral with workspaceId');
82
+ }
83
+ if (options.ephemeral && options.persistent) {
84
+ throw new Error('Cannot use ephemeral with persistent');
85
+ }
86
+
78
87
  const payload = {
79
88
  script,
80
89
  type: 'inline',
81
90
  name: options.name,
82
- workspace_id: options.workspaceId,
83
91
  persistent: options.persistent || false,
84
92
  startup_command: options.startupCommand,
85
93
  environment: options.environment
86
94
  };
87
95
 
96
+ // Only set workspace_id if not ephemeral
97
+ if (!options.ephemeral) {
98
+ payload.workspace_id = options.workspaceId;
99
+ }
100
+
101
+ if (options.fileIds && options.fileIds.length > 0) {
102
+ payload.file_ids = options.fileIds;
103
+ }
104
+
88
105
  const response = await this._request('POST', '/api/v1/mags-jobs', payload);
89
106
  return {
90
107
  requestId: response.request_id,
@@ -173,6 +190,135 @@ class Mags {
173
190
 
174
191
  throw new Error(`Job ${requestId} timed out after ${timeout}ms`);
175
192
  }
193
+
194
+ /**
195
+ * Upload files for use in a job
196
+ * @param {string[]} filePaths - Array of local file paths
197
+ * @returns {Promise<string[]>} Array of file IDs
198
+ */
199
+ async uploadFiles(filePaths) {
200
+ const fs = require('fs');
201
+ const path = require('path');
202
+ const fileIds = [];
203
+
204
+ for (const filePath of filePaths) {
205
+ const fileName = path.basename(filePath);
206
+ const fileData = fs.readFileSync(filePath);
207
+ const boundary = '----MagsBoundary' + Date.now().toString(16);
208
+
209
+ const parts = [];
210
+ parts.push(`--${boundary}\r\n`);
211
+ parts.push(`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`);
212
+ parts.push(`Content-Type: application/octet-stream\r\n\r\n`);
213
+ const header = Buffer.from(parts.join(''));
214
+ const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
215
+ const body = Buffer.concat([header, fileData, footer]);
216
+
217
+ const response = await this._multipartRequest('/api/v1/mags-files', body, boundary);
218
+ if (response.file_id) {
219
+ fileIds.push(response.file_id);
220
+ } else {
221
+ throw new Error(`Failed to upload file: ${fileName}`);
222
+ }
223
+ }
224
+
225
+ return fileIds;
226
+ }
227
+
228
+ _multipartRequest(apiPath, body, boundary) {
229
+ return new Promise((resolve, reject) => {
230
+ const url = new URL(apiPath, this.apiUrl);
231
+ const isHttps = url.protocol === 'https:';
232
+ const lib = isHttps ? https : http;
233
+
234
+ const options = {
235
+ hostname: url.hostname,
236
+ port: url.port || (isHttps ? 443 : 80),
237
+ path: url.pathname,
238
+ method: 'POST',
239
+ headers: {
240
+ 'Authorization': `Bearer ${this.apiToken}`,
241
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
242
+ 'Content-Length': body.length
243
+ }
244
+ };
245
+
246
+ const req = lib.request(options, (res) => {
247
+ let data = '';
248
+ res.on('data', chunk => data += chunk);
249
+ res.on('end', () => {
250
+ try {
251
+ resolve(JSON.parse(data));
252
+ } catch {
253
+ resolve(data);
254
+ }
255
+ });
256
+ });
257
+
258
+ req.on('error', reject);
259
+ req.write(body);
260
+ req.end();
261
+ });
262
+ }
263
+
264
+ // Cron job methods
265
+
266
+ /**
267
+ * Create a cron job
268
+ * @param {object} options - Cron job options
269
+ * @param {string} options.name - Cron job name
270
+ * @param {string} options.cronExpression - Cron expression (e.g., "0 * * * *")
271
+ * @param {string} options.script - Script to execute
272
+ * @param {string} options.workspaceId - Workspace ID
273
+ * @param {boolean} options.persistent - Keep VM alive
274
+ * @returns {Promise<object>}
275
+ */
276
+ async cronCreate(options) {
277
+ const payload = {
278
+ name: options.name,
279
+ cron_expression: options.cronExpression,
280
+ script: options.script,
281
+ workspace_id: options.workspaceId,
282
+ persistent: options.persistent || false
283
+ };
284
+ return this._request('POST', '/api/v1/mags-cron', payload);
285
+ }
286
+
287
+ /**
288
+ * List cron jobs
289
+ * @returns {Promise<{cron_jobs: Array}>}
290
+ */
291
+ async cronList() {
292
+ return this._request('GET', '/api/v1/mags-cron');
293
+ }
294
+
295
+ /**
296
+ * Get a cron job
297
+ * @param {string} id - Cron job ID
298
+ * @returns {Promise<object>}
299
+ */
300
+ async cronGet(id) {
301
+ return this._request('GET', `/api/v1/mags-cron/${id}`);
302
+ }
303
+
304
+ /**
305
+ * Update a cron job
306
+ * @param {string} id - Cron job ID
307
+ * @param {object} updates - Fields to update
308
+ * @returns {Promise<object>}
309
+ */
310
+ async cronUpdate(id, updates) {
311
+ return this._request('PATCH', `/api/v1/mags-cron/${id}`, updates);
312
+ }
313
+
314
+ /**
315
+ * Delete a cron job
316
+ * @param {string} id - Cron job ID
317
+ * @returns {Promise<object>}
318
+ */
319
+ async cronDelete(id) {
320
+ return this._request('DELETE', `/api/v1/mags-cron/${id}`);
321
+ }
176
322
  }
177
323
 
178
324
  module.exports = Mags;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpiecloud/mags",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "description": "Mags CLI - Execute scripts on Magpie's instant VM infrastructure",
5
5
  "main": "index.js",
6
6
  "bin": {