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