@magpiecloud/mags 1.8.16 → 1.8.17
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/API.md +388 -0
- package/Mags-API.postman_collection.json +374 -0
- package/QUICKSTART.md +295 -0
- package/README.md +378 -95
- package/bin/mags.js +23 -2
- package/deploy-page.sh +171 -0
- package/index.js +0 -2
- package/mags +0 -0
- package/mags.sh +270 -0
- package/nodejs/README.md +197 -0
- package/nodejs/bin/mags.js +1882 -0
- package/nodejs/index.js +603 -0
- package/nodejs/package.json +45 -0
- package/package.json +3 -18
- package/python/INTEGRATION.md +800 -0
- package/python/README.md +161 -0
- package/python/dist/magpie_mags-1.3.8-py3-none-any.whl +0 -0
- package/python/dist/magpie_mags-1.3.8.tar.gz +0 -0
- package/python/examples/demo.py +181 -0
- package/python/pyproject.toml +39 -0
- package/python/src/magpie_mags.egg-info/PKG-INFO +186 -0
- package/python/src/magpie_mags.egg-info/SOURCES.txt +9 -0
- package/python/src/magpie_mags.egg-info/dependency_links.txt +1 -0
- package/python/src/magpie_mags.egg-info/requires.txt +1 -0
- package/python/src/magpie_mags.egg-info/top_level.txt +1 -0
- package/python/src/mags/__init__.py +6 -0
- package/python/src/mags/client.py +527 -0
- package/python/test_sdk.py +78 -0
- package/skill.md +153 -0
- package/website/api.html +1095 -0
- package/website/claude-skill.html +481 -0
- package/website/cookbook/hn-marketing.html +410 -0
- package/website/cookbook/hn-marketing.sh +42 -0
- package/website/cookbook.html +282 -0
- package/website/docs.html +677 -0
- package/website/env.js +4 -0
- package/website/index.html +801 -0
- package/website/llms.txt +334 -0
- package/website/login.html +108 -0
- package/website/mags.md +210 -0
- package/website/script.js +453 -0
- package/website/styles.css +1075 -0
- package/website/tokens.html +169 -0
- package/website/usage.html +185 -0
|
@@ -0,0 +1,1882 @@
|
|
|
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 (prefer running/sleeping)
|
|
153
|
+
let job = resp.jobs.find(j => j.name === nameOrId && (j.status === 'running' || j.status === 'sleeping'));
|
|
154
|
+
if (job) return job.request_id;
|
|
155
|
+
|
|
156
|
+
// Try workspace_id match (prefer running/sleeping)
|
|
157
|
+
job = resp.jobs.find(j => j.workspace_id === nameOrId && (j.status === 'running' || j.status === 'sleeping'));
|
|
158
|
+
if (job) return job.request_id;
|
|
159
|
+
|
|
160
|
+
// Try exact name match (any status)
|
|
161
|
+
job = resp.jobs.find(j => j.name === nameOrId);
|
|
162
|
+
if (job) return job.request_id;
|
|
163
|
+
|
|
164
|
+
// Try workspace_id match (any status)
|
|
165
|
+
job = resp.jobs.find(j => j.workspace_id === nameOrId);
|
|
166
|
+
if (job) return job.request_id;
|
|
167
|
+
|
|
168
|
+
// Try partial name match (running/sleeping jobs only)
|
|
169
|
+
job = resp.jobs.find(j =>
|
|
170
|
+
(j.status === 'running' || j.status === 'sleeping') &&
|
|
171
|
+
(j.name && j.name.includes(nameOrId))
|
|
172
|
+
);
|
|
173
|
+
if (job) return job.request_id;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return nameOrId; // Return as-is, let API handle error
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Find a running or sleeping job for a workspace
|
|
180
|
+
async function findWorkspaceJob(workspace) {
|
|
181
|
+
const resp = await request('GET', `/api/v1/mags-jobs?page=1&page_size=50`);
|
|
182
|
+
if (resp.jobs && resp.jobs.length > 0) {
|
|
183
|
+
// Prefer running, then sleeping
|
|
184
|
+
let job = resp.jobs.find(j => j.workspace_id === workspace && j.status === 'running');
|
|
185
|
+
if (job) return job;
|
|
186
|
+
job = resp.jobs.find(j => j.workspace_id === workspace && j.status === 'sleeping');
|
|
187
|
+
if (job) return job;
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function usage() {
|
|
193
|
+
console.log(`
|
|
194
|
+
${colors.cyan}${colors.bold}Mags CLI - Instant VM Execution${colors.reset}
|
|
195
|
+
|
|
196
|
+
Usage: mags <command> [options] [script]
|
|
197
|
+
|
|
198
|
+
${colors.bold}Commands:${colors.reset}
|
|
199
|
+
login Authenticate with Magpie
|
|
200
|
+
logout Remove saved credentials
|
|
201
|
+
whoami Show current authenticated user
|
|
202
|
+
new <name> Create a new persistent VM (returns ID only)
|
|
203
|
+
run [options] <script> Execute a script on a microVM
|
|
204
|
+
ssh <workspace|name|id> Open SSH session (auto-starts VM if needed)
|
|
205
|
+
browser [workspace] Start a Chromium browser session (CDP access)
|
|
206
|
+
exec <workspace> <command> Run a command on an existing VM
|
|
207
|
+
status <name|id> Get job status
|
|
208
|
+
logs <name|id> Get job logs
|
|
209
|
+
list List recent jobs
|
|
210
|
+
url <name|id> [port] Enable URL access for a job
|
|
211
|
+
url alias <sub> <workspace> Create a stable URL alias for a workspace
|
|
212
|
+
url alias list List your URL aliases
|
|
213
|
+
url alias remove <subdomain> Delete a URL alias
|
|
214
|
+
set <name|id> [options] Update VM settings
|
|
215
|
+
stop <name|id> Stop a running job
|
|
216
|
+
resize <workspace> --disk <GB> Resize a workspace's disk (restarts VM)
|
|
217
|
+
sync <workspace|id> Sync workspace to S3 (without stopping)
|
|
218
|
+
workspace list List persistent workspaces
|
|
219
|
+
workspace delete <id> Delete a workspace and its S3 data
|
|
220
|
+
setup-claude Install Mags skill for Claude Code
|
|
221
|
+
|
|
222
|
+
${colors.bold}Run Options:${colors.reset}
|
|
223
|
+
-n, --name <name> Set job/workspace name (used as both)
|
|
224
|
+
-p, --persistent Keep VM alive after script completes
|
|
225
|
+
--no-sleep Never auto-sleep this VM (requires -p)
|
|
226
|
+
--base <workspace> Mount workspace read-only as base image
|
|
227
|
+
-e, --ephemeral No workspace/S3 sync (fastest execution)
|
|
228
|
+
-f, --file <path> Upload file(s) to VM (repeatable)
|
|
229
|
+
--disk <GB> Custom disk size in GB (default: 2)
|
|
230
|
+
--url Enable public URL access (requires -p)
|
|
231
|
+
--port <port> Port to expose for URL (default: 8080)
|
|
232
|
+
--startup-command <cmd> Command to run when VM wakes from sleep
|
|
233
|
+
|
|
234
|
+
${colors.bold}Cron Commands:${colors.reset}
|
|
235
|
+
cron add [options] <script> Create a scheduled cron job
|
|
236
|
+
cron list List all cron jobs
|
|
237
|
+
cron remove <id> Delete a cron job
|
|
238
|
+
cron enable <id> Enable a cron job
|
|
239
|
+
cron disable <id> Disable a cron job
|
|
240
|
+
|
|
241
|
+
${colors.bold}Cron Options:${colors.reset}
|
|
242
|
+
--name <name> Cron job name (required)
|
|
243
|
+
--schedule <expr> Cron expression (required, e.g. "0 * * * *")
|
|
244
|
+
-w, --workspace <id> Workspace for cron jobs (alias for --name)
|
|
245
|
+
-p, --persistent Keep VM alive after cron script
|
|
246
|
+
|
|
247
|
+
${colors.bold}Examples:${colors.reset}
|
|
248
|
+
mags login
|
|
249
|
+
mags new myvm # Create VM (local disk only)
|
|
250
|
+
mags new myvm -p # Create VM with S3 persistence
|
|
251
|
+
mags ssh myvm # SSH (auto-starts if needed)
|
|
252
|
+
mags exec myvm 'ls -la' # Run command on existing VM
|
|
253
|
+
mags run 'echo Hello World'
|
|
254
|
+
mags run -e 'echo fast' # Ephemeral (no S3 sync)
|
|
255
|
+
mags run -f script.py 'python3 script.py' # Upload + run file
|
|
256
|
+
mags run -n myproject 'python3 script.py'
|
|
257
|
+
mags run --base golden 'npm test' # Use golden as read-only base
|
|
258
|
+
mags run --base golden -n fork-1 'npm test' # Base + save changes to fork-1
|
|
259
|
+
mags run -p --url 'python3 -m http.server 8080'
|
|
260
|
+
mags run -n webapp -p --url --port 3000 'npm start'
|
|
261
|
+
mags browser myproject # Start browser with workspace
|
|
262
|
+
mags workspace list # List workspaces
|
|
263
|
+
mags workspace delete myproject # Delete workspace
|
|
264
|
+
mags cron add --name backup --schedule "0 0 * * *" 'tar czf backup.tar.gz data/'
|
|
265
|
+
mags cron list
|
|
266
|
+
mags status myvm
|
|
267
|
+
mags logs myvm
|
|
268
|
+
mags url myvm 8080
|
|
269
|
+
mags url alias my-api myvm # Stable URL: my-api.apps.magpiecloud.com
|
|
270
|
+
mags url alias my-api myvm --lfg # Stable URL: my-api.app.lfg.run
|
|
271
|
+
mags url alias list # List all aliases
|
|
272
|
+
mags url alias remove my-api # Remove alias
|
|
273
|
+
mags setup-claude # Install Claude Code skill
|
|
274
|
+
`);
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function login() {
|
|
279
|
+
console.log(`
|
|
280
|
+
${colors.cyan}${colors.bold}Mags Login${colors.reset}
|
|
281
|
+
|
|
282
|
+
To authenticate, you need an API token from Magpie.
|
|
283
|
+
`);
|
|
284
|
+
|
|
285
|
+
log('blue', 'Opening Mags to create an API token...');
|
|
286
|
+
console.log('');
|
|
287
|
+
|
|
288
|
+
const tokenUrl = 'https://mags.run/tokens';
|
|
289
|
+
openBrowser(tokenUrl);
|
|
290
|
+
|
|
291
|
+
await sleep(1000);
|
|
292
|
+
|
|
293
|
+
console.log(`${colors.gray}If the browser didn't open, visit:${colors.reset}`);
|
|
294
|
+
console.log(`${colors.cyan}${tokenUrl}${colors.reset}`);
|
|
295
|
+
console.log('');
|
|
296
|
+
console.log(`${colors.gray}1. Click "Create Token"${colors.reset}`);
|
|
297
|
+
console.log(`${colors.gray}2. Give it a name (e.g., "mags-cli")${colors.reset}`);
|
|
298
|
+
console.log(`${colors.gray}3. Copy the token and paste it below${colors.reset}`);
|
|
299
|
+
console.log('');
|
|
300
|
+
|
|
301
|
+
const token = await prompt(`${colors.bold}Paste your API token: ${colors.reset}`);
|
|
302
|
+
|
|
303
|
+
if (!token) {
|
|
304
|
+
log('red', 'No token provided. Login cancelled.');
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Validate token by making a test request
|
|
309
|
+
API_TOKEN = token;
|
|
310
|
+
log('blue', 'Verifying token...');
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
const resp = await request('GET', '/api/v1/mags-jobs?page=1&page_size=1');
|
|
314
|
+
|
|
315
|
+
if (resp.error === 'unauthorized' || resp.error === 'Unauthorized') {
|
|
316
|
+
log('red', 'Invalid token. Please check and try again.');
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Save token
|
|
321
|
+
const newConfig = { ...config, api_token: token };
|
|
322
|
+
if (saveConfig(newConfig)) {
|
|
323
|
+
console.log('');
|
|
324
|
+
log('green', 'Login successful!');
|
|
325
|
+
log('gray', `Token saved to ${CONFIG_FILE}`);
|
|
326
|
+
console.log('');
|
|
327
|
+
log('cyan', 'You can now run mags commands. Try:');
|
|
328
|
+
console.log(` ${colors.bold}mags new myvm${colors.reset}`);
|
|
329
|
+
console.log(` ${colors.bold}mags ssh myvm${colors.reset}`);
|
|
330
|
+
} else {
|
|
331
|
+
log('yellow', 'Login successful, but could not save token to config file.');
|
|
332
|
+
log('yellow', 'You may need to login again next time.');
|
|
333
|
+
}
|
|
334
|
+
} catch (err) {
|
|
335
|
+
log('red', `Error validating token: ${err.message}`);
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function logout() {
|
|
341
|
+
if (!config.api_token) {
|
|
342
|
+
log('yellow', 'You are not logged in.');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const newConfig = { ...config };
|
|
347
|
+
delete newConfig.api_token;
|
|
348
|
+
|
|
349
|
+
if (saveConfig(newConfig)) {
|
|
350
|
+
log('green', 'Logged out successfully.');
|
|
351
|
+
log('gray', 'Token removed from config.');
|
|
352
|
+
} else {
|
|
353
|
+
log('red', 'Could not remove token from config file.');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function whoami() {
|
|
358
|
+
if (!API_TOKEN) {
|
|
359
|
+
log('yellow', 'Not logged in.');
|
|
360
|
+
log('gray', 'Run `mags login` to authenticate.');
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
log('blue', 'Checking authentication...');
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
const resp = await request('GET', '/api/v1/mags-jobs?page=1&page_size=1');
|
|
368
|
+
|
|
369
|
+
if (resp.error === 'unauthorized' || resp.error === 'Unauthorized') {
|
|
370
|
+
log('red', 'Token is invalid or expired.');
|
|
371
|
+
log('gray', 'Run `mags login` to re-authenticate.');
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
log('green', 'Authenticated');
|
|
376
|
+
if (process.env.MAGS_API_TOKEN) {
|
|
377
|
+
log('gray', 'Using token from MAGS_API_TOKEN environment variable');
|
|
378
|
+
} else {
|
|
379
|
+
log('gray', `Using token from ${CONFIG_FILE}`);
|
|
380
|
+
}
|
|
381
|
+
} catch (err) {
|
|
382
|
+
log('red', `Error: ${err.message}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function requireAuth() {
|
|
387
|
+
if (API_TOKEN) return true;
|
|
388
|
+
|
|
389
|
+
console.log(`
|
|
390
|
+
${colors.yellow}You are not logged in.${colors.reset}
|
|
391
|
+
|
|
392
|
+
To use Mags, you need to authenticate first.
|
|
393
|
+
`);
|
|
394
|
+
|
|
395
|
+
const answer = await prompt(`${colors.bold}Would you like to login now? (Y/n): ${colors.reset}`);
|
|
396
|
+
|
|
397
|
+
if (answer.toLowerCase() === 'n' || answer.toLowerCase() === 'no') {
|
|
398
|
+
log('gray', 'You can login later with: mags login');
|
|
399
|
+
process.exit(0);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
await login();
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Create a new persistent VM
|
|
407
|
+
async function newVM(args) {
|
|
408
|
+
let name = null;
|
|
409
|
+
let baseWorkspace = null;
|
|
410
|
+
let diskGB = 0;
|
|
411
|
+
let persistent = false;
|
|
412
|
+
|
|
413
|
+
for (let i = 0; i < args.length; i++) {
|
|
414
|
+
if (args[i] === '-p' || args[i] === '--persistent') {
|
|
415
|
+
persistent = true;
|
|
416
|
+
} else if (args[i] === '--base' && args[i + 1]) {
|
|
417
|
+
baseWorkspace = args[++i];
|
|
418
|
+
} else if (args[i] === '--disk' && args[i + 1]) {
|
|
419
|
+
diskGB = parseInt(args[++i]) || 0;
|
|
420
|
+
} else if (!name) {
|
|
421
|
+
name = args[i];
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (!name) {
|
|
426
|
+
log('red', 'Error: Name required');
|
|
427
|
+
console.log(`\nUsage: mags new <name> [-p] [--base <workspace>] [--disk <GB>]`);
|
|
428
|
+
console.log(` -p, --persistent Enable S3 data persistence\n`);
|
|
429
|
+
process.exit(1);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Check if a VM with this name already exists (running or sleeping)
|
|
433
|
+
try {
|
|
434
|
+
const jobs = await request('GET', '/api/v1/mags-jobs');
|
|
435
|
+
const existing = (jobs.jobs || []).find(j =>
|
|
436
|
+
j.name === name && (j.status === 'running' || j.status === 'sleeping')
|
|
437
|
+
);
|
|
438
|
+
if (existing) {
|
|
439
|
+
log('yellow', `Sandbox '${name}' already exists (status: ${existing.status})`);
|
|
440
|
+
console.log(` SSH: mags ssh ${name}`);
|
|
441
|
+
console.log(` Exec: mags exec ${name} <command>`);
|
|
442
|
+
console.log(` Stop: mags stop ${name}`);
|
|
443
|
+
process.exit(0);
|
|
444
|
+
}
|
|
445
|
+
} catch (e) {
|
|
446
|
+
// Non-fatal — proceed with creation if list fails
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const payload = {
|
|
450
|
+
script: 'sleep infinity',
|
|
451
|
+
type: 'inline',
|
|
452
|
+
persistent: true,
|
|
453
|
+
name: name,
|
|
454
|
+
workspace_id: name,
|
|
455
|
+
startup_command: 'sleep infinity',
|
|
456
|
+
no_sync: !persistent
|
|
457
|
+
};
|
|
458
|
+
if (baseWorkspace) payload.base_workspace_id = baseWorkspace;
|
|
459
|
+
if (diskGB) payload.disk_gb = diskGB;
|
|
460
|
+
|
|
461
|
+
const response = await request('POST', '/api/v1/mags-jobs', payload);
|
|
462
|
+
|
|
463
|
+
if (!response.request_id) {
|
|
464
|
+
log('red', 'Failed to create VM:');
|
|
465
|
+
console.log(JSON.stringify(response, null, 2));
|
|
466
|
+
process.exit(1);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Wait for VM to be ready
|
|
470
|
+
const maxAttempts = 60;
|
|
471
|
+
let attempt = 0;
|
|
472
|
+
|
|
473
|
+
while (attempt < maxAttempts) {
|
|
474
|
+
const status = await request('GET', `/api/v1/mags-jobs/${response.request_id}/status`);
|
|
475
|
+
|
|
476
|
+
if (status.status === 'running') {
|
|
477
|
+
log('green', `VM '${name}' created successfully${persistent ? ' (persistent)' : ' (local disk)'}`);
|
|
478
|
+
console.log(` ID: ${response.request_id}`);
|
|
479
|
+
console.log(` SSH: mags ssh ${name}`);
|
|
480
|
+
if (!persistent) {
|
|
481
|
+
log('gray', ` Data is on local disk only. Use -p flag for S3 persistence.`);
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
} else if (status.status === 'error') {
|
|
485
|
+
log('red', `VM creation failed: ${status.error_message || 'Unknown error'}`);
|
|
486
|
+
process.exit(1);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
await sleep(500);
|
|
490
|
+
attempt++;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
log('yellow', 'VM creation timed out, but may still be starting');
|
|
494
|
+
console.log(response.request_id);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function runJob(args) {
|
|
498
|
+
let script = '';
|
|
499
|
+
let baseWorkspace = '';
|
|
500
|
+
let name = '';
|
|
501
|
+
let persistent = false;
|
|
502
|
+
let noSleep = false;
|
|
503
|
+
let ephemeral = false;
|
|
504
|
+
let enableUrl = false;
|
|
505
|
+
let port = 8080;
|
|
506
|
+
let startupCommand = '';
|
|
507
|
+
let diskGB = 0;
|
|
508
|
+
let fileArgs = [];
|
|
509
|
+
|
|
510
|
+
// Parse flags
|
|
511
|
+
for (let i = 0; i < args.length; i++) {
|
|
512
|
+
switch (args[i]) {
|
|
513
|
+
case '-w':
|
|
514
|
+
case '--workspace':
|
|
515
|
+
case '-n':
|
|
516
|
+
case '--name':
|
|
517
|
+
name = args[++i];
|
|
518
|
+
break;
|
|
519
|
+
case '--base':
|
|
520
|
+
baseWorkspace = args[++i];
|
|
521
|
+
break;
|
|
522
|
+
case '-p':
|
|
523
|
+
case '--persistent':
|
|
524
|
+
persistent = true;
|
|
525
|
+
break;
|
|
526
|
+
case '--no-sleep':
|
|
527
|
+
noSleep = true;
|
|
528
|
+
break;
|
|
529
|
+
case '-e':
|
|
530
|
+
case '--ephemeral':
|
|
531
|
+
ephemeral = true;
|
|
532
|
+
break;
|
|
533
|
+
case '-f':
|
|
534
|
+
case '--file':
|
|
535
|
+
fileArgs.push(args[++i]);
|
|
536
|
+
break;
|
|
537
|
+
case '--url':
|
|
538
|
+
enableUrl = true;
|
|
539
|
+
break;
|
|
540
|
+
case '--port':
|
|
541
|
+
port = parseInt(args[++i]) || 8080;
|
|
542
|
+
break;
|
|
543
|
+
case '--disk':
|
|
544
|
+
diskGB = parseInt(args[++i]) || 0;
|
|
545
|
+
break;
|
|
546
|
+
case '--startup-command':
|
|
547
|
+
startupCommand = args[++i];
|
|
548
|
+
break;
|
|
549
|
+
default:
|
|
550
|
+
script = args.slice(i).join(' ');
|
|
551
|
+
i = args.length;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (!script) {
|
|
556
|
+
log('red', 'Error: No script provided');
|
|
557
|
+
usage();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Validate flag combinations
|
|
561
|
+
if (ephemeral && name) {
|
|
562
|
+
log('red', 'Error: Cannot use --ephemeral with --name; ephemeral VMs have no persistent storage');
|
|
563
|
+
process.exit(1);
|
|
564
|
+
}
|
|
565
|
+
if (ephemeral && persistent) {
|
|
566
|
+
log('red', 'Error: Cannot use --ephemeral with --persistent; ephemeral VMs are destroyed after execution');
|
|
567
|
+
process.exit(1);
|
|
568
|
+
}
|
|
569
|
+
if (ephemeral && baseWorkspace) {
|
|
570
|
+
log('red', 'Error: Cannot use --ephemeral with --base; ephemeral VMs have no workspace support');
|
|
571
|
+
process.exit(1);
|
|
572
|
+
}
|
|
573
|
+
if (noSleep && !persistent) {
|
|
574
|
+
log('red', 'Error: --no-sleep requires --persistent (-p)');
|
|
575
|
+
process.exit(1);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Upload files if any
|
|
579
|
+
let fileIds = [];
|
|
580
|
+
if (fileArgs.length > 0) {
|
|
581
|
+
for (const filePath of fileArgs) {
|
|
582
|
+
log('blue', `Uploading ${filePath}...`);
|
|
583
|
+
const fileId = await uploadFile(filePath);
|
|
584
|
+
if (fileId) {
|
|
585
|
+
fileIds.push(fileId);
|
|
586
|
+
log('green', `Uploaded: ${filePath} (${fileId})`);
|
|
587
|
+
} else {
|
|
588
|
+
log('red', `Failed to upload: ${filePath}`);
|
|
589
|
+
process.exit(1);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
log('blue', 'Submitting job...');
|
|
595
|
+
|
|
596
|
+
const payload = {
|
|
597
|
+
script,
|
|
598
|
+
type: 'inline',
|
|
599
|
+
persistent
|
|
600
|
+
};
|
|
601
|
+
if (noSleep) payload.no_sleep = true;
|
|
602
|
+
// name and workspace_id are always the same
|
|
603
|
+
if (name) {
|
|
604
|
+
payload.name = name;
|
|
605
|
+
if (!ephemeral) payload.workspace_id = name;
|
|
606
|
+
}
|
|
607
|
+
if (baseWorkspace) payload.base_workspace_id = baseWorkspace;
|
|
608
|
+
if (startupCommand) payload.startup_command = startupCommand;
|
|
609
|
+
if (fileIds.length > 0) payload.file_ids = fileIds;
|
|
610
|
+
if (diskGB) payload.disk_gb = diskGB;
|
|
611
|
+
|
|
612
|
+
const response = await request('POST', '/api/v1/mags-jobs', payload);
|
|
613
|
+
|
|
614
|
+
if (!response.request_id) {
|
|
615
|
+
log('red', 'Failed to submit job:');
|
|
616
|
+
console.log(JSON.stringify(response, null, 2));
|
|
617
|
+
process.exit(1);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const requestId = response.request_id;
|
|
621
|
+
log('green', `Job submitted: ${requestId}`);
|
|
622
|
+
if (name) log('blue', `Name: ${name}`);
|
|
623
|
+
if (baseWorkspace) log('blue', `Base workspace: ${baseWorkspace} (read-only)`);
|
|
624
|
+
if (persistent) log('yellow', 'Persistent: VM will stay alive');
|
|
625
|
+
|
|
626
|
+
// Poll for completion (200ms intervals, 600 attempts = 2 min timeout)
|
|
627
|
+
const maxAttempts = 600;
|
|
628
|
+
let attempt = 0;
|
|
629
|
+
|
|
630
|
+
while (attempt < maxAttempts) {
|
|
631
|
+
const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
|
|
632
|
+
|
|
633
|
+
if (status.status === 'completed') {
|
|
634
|
+
log('green', `Completed in ${status.script_duration_ms}ms`);
|
|
635
|
+
break;
|
|
636
|
+
} else if (status.status === 'running' && persistent) {
|
|
637
|
+
// If --url requested, wait until VM is actually assigned (vm_id populated)
|
|
638
|
+
if (enableUrl && !status.vm_id) {
|
|
639
|
+
process.stdout.write('.');
|
|
640
|
+
await sleep(200);
|
|
641
|
+
attempt++;
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
log('green', 'VM running');
|
|
646
|
+
|
|
647
|
+
if (enableUrl && status.subdomain) {
|
|
648
|
+
log('blue', `Enabling URL access on port ${port}...`);
|
|
649
|
+
const accessResp = await request('POST', `/api/v1/mags-jobs/${requestId}/access`, { port });
|
|
650
|
+
if (accessResp.success) {
|
|
651
|
+
log('green', `URL: https://${status.subdomain}.apps.magpiecloud.com`);
|
|
652
|
+
} else {
|
|
653
|
+
log('yellow', `Warning: Could not enable URL access${accessResp.error ? ': ' + accessResp.error : ''}`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return;
|
|
657
|
+
} else if (status.status === 'error') {
|
|
658
|
+
log('red', 'Job failed');
|
|
659
|
+
console.log(JSON.stringify(status, null, 2));
|
|
660
|
+
process.exit(1);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
process.stdout.write('.');
|
|
664
|
+
await sleep(200);
|
|
665
|
+
attempt++;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
console.log('');
|
|
669
|
+
|
|
670
|
+
// Get logs
|
|
671
|
+
log('cyan', 'Output:');
|
|
672
|
+
const logsResp = await request('GET', `/api/v1/mags-jobs/${requestId}/logs`);
|
|
673
|
+
if (logsResp.logs) {
|
|
674
|
+
logsResp.logs
|
|
675
|
+
.filter(l => l.source === 'stdout' || l.source === 'stderr')
|
|
676
|
+
.forEach(l => console.log(l.message));
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function enableUrlAccess(nameOrId, port = 8080) {
|
|
681
|
+
if (!nameOrId) {
|
|
682
|
+
log('red', 'Error: Job name or ID required');
|
|
683
|
+
usage();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const requestId = await resolveJobId(nameOrId);
|
|
687
|
+
log('blue', `Enabling URL access on port ${port}...`);
|
|
688
|
+
|
|
689
|
+
const accessResp = await request('POST', `/api/v1/mags-jobs/${requestId}/access`, { port });
|
|
690
|
+
|
|
691
|
+
if (accessResp.success) {
|
|
692
|
+
const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
|
|
693
|
+
if (status.subdomain) {
|
|
694
|
+
log('green', `URL enabled: https://${status.subdomain}.apps.magpiecloud.com`);
|
|
695
|
+
} else {
|
|
696
|
+
log('green', 'URL access enabled');
|
|
697
|
+
}
|
|
698
|
+
} else {
|
|
699
|
+
log('red', 'Failed to enable URL access');
|
|
700
|
+
console.log(JSON.stringify(accessResp, null, 2));
|
|
701
|
+
process.exit(1);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function urlAliasCommand(args) {
|
|
706
|
+
if (args.length === 0) {
|
|
707
|
+
log('red', 'Error: URL alias subcommand required');
|
|
708
|
+
console.log('\nUsage:');
|
|
709
|
+
console.log(' mags url alias <subdomain> <workspace> [--lfg]');
|
|
710
|
+
console.log(' mags url alias list');
|
|
711
|
+
console.log(' mags url alias remove <subdomain>');
|
|
712
|
+
process.exit(1);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const subcommand = args[0];
|
|
716
|
+
|
|
717
|
+
switch (subcommand) {
|
|
718
|
+
case 'list':
|
|
719
|
+
case 'ls':
|
|
720
|
+
await urlAliasList();
|
|
721
|
+
break;
|
|
722
|
+
case 'remove':
|
|
723
|
+
case 'rm':
|
|
724
|
+
case 'delete':
|
|
725
|
+
if (!args[1]) {
|
|
726
|
+
log('red', 'Error: Subdomain required');
|
|
727
|
+
console.log('\nUsage: mags url alias remove <subdomain>');
|
|
728
|
+
process.exit(1);
|
|
729
|
+
}
|
|
730
|
+
await urlAliasRemove(args[1]);
|
|
731
|
+
break;
|
|
732
|
+
default:
|
|
733
|
+
// mags url alias <subdomain> <workspace> [--lfg]
|
|
734
|
+
const subdomain = args[0];
|
|
735
|
+
const workspace = args[1];
|
|
736
|
+
if (!workspace) {
|
|
737
|
+
log('red', 'Error: Workspace required');
|
|
738
|
+
console.log('\nUsage: mags url alias <subdomain> <workspace> [--lfg]');
|
|
739
|
+
process.exit(1);
|
|
740
|
+
}
|
|
741
|
+
const useLfg = args.includes('--lfg');
|
|
742
|
+
await urlAliasCreate(subdomain, workspace, useLfg);
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function urlAliasCreate(subdomain, workspaceId, useLfg) {
|
|
748
|
+
const domain = useLfg ? 'app.lfg.run' : 'apps.magpiecloud.com';
|
|
749
|
+
log('blue', `Creating URL alias: ${subdomain}.${domain} → workspace '${workspaceId}'...`);
|
|
750
|
+
|
|
751
|
+
const resp = await request('POST', '/api/v1/mags-url-aliases', {
|
|
752
|
+
subdomain,
|
|
753
|
+
workspace_id: workspaceId,
|
|
754
|
+
domain,
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
if (resp.error) {
|
|
758
|
+
log('red', `Error: ${resp.error}`);
|
|
759
|
+
process.exit(1);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (resp.url) {
|
|
763
|
+
log('green', `URL alias created: ${resp.url}`);
|
|
764
|
+
} else {
|
|
765
|
+
log('green', `URL alias created: https://${subdomain}.${domain}`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function urlAliasList() {
|
|
770
|
+
const resp = await request('GET', '/api/v1/mags-url-aliases');
|
|
771
|
+
const aliases = resp.aliases || [];
|
|
772
|
+
|
|
773
|
+
if (aliases.length > 0) {
|
|
774
|
+
log('cyan', 'URL Aliases:\n');
|
|
775
|
+
aliases.forEach(a => {
|
|
776
|
+
console.log(` ${colors.bold}${a.subdomain}${colors.reset}`);
|
|
777
|
+
console.log(` URL: ${colors.green}${a.url}${colors.reset}`);
|
|
778
|
+
console.log(` Workspace: ${a.workspace_id} Domain: ${a.domain}`);
|
|
779
|
+
console.log('');
|
|
780
|
+
});
|
|
781
|
+
log('gray', `Total: ${aliases.length} alias(es)`);
|
|
782
|
+
} else {
|
|
783
|
+
log('yellow', 'No URL aliases found');
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async function urlAliasRemove(subdomain) {
|
|
788
|
+
log('blue', `Removing URL alias '${subdomain}'...`);
|
|
789
|
+
const resp = await request('DELETE', `/api/v1/mags-url-aliases/${subdomain}`);
|
|
790
|
+
if (resp.error) {
|
|
791
|
+
log('red', `Error: ${resp.error}`);
|
|
792
|
+
process.exit(1);
|
|
793
|
+
}
|
|
794
|
+
log('green', resp.message || `URL alias '${subdomain}' removed`);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function getStatus(nameOrId) {
|
|
798
|
+
if (!nameOrId) {
|
|
799
|
+
log('red', 'Error: Job name or ID required');
|
|
800
|
+
usage();
|
|
801
|
+
}
|
|
802
|
+
const requestId = await resolveJobId(nameOrId);
|
|
803
|
+
const status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
|
|
804
|
+
console.log(JSON.stringify(status, null, 2));
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async function getLogs(nameOrId) {
|
|
808
|
+
if (!nameOrId) {
|
|
809
|
+
log('red', 'Error: Job name or ID required');
|
|
810
|
+
usage();
|
|
811
|
+
}
|
|
812
|
+
const requestId = await resolveJobId(nameOrId);
|
|
813
|
+
const logsResp = await request('GET', `/api/v1/mags-jobs/${requestId}/logs`);
|
|
814
|
+
if (logsResp.logs) {
|
|
815
|
+
logsResp.logs.forEach(l => {
|
|
816
|
+
const levelColor = l.level === 'error' ? 'red' : l.level === 'warn' ? 'yellow' : 'gray';
|
|
817
|
+
console.log(`${colors[levelColor]}[${l.level}]${colors.reset} ${l.message}`);
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function listJobs() {
|
|
823
|
+
const resp = await request('GET', '/api/v1/mags-jobs?page=1&page_size=10');
|
|
824
|
+
if (resp.jobs && resp.jobs.length > 0) {
|
|
825
|
+
log('cyan', 'Recent Jobs:\n');
|
|
826
|
+
resp.jobs.forEach(job => {
|
|
827
|
+
const statusColor = job.status === 'completed' ? 'green'
|
|
828
|
+
: job.status === 'running' ? 'blue'
|
|
829
|
+
: job.status === 'error' ? 'red'
|
|
830
|
+
: 'yellow';
|
|
831
|
+
console.log(`${colors.gray}${job.request_id}${colors.reset}`);
|
|
832
|
+
console.log(` Name: ${job.name || '-'}`);
|
|
833
|
+
console.log(` Status: ${colors[statusColor]}${job.status}${colors.reset}`);
|
|
834
|
+
console.log(` Workspace: ${job.workspace_id || '-'}`);
|
|
835
|
+
console.log(` Duration: ${job.script_duration_ms ? job.script_duration_ms + 'ms' : '-'}`);
|
|
836
|
+
console.log(` Created: ${job.created_at || '-'}`);
|
|
837
|
+
console.log('');
|
|
838
|
+
});
|
|
839
|
+
} else {
|
|
840
|
+
log('yellow', 'No jobs found');
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async function stopJob(nameOrId) {
|
|
845
|
+
if (!nameOrId) {
|
|
846
|
+
log('red', 'Error: Job name or ID required');
|
|
847
|
+
usage();
|
|
848
|
+
}
|
|
849
|
+
const requestId = await resolveJobId(nameOrId);
|
|
850
|
+
log('blue', `Stopping job ${requestId}...`);
|
|
851
|
+
const resp = await request('POST', `/api/v1/mags-jobs/${requestId}/stop`);
|
|
852
|
+
if (resp.success) {
|
|
853
|
+
log('green', 'Job stopped');
|
|
854
|
+
} else {
|
|
855
|
+
log('red', 'Failed to stop job');
|
|
856
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function setJobSettings(args) {
|
|
861
|
+
let nameOrId = null;
|
|
862
|
+
let noSleep = null;
|
|
863
|
+
|
|
864
|
+
for (let i = 0; i < args.length; i++) {
|
|
865
|
+
if (args[i] === '--no-sleep') {
|
|
866
|
+
noSleep = true;
|
|
867
|
+
} else if (args[i] === '--sleep') {
|
|
868
|
+
noSleep = false;
|
|
869
|
+
} else if (!nameOrId) {
|
|
870
|
+
nameOrId = args[i];
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (!nameOrId) {
|
|
875
|
+
log('red', 'Error: Job name or ID required');
|
|
876
|
+
console.log(`\nUsage: mags set <name|id> [options]\n`);
|
|
877
|
+
console.log('Options:');
|
|
878
|
+
console.log(' --no-sleep Never auto-sleep this VM');
|
|
879
|
+
console.log(' --sleep Re-enable auto-sleep');
|
|
880
|
+
process.exit(1);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const payload = {};
|
|
884
|
+
if (noSleep !== null) payload.no_sleep = noSleep;
|
|
885
|
+
|
|
886
|
+
if (Object.keys(payload).length === 0) {
|
|
887
|
+
log('red', 'Error: No settings to update. Use --no-sleep or --sleep');
|
|
888
|
+
process.exit(1);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const requestId = await resolveJobId(nameOrId);
|
|
892
|
+
log('blue', `Updating settings for ${requestId}...`);
|
|
893
|
+
const resp = await request('PATCH', `/api/v1/mags-jobs/${requestId}`, payload);
|
|
894
|
+
if (resp.success) {
|
|
895
|
+
if (noSleep === true) log('green', 'VM set to never auto-sleep');
|
|
896
|
+
if (noSleep === false) log('green', 'VM set to auto-sleep when idle');
|
|
897
|
+
} else {
|
|
898
|
+
log('red', `Failed: ${resp.error || 'unknown error'}`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async function resizeVM(args) {
|
|
903
|
+
let name = null;
|
|
904
|
+
let diskGB = 0;
|
|
905
|
+
|
|
906
|
+
for (let i = 0; i < args.length; i++) {
|
|
907
|
+
if (args[i] === '--disk' && args[i + 1]) {
|
|
908
|
+
diskGB = parseInt(args[++i]) || 0;
|
|
909
|
+
} else if (!name) {
|
|
910
|
+
name = args[i];
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (!name || !diskGB) {
|
|
915
|
+
log('red', 'Error: Workspace name and --disk <GB> required');
|
|
916
|
+
console.log('\nUsage: mags resize <workspace> --disk <GB>\n');
|
|
917
|
+
process.exit(1);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Find existing job for this workspace
|
|
921
|
+
const existingJob = await findWorkspaceJob(name);
|
|
922
|
+
if (existingJob) {
|
|
923
|
+
// Sync workspace before stopping (preserve files)
|
|
924
|
+
if (existingJob.status === 'running') {
|
|
925
|
+
log('blue', 'Syncing workspace before resize...');
|
|
926
|
+
await request('POST', `/api/v1/mags-jobs/${existingJob.request_id}/sync`);
|
|
927
|
+
}
|
|
928
|
+
log('blue', `Stopping existing VM...`);
|
|
929
|
+
await request('POST', `/api/v1/mags-jobs/${existingJob.request_id}/stop`);
|
|
930
|
+
// Brief wait for the stop to complete
|
|
931
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Create new VM with the same workspace name and new disk size
|
|
935
|
+
log('blue', `Creating new VM with ${diskGB}GB disk...`);
|
|
936
|
+
const payload = {
|
|
937
|
+
script: 'sleep infinity',
|
|
938
|
+
type: 'inline',
|
|
939
|
+
persistent: true,
|
|
940
|
+
name: name,
|
|
941
|
+
workspace_id: name,
|
|
942
|
+
startup_command: 'sleep infinity',
|
|
943
|
+
disk_gb: diskGB,
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
const response = await request('POST', '/api/v1/mags-jobs', payload);
|
|
947
|
+
if (!response.request_id) {
|
|
948
|
+
log('red', 'Failed to create VM:');
|
|
949
|
+
console.log(JSON.stringify(response, null, 2));
|
|
950
|
+
process.exit(1);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
log('green', `Resized '${name}' to ${diskGB}GB disk`);
|
|
954
|
+
log('gray', `Job: ${response.request_id}`);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
async function syncWorkspace(nameOrId) {
|
|
958
|
+
if (!nameOrId) {
|
|
959
|
+
log('red', 'Error: Workspace name or job ID required');
|
|
960
|
+
console.log('\nUsage: mags sync <workspace|id>\n');
|
|
961
|
+
console.log('Syncs the workspace filesystem to S3 without stopping the VM.');
|
|
962
|
+
console.log('Use this after setting up a base image to persist your changes.\n');
|
|
963
|
+
console.log('Examples:');
|
|
964
|
+
console.log(' mags sync myproject');
|
|
965
|
+
console.log(' mags sync golden # after setting up base image');
|
|
966
|
+
process.exit(1);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Try to find a running job for this workspace
|
|
970
|
+
const existingJob = await findWorkspaceJob(nameOrId);
|
|
971
|
+
let requestId;
|
|
972
|
+
|
|
973
|
+
if (existingJob && existingJob.status === 'running') {
|
|
974
|
+
requestId = existingJob.request_id;
|
|
975
|
+
} else {
|
|
976
|
+
// Try as a direct job ID
|
|
977
|
+
requestId = await resolveJobId(nameOrId);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
log('blue', `Syncing workspace...`);
|
|
981
|
+
const resp = await request('POST', `/api/v1/mags-jobs/${requestId}/sync`);
|
|
982
|
+
if (resp.success) {
|
|
983
|
+
log('green', 'Workspace synced to S3 successfully');
|
|
984
|
+
} else {
|
|
985
|
+
log('red', 'Failed to sync workspace');
|
|
986
|
+
if (resp.error) log('red', resp.error);
|
|
987
|
+
process.exit(1);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Download a file from URL
|
|
992
|
+
function downloadFile(url) {
|
|
993
|
+
return new Promise((resolve, reject) => {
|
|
994
|
+
const urlObj = new URL(url);
|
|
995
|
+
const lib = urlObj.protocol === 'https:' ? https : http;
|
|
996
|
+
|
|
997
|
+
lib.get(url, (res) => {
|
|
998
|
+
// Handle redirects
|
|
999
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
1000
|
+
downloadFile(res.headers.location).then(resolve).catch(reject);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
if (res.statusCode !== 200) {
|
|
1005
|
+
reject(new Error(`Failed to download: HTTP ${res.statusCode}`));
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
let data = '';
|
|
1010
|
+
res.on('data', chunk => data += chunk);
|
|
1011
|
+
res.on('end', () => resolve(data));
|
|
1012
|
+
}).on('error', reject);
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
async function setupClaude() {
|
|
1017
|
+
console.log(`
|
|
1018
|
+
${colors.cyan}${colors.bold}Mags Claude Code Skill Setup${colors.reset}
|
|
1019
|
+
|
|
1020
|
+
This will install the Mags skill for Claude Code, allowing you to run
|
|
1021
|
+
scripts on instant VMs directly from Claude.
|
|
1022
|
+
`);
|
|
1023
|
+
|
|
1024
|
+
// Ask where to install
|
|
1025
|
+
console.log(`${colors.bold}Installation options:${colors.reset}`);
|
|
1026
|
+
console.log(` 1. Global (all projects) - ~/.claude/commands/`);
|
|
1027
|
+
console.log(` 2. Current project only - ./.claude/commands/`);
|
|
1028
|
+
console.log('');
|
|
1029
|
+
|
|
1030
|
+
const choice = await prompt(`${colors.bold}Choose installation type [1/2]: ${colors.reset}`);
|
|
1031
|
+
|
|
1032
|
+
let targetDir;
|
|
1033
|
+
if (choice === '2') {
|
|
1034
|
+
targetDir = path.join(process.cwd(), '.claude', 'commands');
|
|
1035
|
+
log('blue', 'Installing to current project...');
|
|
1036
|
+
} else {
|
|
1037
|
+
targetDir = CLAUDE_GLOBAL_DIR;
|
|
1038
|
+
log('blue', 'Installing globally...');
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Create directory if it doesn't exist
|
|
1042
|
+
try {
|
|
1043
|
+
if (!fs.existsSync(targetDir)) {
|
|
1044
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
1045
|
+
}
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
log('red', `Failed to create directory: ${err.message}`);
|
|
1048
|
+
process.exit(1);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Download skill file
|
|
1052
|
+
log('blue', `Downloading skill from ${SKILL_URL}...`);
|
|
1053
|
+
|
|
1054
|
+
try {
|
|
1055
|
+
const skillContent = await downloadFile(SKILL_URL);
|
|
1056
|
+
|
|
1057
|
+
const targetFile = path.join(targetDir, 'mags.md');
|
|
1058
|
+
fs.writeFileSync(targetFile, skillContent);
|
|
1059
|
+
|
|
1060
|
+
console.log('');
|
|
1061
|
+
log('green', 'Mags skill installed successfully!');
|
|
1062
|
+
log('gray', `Location: ${targetFile}`);
|
|
1063
|
+
console.log('');
|
|
1064
|
+
log('cyan', 'Next steps:');
|
|
1065
|
+
console.log(` 1. Make sure you're logged in: ${colors.bold}mags login${colors.reset}`);
|
|
1066
|
+
console.log(` 2. In Claude Code, use: ${colors.bold}/mags <your request>${colors.reset}`);
|
|
1067
|
+
console.log('');
|
|
1068
|
+
log('cyan', 'Example prompts:');
|
|
1069
|
+
console.log(' /mags run echo Hello World');
|
|
1070
|
+
console.log(' /mags create a python environment with numpy');
|
|
1071
|
+
console.log(' /mags run a flask server and give me the URL');
|
|
1072
|
+
|
|
1073
|
+
} catch (err) {
|
|
1074
|
+
log('red', `Failed to download skill: ${err.message}`);
|
|
1075
|
+
log('gray', `You can manually download from: ${SKILL_URL}`);
|
|
1076
|
+
process.exit(1);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Upload file via multipart form data
|
|
1081
|
+
async function uploadFile(filePath) {
|
|
1082
|
+
if (!fs.existsSync(filePath)) {
|
|
1083
|
+
log('red', `File not found: ${filePath}`);
|
|
1084
|
+
return null;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const fileName = path.basename(filePath);
|
|
1088
|
+
const fileData = fs.readFileSync(filePath);
|
|
1089
|
+
const boundary = '----MagsBoundary' + Date.now().toString(16);
|
|
1090
|
+
|
|
1091
|
+
// Build multipart body
|
|
1092
|
+
const parts = [];
|
|
1093
|
+
parts.push(`--${boundary}\r\n`);
|
|
1094
|
+
parts.push(`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`);
|
|
1095
|
+
parts.push(`Content-Type: application/octet-stream\r\n\r\n`);
|
|
1096
|
+
const header = Buffer.from(parts.join(''));
|
|
1097
|
+
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
1098
|
+
const body = Buffer.concat([header, fileData, footer]);
|
|
1099
|
+
|
|
1100
|
+
return new Promise((resolve, reject) => {
|
|
1101
|
+
const url = new URL('/api/v1/mags-files', API_URL);
|
|
1102
|
+
const isHttps = url.protocol === 'https:';
|
|
1103
|
+
const lib = isHttps ? https : http;
|
|
1104
|
+
|
|
1105
|
+
const options = {
|
|
1106
|
+
hostname: url.hostname,
|
|
1107
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
1108
|
+
path: url.pathname,
|
|
1109
|
+
method: 'POST',
|
|
1110
|
+
headers: {
|
|
1111
|
+
'Authorization': `Bearer ${API_TOKEN}`,
|
|
1112
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
1113
|
+
'Content-Length': body.length
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
|
|
1117
|
+
const req = lib.request(options, (res) => {
|
|
1118
|
+
let data = '';
|
|
1119
|
+
res.on('data', chunk => data += chunk);
|
|
1120
|
+
res.on('end', () => {
|
|
1121
|
+
try {
|
|
1122
|
+
const parsed = JSON.parse(data);
|
|
1123
|
+
if (parsed.file_id) {
|
|
1124
|
+
resolve(parsed.file_id);
|
|
1125
|
+
} else {
|
|
1126
|
+
resolve(null);
|
|
1127
|
+
}
|
|
1128
|
+
} catch {
|
|
1129
|
+
resolve(null);
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
req.on('error', () => resolve(null));
|
|
1135
|
+
req.write(body);
|
|
1136
|
+
req.end();
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// Cron job management
|
|
1141
|
+
async function cronCommand(args) {
|
|
1142
|
+
if (args.length === 0) {
|
|
1143
|
+
log('red', 'Error: Cron subcommand required (add, list, remove, enable, disable)');
|
|
1144
|
+
usage();
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const subcommand = args[0];
|
|
1149
|
+
const subArgs = args.slice(1);
|
|
1150
|
+
|
|
1151
|
+
switch (subcommand) {
|
|
1152
|
+
case 'add':
|
|
1153
|
+
await cronAdd(subArgs);
|
|
1154
|
+
break;
|
|
1155
|
+
case 'list':
|
|
1156
|
+
case 'ls':
|
|
1157
|
+
await cronList();
|
|
1158
|
+
break;
|
|
1159
|
+
case 'remove':
|
|
1160
|
+
case 'rm':
|
|
1161
|
+
case 'delete':
|
|
1162
|
+
if (!subArgs[0]) {
|
|
1163
|
+
log('red', 'Error: Cron job ID required');
|
|
1164
|
+
process.exit(1);
|
|
1165
|
+
}
|
|
1166
|
+
await cronRemove(subArgs[0]);
|
|
1167
|
+
break;
|
|
1168
|
+
case 'enable':
|
|
1169
|
+
if (!subArgs[0]) {
|
|
1170
|
+
log('red', 'Error: Cron job ID required');
|
|
1171
|
+
process.exit(1);
|
|
1172
|
+
}
|
|
1173
|
+
await cronToggle(subArgs[0], true);
|
|
1174
|
+
break;
|
|
1175
|
+
case 'disable':
|
|
1176
|
+
if (!subArgs[0]) {
|
|
1177
|
+
log('red', 'Error: Cron job ID required');
|
|
1178
|
+
process.exit(1);
|
|
1179
|
+
}
|
|
1180
|
+
await cronToggle(subArgs[0], false);
|
|
1181
|
+
break;
|
|
1182
|
+
default:
|
|
1183
|
+
log('red', `Unknown cron subcommand: ${subcommand}`);
|
|
1184
|
+
usage();
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
async function cronAdd(args) {
|
|
1189
|
+
let cronName = '';
|
|
1190
|
+
let schedule = '';
|
|
1191
|
+
let workspace = '';
|
|
1192
|
+
let persistent = false;
|
|
1193
|
+
let script = '';
|
|
1194
|
+
|
|
1195
|
+
for (let i = 0; i < args.length; i++) {
|
|
1196
|
+
switch (args[i]) {
|
|
1197
|
+
case '--name':
|
|
1198
|
+
cronName = args[++i];
|
|
1199
|
+
break;
|
|
1200
|
+
case '--schedule':
|
|
1201
|
+
schedule = args[++i];
|
|
1202
|
+
break;
|
|
1203
|
+
case '-w':
|
|
1204
|
+
case '--workspace':
|
|
1205
|
+
workspace = args[++i];
|
|
1206
|
+
break;
|
|
1207
|
+
case '-p':
|
|
1208
|
+
case '--persistent':
|
|
1209
|
+
persistent = true;
|
|
1210
|
+
break;
|
|
1211
|
+
default:
|
|
1212
|
+
script = args.slice(i).join(' ');
|
|
1213
|
+
i = args.length;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
if (!cronName) {
|
|
1218
|
+
log('red', 'Error: --name is required for cron jobs');
|
|
1219
|
+
process.exit(1);
|
|
1220
|
+
}
|
|
1221
|
+
if (!schedule) {
|
|
1222
|
+
log('red', 'Error: --schedule is required (e.g. "0 * * * *")');
|
|
1223
|
+
process.exit(1);
|
|
1224
|
+
}
|
|
1225
|
+
if (!script) {
|
|
1226
|
+
log('red', 'Error: Script is required');
|
|
1227
|
+
process.exit(1);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const payload = {
|
|
1231
|
+
name: cronName,
|
|
1232
|
+
cron_expression: schedule,
|
|
1233
|
+
script,
|
|
1234
|
+
persistent
|
|
1235
|
+
};
|
|
1236
|
+
if (workspace) payload.workspace_id = workspace;
|
|
1237
|
+
|
|
1238
|
+
const resp = await request('POST', '/api/v1/mags-cron', payload);
|
|
1239
|
+
if (resp.id) {
|
|
1240
|
+
log('green', `Cron job created: ${resp.id}`);
|
|
1241
|
+
log('blue', `Name: ${cronName}`);
|
|
1242
|
+
log('blue', `Schedule: ${schedule}`);
|
|
1243
|
+
if (resp.next_run_at) log('blue', `Next run: ${resp.next_run_at}`);
|
|
1244
|
+
} else {
|
|
1245
|
+
log('red', 'Failed to create cron job:');
|
|
1246
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
1247
|
+
process.exit(1);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
async function cronList() {
|
|
1252
|
+
const resp = await request('GET', '/api/v1/mags-cron');
|
|
1253
|
+
if (resp.cron_jobs && resp.cron_jobs.length > 0) {
|
|
1254
|
+
log('cyan', 'Cron Jobs:\n');
|
|
1255
|
+
resp.cron_jobs.forEach(cron => {
|
|
1256
|
+
const statusColor = cron.enabled ? 'green' : 'yellow';
|
|
1257
|
+
console.log(`${colors.gray}${cron.id}${colors.reset}`);
|
|
1258
|
+
console.log(` Name: ${cron.name}`);
|
|
1259
|
+
console.log(` Schedule: ${cron.cron_expression}`);
|
|
1260
|
+
console.log(` Enabled: ${colors[statusColor]}${cron.enabled}${colors.reset}`);
|
|
1261
|
+
console.log(` Workspace: ${cron.workspace_id || '-'}`);
|
|
1262
|
+
console.log(` Runs: ${cron.run_count || 0}`);
|
|
1263
|
+
console.log(` Last Run: ${cron.last_run_at || '-'}`);
|
|
1264
|
+
console.log(` Next Run: ${cron.next_run_at || '-'}`);
|
|
1265
|
+
console.log(` Last Status: ${cron.last_status || '-'}`);
|
|
1266
|
+
console.log('');
|
|
1267
|
+
});
|
|
1268
|
+
} else {
|
|
1269
|
+
log('yellow', 'No cron jobs found');
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
async function cronRemove(id) {
|
|
1274
|
+
const resp = await request('DELETE', `/api/v1/mags-cron/${id}`);
|
|
1275
|
+
if (resp.success) {
|
|
1276
|
+
log('green', 'Cron job deleted');
|
|
1277
|
+
} else {
|
|
1278
|
+
log('red', 'Failed to delete cron job');
|
|
1279
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
async function cronToggle(id, enabled) {
|
|
1284
|
+
const resp = await request('PATCH', `/api/v1/mags-cron/${id}`, { enabled });
|
|
1285
|
+
if (resp.id) {
|
|
1286
|
+
log('green', `Cron job ${enabled ? 'enabled' : 'disabled'}`);
|
|
1287
|
+
if (resp.next_run_at) log('blue', `Next run: ${resp.next_run_at}`);
|
|
1288
|
+
} else {
|
|
1289
|
+
log('red', `Failed to ${enabled ? 'enable' : 'disable'} cron job`);
|
|
1290
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// Workspace management
|
|
1295
|
+
async function workspaceCommand(args) {
|
|
1296
|
+
if (args.length === 0) {
|
|
1297
|
+
log('red', 'Error: Workspace subcommand required (list, delete)');
|
|
1298
|
+
console.log('\nUsage:');
|
|
1299
|
+
console.log(' mags workspace list');
|
|
1300
|
+
console.log(' mags workspace delete <workspace-id>');
|
|
1301
|
+
process.exit(1);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
const subcommand = args[0];
|
|
1305
|
+
|
|
1306
|
+
switch (subcommand) {
|
|
1307
|
+
case 'list':
|
|
1308
|
+
case 'ls':
|
|
1309
|
+
await workspaceList();
|
|
1310
|
+
break;
|
|
1311
|
+
case 'delete':
|
|
1312
|
+
case 'rm':
|
|
1313
|
+
case 'remove':
|
|
1314
|
+
if (!args[1]) {
|
|
1315
|
+
log('red', 'Error: Workspace ID required');
|
|
1316
|
+
console.log('\nUsage: mags workspace delete <workspace-id>');
|
|
1317
|
+
process.exit(1);
|
|
1318
|
+
}
|
|
1319
|
+
await workspaceDelete(args[1]);
|
|
1320
|
+
break;
|
|
1321
|
+
default:
|
|
1322
|
+
log('red', `Unknown workspace subcommand: ${subcommand}`);
|
|
1323
|
+
console.log('\nUsage:');
|
|
1324
|
+
console.log(' mags workspace list');
|
|
1325
|
+
console.log(' mags workspace delete <workspace-id>');
|
|
1326
|
+
process.exit(1);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
async function workspaceList() {
|
|
1331
|
+
const resp = await request('GET', '/api/v1/mags-workspaces');
|
|
1332
|
+
if (resp.workspaces && resp.workspaces.length > 0) {
|
|
1333
|
+
log('cyan', 'Workspaces:\n');
|
|
1334
|
+
resp.workspaces.forEach(ws => {
|
|
1335
|
+
const statusColor = ws.has_active ? 'blue' : 'yellow';
|
|
1336
|
+
const status = ws.has_active ? 'active' : 'idle';
|
|
1337
|
+
console.log(` ${colors.bold}${ws.workspace_id}${colors.reset}`);
|
|
1338
|
+
console.log(` Jobs: ${ws.job_count} Status: ${colors[statusColor]}${status}${colors.reset} Last used: ${ws.last_used || '-'}`);
|
|
1339
|
+
console.log('');
|
|
1340
|
+
});
|
|
1341
|
+
log('gray', `Total: ${resp.total} workspace(s)`);
|
|
1342
|
+
} else {
|
|
1343
|
+
log('yellow', 'No workspaces found');
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
async function workspaceDelete(workspaceId) {
|
|
1348
|
+
log('blue', `Deleting workspace '${workspaceId}'...`);
|
|
1349
|
+
const resp = await request('DELETE', `/api/v1/mags-workspaces/${workspaceId}`);
|
|
1350
|
+
if (resp.success) {
|
|
1351
|
+
log('green', resp.message || `Workspace '${workspaceId}' deleted`);
|
|
1352
|
+
} else {
|
|
1353
|
+
log('red', resp.error || 'Failed to delete workspace');
|
|
1354
|
+
if (resp.error && resp.error.includes('active job')) {
|
|
1355
|
+
log('gray', 'Stop active jobs first with: mags stop <job-id>');
|
|
1356
|
+
}
|
|
1357
|
+
process.exit(1);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
async function browserSession(args) {
|
|
1362
|
+
let workspace = '';
|
|
1363
|
+
let name = '';
|
|
1364
|
+
|
|
1365
|
+
// Parse args: mags browser [workspace] [--name <n>]
|
|
1366
|
+
for (let i = 0; i < args.length; i++) {
|
|
1367
|
+
if ((args[i] === '-n' || args[i] === '--name') && args[i + 1]) {
|
|
1368
|
+
name = args[++i];
|
|
1369
|
+
} else if (!workspace) {
|
|
1370
|
+
workspace = args[i];
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// Check for existing running/sleeping browser session with this workspace
|
|
1375
|
+
if (workspace) {
|
|
1376
|
+
const existingJob = await findWorkspaceJob(workspace);
|
|
1377
|
+
|
|
1378
|
+
if (existingJob && existingJob.status === 'running') {
|
|
1379
|
+
// Check if it's already a browser session
|
|
1380
|
+
const status = await request('GET', `/api/v1/mags-jobs/${existingJob.request_id}/status`);
|
|
1381
|
+
if (status.browser_mode && status.debug_ws_url) {
|
|
1382
|
+
log('green', 'Found existing browser session');
|
|
1383
|
+
console.log('');
|
|
1384
|
+
log('cyan', `CDP Endpoint: ${status.debug_ws_url}`);
|
|
1385
|
+
console.log('');
|
|
1386
|
+
log('gray', 'Connect with Playwright:');
|
|
1387
|
+
console.log(` const browser = await chromium.connectOverCDP('${status.debug_ws_url}');`);
|
|
1388
|
+
console.log('');
|
|
1389
|
+
log('gray', 'Connect with Puppeteer:');
|
|
1390
|
+
console.log(` const browser = await puppeteer.connect({ browserWSEndpoint: '${status.debug_ws_url}' });`);
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
log('blue', 'Starting browser session...');
|
|
1397
|
+
|
|
1398
|
+
const payload = {
|
|
1399
|
+
script: 'echo "Browser session ready"',
|
|
1400
|
+
type: 'inline',
|
|
1401
|
+
browser_mode: true,
|
|
1402
|
+
persistent: true
|
|
1403
|
+
};
|
|
1404
|
+
if (workspace) payload.workspace_id = workspace;
|
|
1405
|
+
if (name) payload.name = name;
|
|
1406
|
+
if (!name && workspace) payload.name = `browser-${workspace}`;
|
|
1407
|
+
|
|
1408
|
+
const response = await request('POST', '/api/v1/mags-jobs', payload);
|
|
1409
|
+
|
|
1410
|
+
if (!response.request_id) {
|
|
1411
|
+
log('red', 'Failed to start browser session:');
|
|
1412
|
+
console.log(JSON.stringify(response, null, 2));
|
|
1413
|
+
process.exit(1);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
const requestId = response.request_id;
|
|
1417
|
+
log('gray', `Job: ${requestId}`);
|
|
1418
|
+
|
|
1419
|
+
// Wait for VM + Chromium to be ready
|
|
1420
|
+
log('blue', 'Waiting for Chromium to start...');
|
|
1421
|
+
let status;
|
|
1422
|
+
for (let i = 0; i < 90; i++) {
|
|
1423
|
+
status = await request('GET', `/api/v1/mags-jobs/${requestId}/status`);
|
|
1424
|
+
if (status.status === 'running' && status.debug_ws_url) {
|
|
1425
|
+
break;
|
|
1426
|
+
}
|
|
1427
|
+
if (status.status === 'error') {
|
|
1428
|
+
log('red', `Browser session failed: ${status.error_message || 'Unknown error'}`);
|
|
1429
|
+
process.exit(1);
|
|
1430
|
+
}
|
|
1431
|
+
process.stdout.write('.');
|
|
1432
|
+
await sleep(1000);
|
|
1433
|
+
}
|
|
1434
|
+
console.log('');
|
|
1435
|
+
|
|
1436
|
+
if (!status || !status.debug_ws_url) {
|
|
1437
|
+
// VM is running but no CDP URL yet - it may still be initializing
|
|
1438
|
+
if (status && status.status === 'running' && status.subdomain) {
|
|
1439
|
+
const wsUrl = `wss://${status.subdomain}.apps.magpiecloud.com`;
|
|
1440
|
+
log('green', 'Browser session ready!');
|
|
1441
|
+
console.log('');
|
|
1442
|
+
log('cyan', `CDP Endpoint: ${wsUrl}`);
|
|
1443
|
+
console.log('');
|
|
1444
|
+
log('gray', 'Connect with Playwright:');
|
|
1445
|
+
console.log(` const browser = await chromium.connectOverCDP('${wsUrl}');`);
|
|
1446
|
+
console.log('');
|
|
1447
|
+
log('gray', 'Connect with Puppeteer:');
|
|
1448
|
+
console.log(` const browser = await puppeteer.connect({ browserWSEndpoint: '${wsUrl}' });`);
|
|
1449
|
+
} else {
|
|
1450
|
+
log('yellow', 'Browser session started but CDP URL not yet available.');
|
|
1451
|
+
log('yellow', `Check status with: mags status ${requestId}`);
|
|
1452
|
+
}
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
log('green', 'Browser session ready!');
|
|
1457
|
+
console.log('');
|
|
1458
|
+
log('cyan', `CDP Endpoint: ${status.debug_ws_url}`);
|
|
1459
|
+
console.log('');
|
|
1460
|
+
log('gray', 'Connect with Playwright:');
|
|
1461
|
+
console.log(` const browser = await chromium.connectOverCDP('${status.debug_ws_url}');`);
|
|
1462
|
+
console.log('');
|
|
1463
|
+
log('gray', 'Connect with Puppeteer:');
|
|
1464
|
+
console.log(` const browser = await puppeteer.connect({ browserWSEndpoint: '${status.debug_ws_url}' });`);
|
|
1465
|
+
console.log('');
|
|
1466
|
+
log('gray', `Stop with: mags stop ${workspace || requestId}`);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// WebSocket tunnel proxy — pipes stdin/stdout through a WebSocket to the agent SSH proxy.
|
|
1470
|
+
// Used as SSH ProxyCommand: ssh -o ProxyCommand="mags proxy <jobID>" root@mags-vm
|
|
1471
|
+
async function proxyTunnel(jobID) {
|
|
1472
|
+
const url = new URL(`/api/v1/mags-jobs/${jobID}/tunnel`, API_URL);
|
|
1473
|
+
const wsUrl = url.toString().replace(/^http/, 'ws');
|
|
1474
|
+
|
|
1475
|
+
// Dynamic import for WebSocket
|
|
1476
|
+
let WebSocket;
|
|
1477
|
+
try {
|
|
1478
|
+
WebSocket = require('ws');
|
|
1479
|
+
} catch {
|
|
1480
|
+
if (typeof globalThis.WebSocket !== 'undefined') {
|
|
1481
|
+
WebSocket = globalThis.WebSocket;
|
|
1482
|
+
} else {
|
|
1483
|
+
process.stderr.write('Error: ws package required. Run: npm install -g ws\n');
|
|
1484
|
+
process.exit(1);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const ws = new WebSocket(wsUrl, {
|
|
1489
|
+
headers: {
|
|
1490
|
+
'Authorization': `Bearer ${API_TOKEN}`,
|
|
1491
|
+
},
|
|
1492
|
+
perMessageDeflate: false,
|
|
1493
|
+
rejectUnauthorized: false,
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
// Buffer stdin data until WebSocket is open AND key is received
|
|
1497
|
+
const pendingData = [];
|
|
1498
|
+
let bridgeReady = false;
|
|
1499
|
+
let firstMessage = true;
|
|
1500
|
+
|
|
1501
|
+
process.stdin.on('data', (data) => {
|
|
1502
|
+
if (bridgeReady && ws.readyState === WebSocket.OPEN) {
|
|
1503
|
+
ws.send(data);
|
|
1504
|
+
} else {
|
|
1505
|
+
pendingData.push(Buffer.from(data));
|
|
1506
|
+
}
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
ws.on('open', () => {
|
|
1510
|
+
// Don't flush yet — wait for first message (SSH key)
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
ws.on('message', (data, isBinary) => {
|
|
1514
|
+
if (firstMessage && !isBinary) {
|
|
1515
|
+
// First text message is the SSH private key — save to temp file
|
|
1516
|
+
firstMessage = false;
|
|
1517
|
+
const keyData = Buffer.from(data).toString('utf8');
|
|
1518
|
+
if (keyData.startsWith('-----BEGIN')) {
|
|
1519
|
+
const keyPath = path.join(os.tmpdir(), `mags_tunnel_key_${jobID}`);
|
|
1520
|
+
fs.writeFileSync(keyPath, keyData, { mode: 0o600 });
|
|
1521
|
+
}
|
|
1522
|
+
// Now start the SSH data bridge
|
|
1523
|
+
bridgeReady = true;
|
|
1524
|
+
for (const buf of pendingData) {
|
|
1525
|
+
ws.send(buf);
|
|
1526
|
+
}
|
|
1527
|
+
pendingData.length = 0;
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
process.stdout.write(Buffer.from(data));
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
ws.on('close', () => {
|
|
1534
|
+
process.exit(0);
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
ws.on('error', (err) => {
|
|
1538
|
+
process.stderr.write(`Tunnel error: ${err.message}\n`);
|
|
1539
|
+
process.exit(1);
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
process.stdin.on('end', () => {
|
|
1543
|
+
ws.close();
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// Fetch SSH key from tunnel endpoint (quick WS handshake)
|
|
1548
|
+
async function fetchTunnelKey(jobID) {
|
|
1549
|
+
const WebSocket = require('ws');
|
|
1550
|
+
const url = new URL(`/api/v1/mags-jobs/${jobID}/tunnel`, API_URL);
|
|
1551
|
+
const wsUrl = url.toString().replace(/^http/, 'ws');
|
|
1552
|
+
|
|
1553
|
+
return new Promise((resolve, reject) => {
|
|
1554
|
+
const ws = new WebSocket(wsUrl, {
|
|
1555
|
+
headers: { 'Authorization': `Bearer ${API_TOKEN}` },
|
|
1556
|
+
perMessageDeflate: false,
|
|
1557
|
+
rejectUnauthorized: false,
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
const timeout = setTimeout(() => {
|
|
1561
|
+
ws.close();
|
|
1562
|
+
reject(new Error('Tunnel key fetch timeout'));
|
|
1563
|
+
}, 60000);
|
|
1564
|
+
|
|
1565
|
+
ws.on('message', (data, isBinary) => {
|
|
1566
|
+
if (!isBinary) {
|
|
1567
|
+
const key = Buffer.from(data).toString('utf8');
|
|
1568
|
+
if (key.startsWith('-----BEGIN')) {
|
|
1569
|
+
clearTimeout(timeout);
|
|
1570
|
+
ws.close();
|
|
1571
|
+
resolve(key);
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
// Not a key message — shouldn't happen as first message
|
|
1576
|
+
clearTimeout(timeout);
|
|
1577
|
+
ws.close();
|
|
1578
|
+
reject(new Error('No SSH key received from tunnel'));
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
ws.on('error', (err) => {
|
|
1582
|
+
clearTimeout(timeout);
|
|
1583
|
+
reject(err);
|
|
1584
|
+
});
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// Resolve a workspace name to a job ID, creating a new VM if needed
|
|
1589
|
+
async function resolveOrCreateJob(nameOrId) {
|
|
1590
|
+
const existingJob = await findWorkspaceJob(nameOrId);
|
|
1591
|
+
|
|
1592
|
+
if (existingJob && existingJob.status === 'running') {
|
|
1593
|
+
log('green', `Found running VM for '${nameOrId}'`);
|
|
1594
|
+
return existingJob.request_id;
|
|
1595
|
+
} else if (existingJob && existingJob.status === 'sleeping') {
|
|
1596
|
+
log('yellow', `Waking sleeping VM for '${nameOrId}'...`);
|
|
1597
|
+
return existingJob.request_id;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// No running/sleeping VM — start a new persistent one
|
|
1601
|
+
log('blue', `Starting VM with workspace '${nameOrId}'...`);
|
|
1602
|
+
const payload = {
|
|
1603
|
+
script: 'echo "SSH session ready" && sleep 3600',
|
|
1604
|
+
type: 'inline',
|
|
1605
|
+
workspace_id: nameOrId,
|
|
1606
|
+
persistent: true,
|
|
1607
|
+
startup_command: 'sleep 3600'
|
|
1608
|
+
};
|
|
1609
|
+
|
|
1610
|
+
const response = await request('POST', '/api/v1/mags-jobs', payload);
|
|
1611
|
+
if (!response.request_id) {
|
|
1612
|
+
log('red', 'Failed to start VM:');
|
|
1613
|
+
console.log(JSON.stringify(response, null, 2));
|
|
1614
|
+
process.exit(1);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
const jobID = response.request_id;
|
|
1618
|
+
log('gray', `Job: ${jobID}`);
|
|
1619
|
+
|
|
1620
|
+
log('blue', 'Waiting for VM...');
|
|
1621
|
+
for (let i = 0; i < 60; i++) {
|
|
1622
|
+
const status = await request('GET', `/api/v1/mags-jobs/${jobID}/status`);
|
|
1623
|
+
if (status.status === 'running' && status.vm_id) break;
|
|
1624
|
+
if (status.status === 'error') {
|
|
1625
|
+
log('red', 'VM failed to start');
|
|
1626
|
+
process.exit(1);
|
|
1627
|
+
}
|
|
1628
|
+
process.stdout.write('.');
|
|
1629
|
+
await sleep(300);
|
|
1630
|
+
}
|
|
1631
|
+
console.log('');
|
|
1632
|
+
return jobID;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// Get SSH key for a job (calls EnableAccess to set up agent proxy and get key)
|
|
1636
|
+
async function getSSHKey(jobID) {
|
|
1637
|
+
const accessResp = await request('POST', `/api/v1/mags-jobs/${jobID}/access`, { port: 22 });
|
|
1638
|
+
if (!accessResp.success) {
|
|
1639
|
+
log('red', 'Failed to enable SSH access');
|
|
1640
|
+
if (accessResp.error) log('red', accessResp.error);
|
|
1641
|
+
process.exit(1);
|
|
1642
|
+
}
|
|
1643
|
+
return accessResp.ssh_private_key;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
async function sshToJob(nameOrId) {
|
|
1647
|
+
if (!nameOrId) {
|
|
1648
|
+
log('red', 'Error: Workspace, job name, or job ID required');
|
|
1649
|
+
console.log(`\nUsage: mags ssh <workspace|name|id>\n`);
|
|
1650
|
+
console.log('Examples:');
|
|
1651
|
+
console.log(' mags ssh myproject # Auto-starts VM if needed');
|
|
1652
|
+
console.log(' mags ssh 7bd12031-25ff...');
|
|
1653
|
+
console.log('');
|
|
1654
|
+
console.log('Get job names/IDs with: mags list');
|
|
1655
|
+
process.exit(1);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
const jobID = await resolveOrCreateJob(nameOrId);
|
|
1659
|
+
|
|
1660
|
+
// Fetch SSH key from tunnel endpoint (also warms up the agent proxy)
|
|
1661
|
+
log('blue', 'Setting up tunnel...');
|
|
1662
|
+
const sshKey = await fetchTunnelKey(jobID);
|
|
1663
|
+
const magsPath = process.argv[1];
|
|
1664
|
+
const keyFile = path.join(os.tmpdir(), `mags_tunnel_key_${jobID}`);
|
|
1665
|
+
fs.writeFileSync(keyFile, sshKey, { mode: 0o600 });
|
|
1666
|
+
|
|
1667
|
+
log('green', `Connecting via secure tunnel...`);
|
|
1668
|
+
console.log(`${colors.gray}(Use Ctrl+D or 'exit' to disconnect)${colors.reset}\n`);
|
|
1669
|
+
|
|
1670
|
+
const sshArgs = [
|
|
1671
|
+
'-tt',
|
|
1672
|
+
'-o', 'StrictHostKeyChecking=no',
|
|
1673
|
+
'-o', 'UserKnownHostsFile=/dev/null',
|
|
1674
|
+
'-o', 'LogLevel=ERROR',
|
|
1675
|
+
'-o', `ProxyCommand=${magsPath} proxy ${jobID}`,
|
|
1676
|
+
'-i', keyFile,
|
|
1677
|
+
'root@mags-vm',
|
|
1678
|
+
];
|
|
1679
|
+
|
|
1680
|
+
const ssh = spawn('ssh', sshArgs, {
|
|
1681
|
+
stdio: 'inherit'
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
ssh.on('error', (err) => {
|
|
1685
|
+
try { fs.unlinkSync(keyFile); } catch (e) {}
|
|
1686
|
+
if (err.code === 'ENOENT') {
|
|
1687
|
+
log('red', 'SSH client not found. Please install OpenSSH.');
|
|
1688
|
+
} else {
|
|
1689
|
+
log('red', `SSH error: ${err.message}`);
|
|
1690
|
+
}
|
|
1691
|
+
process.exit(1);
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
ssh.on('close', (code) => {
|
|
1695
|
+
try { fs.unlinkSync(keyFile); } catch (e) {}
|
|
1696
|
+
if (code === 0) {
|
|
1697
|
+
log('green', '\nSSH session ended');
|
|
1698
|
+
} else {
|
|
1699
|
+
log('yellow', `\nSSH session ended with code ${code}`);
|
|
1700
|
+
}
|
|
1701
|
+
process.exit(code || 0);
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
async function execOnJob(nameOrId, command) {
|
|
1706
|
+
if (!nameOrId || !command) {
|
|
1707
|
+
log('red', 'Error: Workspace and command required');
|
|
1708
|
+
console.log(`\nUsage: mags exec <workspace|name|id> <command>\n`);
|
|
1709
|
+
console.log('Examples:');
|
|
1710
|
+
console.log(' mags exec myproject "ls -la"');
|
|
1711
|
+
console.log(' mags exec myproject "node --version"');
|
|
1712
|
+
process.exit(1);
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// Find a running/sleeping job for this workspace
|
|
1716
|
+
const existingJob = await findWorkspaceJob(nameOrId);
|
|
1717
|
+
|
|
1718
|
+
let jobID;
|
|
1719
|
+
if (existingJob && existingJob.status === 'running') {
|
|
1720
|
+
log('green', `Found running VM for '${nameOrId}'`);
|
|
1721
|
+
jobID = existingJob.request_id;
|
|
1722
|
+
} else if (existingJob && existingJob.status === 'sleeping') {
|
|
1723
|
+
log('yellow', `Waking sleeping VM for '${nameOrId}'...`);
|
|
1724
|
+
jobID = existingJob.request_id;
|
|
1725
|
+
} else {
|
|
1726
|
+
log('red', `No running or sleeping VM found for '${nameOrId}'`);
|
|
1727
|
+
log('gray', `Start one with: mags new ${nameOrId}`);
|
|
1728
|
+
process.exit(1);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// Fetch SSH key from tunnel endpoint (also warms up the agent proxy)
|
|
1732
|
+
log('blue', 'Setting up tunnel...');
|
|
1733
|
+
const sshKey = await fetchTunnelKey(jobID);
|
|
1734
|
+
const magsPath = process.argv[1];
|
|
1735
|
+
const keyFile = path.join(os.tmpdir(), `mags_tunnel_key_${jobID}`);
|
|
1736
|
+
fs.writeFileSync(keyFile, sshKey, { mode: 0o600 });
|
|
1737
|
+
|
|
1738
|
+
const sshArgs = [
|
|
1739
|
+
'-o', 'StrictHostKeyChecking=no',
|
|
1740
|
+
'-o', 'UserKnownHostsFile=/dev/null',
|
|
1741
|
+
'-o', 'LogLevel=ERROR',
|
|
1742
|
+
'-o', `ProxyCommand=${magsPath} proxy ${jobID}`,
|
|
1743
|
+
'-i', keyFile,
|
|
1744
|
+
];
|
|
1745
|
+
|
|
1746
|
+
// Wrap command to use chroot if overlay is mounted, with proper env
|
|
1747
|
+
const escaped = command.replace(/'/g, "'\\''");
|
|
1748
|
+
const wrappedCmd = `if [ -d /overlay/bin ]; then chroot /overlay /bin/sh -l -c 'cd /root 2>/dev/null; ${escaped}'; else cd /root 2>/dev/null; ${escaped}; fi`;
|
|
1749
|
+
// Allocate TTY so interactive CLIs (e.g. claude) work
|
|
1750
|
+
if (process.stdin.isTTY) {
|
|
1751
|
+
sshArgs.push('-t');
|
|
1752
|
+
}
|
|
1753
|
+
sshArgs.push('root@mags-vm', wrappedCmd);
|
|
1754
|
+
|
|
1755
|
+
const ssh = spawn('ssh', sshArgs, { stdio: 'inherit' });
|
|
1756
|
+
|
|
1757
|
+
ssh.on('error', (err) => {
|
|
1758
|
+
try { fs.unlinkSync(keyFile); } catch (e) {}
|
|
1759
|
+
log('red', `SSH error: ${err.message}`);
|
|
1760
|
+
process.exit(1);
|
|
1761
|
+
});
|
|
1762
|
+
|
|
1763
|
+
ssh.on('close', (code) => {
|
|
1764
|
+
try { fs.unlinkSync(keyFile); } catch (e) {}
|
|
1765
|
+
process.exit(code || 0);
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
async function main() {
|
|
1770
|
+
const args = process.argv.slice(2);
|
|
1771
|
+
const command = args[0];
|
|
1772
|
+
|
|
1773
|
+
try {
|
|
1774
|
+
switch (command) {
|
|
1775
|
+
case 'login':
|
|
1776
|
+
await login();
|
|
1777
|
+
break;
|
|
1778
|
+
case 'logout':
|
|
1779
|
+
await logout();
|
|
1780
|
+
break;
|
|
1781
|
+
case 'whoami':
|
|
1782
|
+
await whoami();
|
|
1783
|
+
break;
|
|
1784
|
+
case '--help':
|
|
1785
|
+
case '-h':
|
|
1786
|
+
usage();
|
|
1787
|
+
break;
|
|
1788
|
+
case '--version':
|
|
1789
|
+
case '-v':
|
|
1790
|
+
console.log('mags v1.8.8');
|
|
1791
|
+
process.exit(0);
|
|
1792
|
+
break;
|
|
1793
|
+
case 'new':
|
|
1794
|
+
await requireAuth();
|
|
1795
|
+
await newVM(args.slice(1));
|
|
1796
|
+
break;
|
|
1797
|
+
case 'run':
|
|
1798
|
+
await requireAuth();
|
|
1799
|
+
await runJob(args.slice(1));
|
|
1800
|
+
break;
|
|
1801
|
+
case 'ssh':
|
|
1802
|
+
await requireAuth();
|
|
1803
|
+
await sshToJob(args[1]);
|
|
1804
|
+
break;
|
|
1805
|
+
case 'browser':
|
|
1806
|
+
await requireAuth();
|
|
1807
|
+
await browserSession(args.slice(1));
|
|
1808
|
+
break;
|
|
1809
|
+
case 'exec':
|
|
1810
|
+
await requireAuth();
|
|
1811
|
+
await execOnJob(args[1], args.slice(2).join(' '));
|
|
1812
|
+
break;
|
|
1813
|
+
case 'url':
|
|
1814
|
+
await requireAuth();
|
|
1815
|
+
if (args[1] === 'alias') {
|
|
1816
|
+
await urlAliasCommand(args.slice(2));
|
|
1817
|
+
} else {
|
|
1818
|
+
await enableUrlAccess(args[1], parseInt(args[2]) || 8080);
|
|
1819
|
+
}
|
|
1820
|
+
break;
|
|
1821
|
+
case 'status':
|
|
1822
|
+
await requireAuth();
|
|
1823
|
+
await getStatus(args[1]);
|
|
1824
|
+
break;
|
|
1825
|
+
case 'logs':
|
|
1826
|
+
await requireAuth();
|
|
1827
|
+
await getLogs(args[1]);
|
|
1828
|
+
break;
|
|
1829
|
+
case 'list':
|
|
1830
|
+
await requireAuth();
|
|
1831
|
+
await listJobs();
|
|
1832
|
+
break;
|
|
1833
|
+
case 'set':
|
|
1834
|
+
await requireAuth();
|
|
1835
|
+
await setJobSettings(args.slice(1));
|
|
1836
|
+
break;
|
|
1837
|
+
case 'stop':
|
|
1838
|
+
await requireAuth();
|
|
1839
|
+
await stopJob(args[1]);
|
|
1840
|
+
break;
|
|
1841
|
+
case 'resize':
|
|
1842
|
+
await requireAuth();
|
|
1843
|
+
await resizeVM(args.slice(1));
|
|
1844
|
+
break;
|
|
1845
|
+
case 'sync':
|
|
1846
|
+
await requireAuth();
|
|
1847
|
+
await syncWorkspace(args[1]);
|
|
1848
|
+
break;
|
|
1849
|
+
case 'cron':
|
|
1850
|
+
await requireAuth();
|
|
1851
|
+
await cronCommand(args.slice(1));
|
|
1852
|
+
break;
|
|
1853
|
+
case 'workspace':
|
|
1854
|
+
await requireAuth();
|
|
1855
|
+
await workspaceCommand(args.slice(1));
|
|
1856
|
+
break;
|
|
1857
|
+
case 'setup-claude':
|
|
1858
|
+
await setupClaude();
|
|
1859
|
+
break;
|
|
1860
|
+
case 'proxy':
|
|
1861
|
+
await proxyTunnel(args[1]);
|
|
1862
|
+
break;
|
|
1863
|
+
default:
|
|
1864
|
+
if (!command) {
|
|
1865
|
+
// No command - check if logged in
|
|
1866
|
+
if (!API_TOKEN) {
|
|
1867
|
+
await requireAuth();
|
|
1868
|
+
} else {
|
|
1869
|
+
usage();
|
|
1870
|
+
}
|
|
1871
|
+
} else {
|
|
1872
|
+
log('red', `Unknown command: ${command}`);
|
|
1873
|
+
usage();
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
} catch (err) {
|
|
1877
|
+
log('red', `Error: ${err.message}`);
|
|
1878
|
+
process.exit(1);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
main();
|