teleportation-cli 1.0.0 → 1.0.1
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/.claude/hooks/heartbeat.mjs +67 -2
- package/.claude/hooks/permission_request.mjs +55 -26
- package/.claude/hooks/pre_tool_use.mjs +29 -2
- package/.claude/hooks/session-register.mjs +64 -5
- package/.claude/hooks/user_prompt_submit.mjs +54 -0
- package/README.md +36 -12
- package/lib/auth/claude-key-extractor.js +196 -0
- package/lib/auth/credentials.js +7 -2
- package/lib/cli/remote-commands.js +649 -0
- package/lib/install/installer.js +22 -7
- package/lib/remote/code-sync.js +213 -0
- package/lib/remote/init-script-robust.js +187 -0
- package/lib/remote/liveport-client.js +417 -0
- package/lib/remote/orchestrator.js +480 -0
- package/lib/remote/pr-creator.js +382 -0
- package/lib/remote/providers/base-provider.js +407 -0
- package/lib/remote/providers/daytona-provider.js +506 -0
- package/lib/remote/providers/fly-provider.js +611 -0
- package/lib/remote/providers/provider-factory.js +228 -0
- package/lib/remote/results-delivery.js +333 -0
- package/lib/remote/session-manager.js +273 -0
- package/lib/remote/state-capture.js +324 -0
- package/lib/remote/vault-client.js +478 -0
- package/lib/session/metadata.js +80 -49
- package/lib/session/mute-checker.js +2 -1
- package/lib/utils/vault-errors.js +353 -0
- package/package.json +5 -5
- package/teleportation-cli.cjs +417 -7
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fly.io Provider for Remote Development Environments
|
|
3
|
+
*
|
|
4
|
+
* Uses Fly.io Machines API for persistent remote development environments.
|
|
5
|
+
* Machines are persistent VMs that can be stopped/started without losing state.
|
|
6
|
+
*
|
|
7
|
+
* Ideal for:
|
|
8
|
+
* - Long-running tasks (overnight work, multi-day projects)
|
|
9
|
+
* - Cost-efficient: pay only when machine is running
|
|
10
|
+
* - Persistent storage between stops/starts
|
|
11
|
+
*
|
|
12
|
+
* @see https://fly.io/docs/machines/api/
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { BaseProvider } from './base-provider.js';
|
|
16
|
+
|
|
17
|
+
export class FlyProvider extends BaseProvider {
|
|
18
|
+
/**
|
|
19
|
+
* Initialize FlyProvider
|
|
20
|
+
* @param {Object} config - Provider configuration
|
|
21
|
+
* @param {VaultClient} config.vaultClient - Mech Vault client
|
|
22
|
+
* @param {LivePortClient} config.livePortClient - LivePort client
|
|
23
|
+
* @param {string} [config.flyApiToken] - Fly.io API token (or FLY_API_TOKEN env var)
|
|
24
|
+
* @param {string} [config.appName] - Fly app name (or FLY_APP_NAME env var, defaults to 'teleportation-remote')
|
|
25
|
+
* @param {string} [config.region] - Fly region code (defaults to 'iad' - Ashburn, VA)
|
|
26
|
+
*/
|
|
27
|
+
constructor(config) {
|
|
28
|
+
super(config);
|
|
29
|
+
this.apiToken = config.flyApiToken || process.env.FLY_API_TOKEN;
|
|
30
|
+
this.apiUrl = 'https://api.machines.dev/v1';
|
|
31
|
+
this.appName = config.appName || process.env.FLY_APP_NAME || 'teleportation-remote';
|
|
32
|
+
this.region = config.region || 'iad'; // Default to Ashburn, VA
|
|
33
|
+
this.timeout = config.timeout || 30000; // 30 second default timeout
|
|
34
|
+
|
|
35
|
+
if (!this.apiToken) {
|
|
36
|
+
throw new Error('FLY_API_TOKEN is required for FlyProvider');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create headers for Fly.io API
|
|
42
|
+
* @private
|
|
43
|
+
*/
|
|
44
|
+
_headers() {
|
|
45
|
+
const authToken = this.apiToken.startsWith('Bearer ') ? this.apiToken : `Bearer ${this.apiToken}`;
|
|
46
|
+
return {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
'Authorization': authToken,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Escape shell argument by wrapping in single quotes and escaping any single quotes
|
|
54
|
+
* @private
|
|
55
|
+
* @param {string} arg - Argument to escape
|
|
56
|
+
* @returns {string} Shell-safe escaped argument
|
|
57
|
+
*/
|
|
58
|
+
_escapeShellArg(arg) {
|
|
59
|
+
if (!arg) return "''";
|
|
60
|
+
// Replace any single quotes with '\'' (end quote, escaped quote, start quote)
|
|
61
|
+
return `'${arg.replace(/'/g, "'\\''")}'`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fetch with timeout support
|
|
66
|
+
* @private
|
|
67
|
+
* @param {string} url - URL to fetch
|
|
68
|
+
* @param {Object} options - Fetch options
|
|
69
|
+
* @returns {Promise<Response>} Fetch response
|
|
70
|
+
* @throws {Error} If request times out or fails
|
|
71
|
+
*/
|
|
72
|
+
async _fetchWithTimeout(url, options = {}) {
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetch(url, {
|
|
78
|
+
...options,
|
|
79
|
+
signal: controller.signal,
|
|
80
|
+
});
|
|
81
|
+
clearTimeout(timeoutId);
|
|
82
|
+
return response;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
clearTimeout(timeoutId);
|
|
85
|
+
if (error.name === 'AbortError') {
|
|
86
|
+
throw new Error(`Request timed out after ${this.timeout}ms: ${url}`);
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Create a remote Fly.io machine
|
|
94
|
+
*
|
|
95
|
+
* Provisions a new Fly machine with:
|
|
96
|
+
* - Bun runtime (oven/bun:latest)
|
|
97
|
+
* - SSH access configured
|
|
98
|
+
* - LivePort tunnel setup
|
|
99
|
+
* - All secrets stored in Vault
|
|
100
|
+
* - Git repository cloned
|
|
101
|
+
* - Teleportation daemon started
|
|
102
|
+
*
|
|
103
|
+
* @param {Object} sessionConfig - Session configuration (see BaseProvider)
|
|
104
|
+
* @returns {Promise<Object>} Machine details with IDs for cleanup
|
|
105
|
+
* @throws {Error} If machine provisioning fails
|
|
106
|
+
*/
|
|
107
|
+
async createMachine(sessionConfig) {
|
|
108
|
+
const sessionId = sessionConfig.sessionId;
|
|
109
|
+
const namespace = `teleportationremote${sessionId.replace(/-/g, '')}`;
|
|
110
|
+
|
|
111
|
+
// 1. Setup SSH access
|
|
112
|
+
console.log('[DEBUG] Step 1: Setting up SSH access...');
|
|
113
|
+
const ssh = await this._setupSSHAccess(sessionConfig.machineId || sessionId, namespace);
|
|
114
|
+
console.log('[DEBUG] SSH setup complete');
|
|
115
|
+
|
|
116
|
+
// 2. Setup LivePort tunnel
|
|
117
|
+
console.log('[DEBUG] Step 2: Setting up LivePort tunnel...');
|
|
118
|
+
const tunnel = await this._setupTunnel(sessionId, sessionId);
|
|
119
|
+
console.log('[DEBUG] Tunnel setup complete');
|
|
120
|
+
|
|
121
|
+
// 3. Store necessary secrets in Vault
|
|
122
|
+
console.log('[DEBUG] Step 3: Storing secrets in Vault...');
|
|
123
|
+
|
|
124
|
+
// Fetch user environment variables from relay API
|
|
125
|
+
let userEnvVars = {};
|
|
126
|
+
if (sessionConfig.relayApiUrl && sessionConfig.relayApiKey) {
|
|
127
|
+
try {
|
|
128
|
+
console.log('[DEBUG] Fetching user environment variables from relay API...');
|
|
129
|
+
const envVarsResponse = await fetch(`${sessionConfig.relayApiUrl}/api/env-vars`, {
|
|
130
|
+
headers: {
|
|
131
|
+
'Authorization': `Bearer ${sessionConfig.relayApiKey}`,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (!envVarsResponse.ok) {
|
|
136
|
+
console.warn('[WARNING] Failed to fetch user environment variables:', envVarsResponse.status);
|
|
137
|
+
console.warn('[WARNING] Remote session will use local environment variables only');
|
|
138
|
+
} else {
|
|
139
|
+
const envVarsData = await envVarsResponse.json();
|
|
140
|
+
userEnvVars = envVarsData.variables || {};
|
|
141
|
+
console.log('[DEBUG] Fetched user environment variables:', Object.keys(userEnvVars).join(', '));
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
console.warn('[WARNING] Error fetching user environment variables:', error.message);
|
|
145
|
+
console.warn('[WARNING] Remote session will use local environment variables only');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Try to extract Claude API key from local installation if not provided
|
|
150
|
+
// User env vars take precedence, then sessionConfig, then local extraction, then env vars
|
|
151
|
+
let anthropicApiKey = userEnvVars.ANTHROPIC_API_KEY || sessionConfig.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
|
|
152
|
+
if (!anthropicApiKey) {
|
|
153
|
+
try {
|
|
154
|
+
const { extractClaudeApiKey } = await import('../auth/claude-key-extractor.js');
|
|
155
|
+
anthropicApiKey = await extractClaudeApiKey();
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.log('[DEBUG] Could not auto-extract Claude API key:', error.message);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const secrets = {
|
|
162
|
+
RELAY_API_URL: sessionConfig.relayApiUrl,
|
|
163
|
+
RELAY_API_KEY: sessionConfig.relayApiKey,
|
|
164
|
+
GITHUB_TOKEN: userEnvVars.GITHUB_TOKEN || sessionConfig.githubToken,
|
|
165
|
+
MECH_API_KEY: sessionConfig.mechApiKey,
|
|
166
|
+
LIVEPORT_BRIDGE_KEY: tunnel.bridgeKey,
|
|
167
|
+
// Machine coder API keys (user env vars take precedence)
|
|
168
|
+
ANTHROPIC_API_KEY: anthropicApiKey,
|
|
169
|
+
GEMINI_API_KEY: userEnvVars.GEMINI_API_KEY || sessionConfig.geminiApiKey || process.env.GEMINI_API_KEY,
|
|
170
|
+
// Merge all other user environment variables
|
|
171
|
+
...userEnvVars,
|
|
172
|
+
// Session config can override user env vars if explicitly provided
|
|
173
|
+
...sessionConfig.additionalSecrets,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const secretIds = await this._storeSecrets(namespace, secrets);
|
|
177
|
+
console.log('[DEBUG] Secrets stored');
|
|
178
|
+
|
|
179
|
+
// 4. Export env file for the machine
|
|
180
|
+
console.log('[DEBUG] Step 4: Exporting env file...');
|
|
181
|
+
const envFile = await this._exportEnvFile(namespace);
|
|
182
|
+
console.log('[DEBUG] Env file exported');
|
|
183
|
+
|
|
184
|
+
// 5. Create Fly.io machine
|
|
185
|
+
const machineConfig = {
|
|
186
|
+
name: `teleportation-${sessionId}`,
|
|
187
|
+
config: {
|
|
188
|
+
image: 'oven/bun:latest',
|
|
189
|
+
auto_destroy: true,
|
|
190
|
+
restart: {
|
|
191
|
+
policy: 'no',
|
|
192
|
+
},
|
|
193
|
+
env: {
|
|
194
|
+
SESSION_ID: sessionId,
|
|
195
|
+
VAULT_NAMESPACE: namespace,
|
|
196
|
+
MECH_VAULT_URL: this.vaultClient.apiUrl,
|
|
197
|
+
MECH_API_KEY: this.vaultClient.apiKey,
|
|
198
|
+
MECH_APP_ID: this.vaultClient.appId,
|
|
199
|
+
TUNNEL_URL: tunnel.tunnelUrl,
|
|
200
|
+
},
|
|
201
|
+
services: [
|
|
202
|
+
{
|
|
203
|
+
ports: [
|
|
204
|
+
{
|
|
205
|
+
port: 22,
|
|
206
|
+
handlers: ['tls', 'http'],
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
port: 3000,
|
|
210
|
+
handlers: ['tls', 'http'],
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
protocol: 'tcp',
|
|
214
|
+
internal_port: 22,
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
guest: {
|
|
218
|
+
cpu_kind: 'shared',
|
|
219
|
+
cpus: 1,
|
|
220
|
+
memory_mb: 512,
|
|
221
|
+
},
|
|
222
|
+
init: {
|
|
223
|
+
cmd: ['/bin/sh', '-c', this._generateInitScript(sessionConfig, tunnel, ssh)],
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const response = await fetch(`${this.apiUrl}/apps/${this.appName}/machines`, {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: this._headers(),
|
|
231
|
+
body: JSON.stringify(machineConfig),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
if (!response.ok) {
|
|
235
|
+
const error = await response.text();
|
|
236
|
+
throw new Error(`Failed to create Fly machine: ${error}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const machine = await response.json();
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
machineId: machine.id,
|
|
243
|
+
sshHost: `${machine.id}.vm.${this.appName}.internal`,
|
|
244
|
+
sshPort: 22,
|
|
245
|
+
tunnelUrl: tunnel.tunnelUrl,
|
|
246
|
+
namespace,
|
|
247
|
+
secretIds,
|
|
248
|
+
sshKeyId: ssh.keyId,
|
|
249
|
+
bridgeKeyId: tunnel.keyId,
|
|
250
|
+
publicIp: machine.private_ip,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Generate initialization script for the Fly machine
|
|
256
|
+
*
|
|
257
|
+
* This script runs when the machine starts and sets up:
|
|
258
|
+
* - SSH server with authorized keys
|
|
259
|
+
* - Git repository clone
|
|
260
|
+
* - LivePort tunnel
|
|
261
|
+
* - Teleportation daemon
|
|
262
|
+
*
|
|
263
|
+
* @private
|
|
264
|
+
* @param {Object} sessionConfig - Session configuration
|
|
265
|
+
* @param {Object} tunnel - Tunnel configuration with tunnelCommand
|
|
266
|
+
* @param {Object} ssh - SSH key with publicKey
|
|
267
|
+
* @returns {string} Shell script for machine initialization
|
|
268
|
+
*/
|
|
269
|
+
_generateInitScript(sessionConfig, tunnel, ssh) {
|
|
270
|
+
// Escape all user-provided values to prevent command injection
|
|
271
|
+
const safeRepoUrl = this._escapeShellArg(sessionConfig.repoUrl);
|
|
272
|
+
const safeBranch = this._escapeShellArg(sessionConfig.branch || 'main');
|
|
273
|
+
const safeSessionId = this._escapeShellArg(sessionConfig.sessionId);
|
|
274
|
+
const safeTask = this._escapeShellArg(sessionConfig.task || '');
|
|
275
|
+
const safePublicKey = this._escapeShellArg(ssh.publicKey);
|
|
276
|
+
const safeVaultUrl = this._escapeShellArg(this.vaultClient.apiUrl);
|
|
277
|
+
const safeVaultKey = this._escapeShellArg(this.vaultClient.apiKey);
|
|
278
|
+
const safeAppId = this._escapeShellArg(this.vaultClient.appId);
|
|
279
|
+
|
|
280
|
+
// Teleportation repo URL (configurable via env var or config)
|
|
281
|
+
const teleportationRepoUrl = process.env.TELEPORTATION_REPO_URL ||
|
|
282
|
+
sessionConfig.teleportationRepoUrl ||
|
|
283
|
+
'https://github.com/dundas/teleportation-private.git';
|
|
284
|
+
const safeTeleportationRepoUrl = this._escapeShellArg(teleportationRepoUrl);
|
|
285
|
+
|
|
286
|
+
return `
|
|
287
|
+
set -e
|
|
288
|
+
|
|
289
|
+
# Install system dependencies
|
|
290
|
+
apt-get update && apt-get install -y git openssh-server curl jq wget
|
|
291
|
+
|
|
292
|
+
# Install Node.js (required for Claude Code CLI)
|
|
293
|
+
echo "=== Installing Node.js ==="
|
|
294
|
+
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
|
295
|
+
apt-get install -y nodejs
|
|
296
|
+
echo "✅ Node.js installed: $(node --version)"
|
|
297
|
+
|
|
298
|
+
# Install Claude Code CLI
|
|
299
|
+
echo "=== Installing Claude Code CLI ==="
|
|
300
|
+
npm install -g @anthropic-ai/claude-code || {
|
|
301
|
+
echo "WARNING: Claude Code CLI installation failed"
|
|
302
|
+
}
|
|
303
|
+
if command -v claude >/dev/null 2>&1; then
|
|
304
|
+
echo "✅ Claude Code CLI installed: $(claude --version)"
|
|
305
|
+
else
|
|
306
|
+
echo "⚠️ Claude Code CLI not available"
|
|
307
|
+
fi
|
|
308
|
+
|
|
309
|
+
# Install Gemini CLI
|
|
310
|
+
echo "=== Installing Gemini CLI ==="
|
|
311
|
+
wget -O /tmp/gemini-cli.tar.gz https://github.com/google/generative-ai-cli/releases/latest/download/gemini-cli-linux-amd64.tar.gz || {
|
|
312
|
+
echo "WARNING: Gemini CLI download failed"
|
|
313
|
+
}
|
|
314
|
+
if [ -f /tmp/gemini-cli.tar.gz ]; then
|
|
315
|
+
tar -xzf /tmp/gemini-cli.tar.gz -C /usr/local/bin/
|
|
316
|
+
chmod +x /usr/local/bin/gemini
|
|
317
|
+
rm /tmp/gemini-cli.tar.gz
|
|
318
|
+
if command -v gemini >/dev/null 2>&1; then
|
|
319
|
+
echo "✅ Gemini CLI installed: $(gemini --version)"
|
|
320
|
+
else
|
|
321
|
+
echo "⚠️ Gemini CLI not available"
|
|
322
|
+
fi
|
|
323
|
+
else
|
|
324
|
+
echo "⚠️ Gemini CLI installation skipped"
|
|
325
|
+
fi
|
|
326
|
+
|
|
327
|
+
# Setup SSH
|
|
328
|
+
mkdir -p ~/.ssh
|
|
329
|
+
echo ${safePublicKey} >> ~/.ssh/authorized_keys
|
|
330
|
+
chmod 600 ~/.ssh/authorized_keys
|
|
331
|
+
# SSH service not needed in container
|
|
332
|
+
|
|
333
|
+
# Pull secrets from Vault
|
|
334
|
+
export MECH_VAULT_URL=${safeVaultUrl}
|
|
335
|
+
export MECH_API_KEY=${safeVaultKey}
|
|
336
|
+
export MECH_APP_ID=${safeAppId}
|
|
337
|
+
|
|
338
|
+
# Export session env vars from Vault (with debugging)
|
|
339
|
+
ENV_FILE=/tmp/teleportation.env
|
|
340
|
+
echo "=== Exporting env from Vault ==="
|
|
341
|
+
echo "Namespace: $VAULT_NAMESPACE"
|
|
342
|
+
echo "Vault URL: $MECH_VAULT_URL"
|
|
343
|
+
|
|
344
|
+
# Construct payload
|
|
345
|
+
PAYLOAD=$(jq -n --arg namespace "$VAULT_NAMESPACE" --arg format 'env' '{namespace:$namespace,format:$format}')
|
|
346
|
+
echo "Payload: $PAYLOAD"
|
|
347
|
+
|
|
348
|
+
# Make request with verbose output
|
|
349
|
+
curl -v -X POST "$MECH_VAULT_URL/env-files/export" \
|
|
350
|
+
-H "Content-Type: application/json" \
|
|
351
|
+
-H "X-API-Key: $MECH_API_KEY" \
|
|
352
|
+
-H "X-App-ID: $MECH_APP_ID" \
|
|
353
|
+
-d "$PAYLOAD" \
|
|
354
|
+
-o "$ENV_FILE" 2>&1 || {
|
|
355
|
+
EXIT_CODE=$?
|
|
356
|
+
echo "ERROR: Vault env export failed with exit code $EXIT_CODE"
|
|
357
|
+
echo "Response body (if any):"
|
|
358
|
+
cat "$ENV_FILE" 2>/dev/null || echo "(empty)"
|
|
359
|
+
exit $EXIT_CODE
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if [ ! -s "$ENV_FILE" ]; then
|
|
363
|
+
echo "WARNING: Env file is empty, creating minimal file"
|
|
364
|
+
echo "# No secrets found" > "$ENV_FILE"
|
|
365
|
+
fi
|
|
366
|
+
|
|
367
|
+
echo "✅ Env file exported successfully ($(wc -l < "$ENV_FILE") lines)"
|
|
368
|
+
|
|
369
|
+
# Load exported env vars (no xtrace; avoid printing secrets)
|
|
370
|
+
set +x
|
|
371
|
+
set -a
|
|
372
|
+
. "$ENV_FILE"
|
|
373
|
+
set +a
|
|
374
|
+
|
|
375
|
+
# Configure git authentication (for both teleportation and user repositories)
|
|
376
|
+
if [ -n "$GITHUB_TOKEN" ]; then
|
|
377
|
+
git config --global url."https://x-access-token:$GITHUB_TOKEN@github.com/".insteadOf "https://github.com/"
|
|
378
|
+
git config --global url."https://x-access-token:$GITHUB_TOKEN@github.com/".insteadOf "git@github.com:"
|
|
379
|
+
echo "✅ Git authentication configured"
|
|
380
|
+
fi
|
|
381
|
+
|
|
382
|
+
# Clone teleportation repository first (contains daemon)
|
|
383
|
+
echo "=== Cloning teleportation repository ==="
|
|
384
|
+
git clone ${safeTeleportationRepoUrl} /teleportation
|
|
385
|
+
cd /teleportation
|
|
386
|
+
bun install
|
|
387
|
+
echo "✅ Teleportation repo ready at /teleportation"
|
|
388
|
+
|
|
389
|
+
# Clone user repository
|
|
390
|
+
echo "=== Cloning user repository ==="
|
|
391
|
+
git clone ${safeRepoUrl} /workspace
|
|
392
|
+
cd /workspace
|
|
393
|
+
|
|
394
|
+
# Checkout branch
|
|
395
|
+
git checkout ${safeBranch}
|
|
396
|
+
|
|
397
|
+
# Install repo dependencies
|
|
398
|
+
bun install
|
|
399
|
+
echo "✅ User repo ready at /workspace"
|
|
400
|
+
|
|
401
|
+
# Start LivePort tunnel in background
|
|
402
|
+
${tunnel.tunnelCommand} &
|
|
403
|
+
|
|
404
|
+
# Ensure session exists in relay so daemon can poll immediately
|
|
405
|
+
curl -fsSL -X POST "$RELAY_API_URL/api/sessions/register" \
|
|
406
|
+
-H "Content-Type: application/json" \
|
|
407
|
+
-H "Authorization: Bearer $RELAY_API_KEY" \
|
|
408
|
+
-d "$(jq -n --arg session_id \"$SESSION_ID\" '{session_id:$session_id,meta:{provider:"fly",remote:true}}')" >/dev/null || true
|
|
409
|
+
|
|
410
|
+
# Start teleportation daemon from user workspace
|
|
411
|
+
# The daemon will run in /workspace context so child processes have correct working directory
|
|
412
|
+
cd /workspace
|
|
413
|
+
echo "=== Starting daemon ==="
|
|
414
|
+
echo "Session ID: $SESSION_ID"
|
|
415
|
+
echo "Teleportation repo: /teleportation"
|
|
416
|
+
echo "Working directory: $(pwd)"
|
|
417
|
+
|
|
418
|
+
# Export task description for daemon
|
|
419
|
+
export TELEPORTATION_TASK="${safeTask}"
|
|
420
|
+
|
|
421
|
+
# Start daemon (it's installed in /teleportation)
|
|
422
|
+
bun /teleportation/teleportation-cli.cjs daemon start || {
|
|
423
|
+
echo "ERROR: Daemon failed to start (exit code $?)"
|
|
424
|
+
exit 1
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
# Keep container running
|
|
428
|
+
echo "=== Daemon started successfully, keeping container alive ==="
|
|
429
|
+
echo "Initialization completed at $(date)"
|
|
430
|
+
tail -f /dev/null
|
|
431
|
+
`.trim();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Destroy the Fly.io machine
|
|
436
|
+
*
|
|
437
|
+
* Permanently deletes the machine and all its data.
|
|
438
|
+
* This action cannot be undone.
|
|
439
|
+
*
|
|
440
|
+
* @param {string} machineId - Fly machine ID
|
|
441
|
+
* @returns {Promise<boolean>} True if destroyed successfully
|
|
442
|
+
* @throws {Error} If machine deletion fails
|
|
443
|
+
*/
|
|
444
|
+
async destroyMachine(machineId) {
|
|
445
|
+
this._validateMachineId(machineId);
|
|
446
|
+
const response = await this._fetchWithTimeout(`${this.apiUrl}/apps/${this.appName}/machines/${machineId}`, {
|
|
447
|
+
method: 'DELETE',
|
|
448
|
+
headers: this._headers(),
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
if (!response.ok) {
|
|
452
|
+
throw new Error(`Failed to destroy Fly machine: ${response.statusText}`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Get machine status
|
|
460
|
+
*
|
|
461
|
+
* Returns current state of the Fly machine.
|
|
462
|
+
*
|
|
463
|
+
* @param {string} machineId - Fly machine ID
|
|
464
|
+
* @returns {Promise<Object>} Machine status
|
|
465
|
+
* @returns {string} return.status - Machine state (started, stopped, etc.)
|
|
466
|
+
* @returns {string} return.ip - Private IP address
|
|
467
|
+
* @returns {string} return.uptime - Last update timestamp
|
|
468
|
+
* @returns {string} return.region - Fly region
|
|
469
|
+
* @throws {Error} If status fetch fails
|
|
470
|
+
*/
|
|
471
|
+
async getMachineStatus(machineId) {
|
|
472
|
+
this._validateMachineId(machineId);
|
|
473
|
+
const response = await this._fetchWithTimeout(`${this.apiUrl}/apps/${this.appName}/machines/${machineId}`, {
|
|
474
|
+
method: 'GET',
|
|
475
|
+
headers: this._headers(),
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
if (!response.ok) {
|
|
479
|
+
throw new Error(`Failed to get machine status: ${response.statusText}`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const machine = await response.json();
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
status: machine.state,
|
|
486
|
+
ip: machine.private_ip,
|
|
487
|
+
uptime: machine.updated_at,
|
|
488
|
+
region: machine.region,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Execute command on remote machine via SSH
|
|
494
|
+
*
|
|
495
|
+
* Note: This is a placeholder. Real SSH execution would require
|
|
496
|
+
* establishing SSH connection using the private key from Vault.
|
|
497
|
+
*
|
|
498
|
+
* @param {string} machineId - Fly machine ID
|
|
499
|
+
* @param {string} command - Command to execute
|
|
500
|
+
* @returns {Promise<Object>} Command result (placeholder)
|
|
501
|
+
* @returns {string} return.stdout - Standard output
|
|
502
|
+
* @returns {string} return.stderr - Standard error
|
|
503
|
+
* @returns {number} return.exitCode - Exit code
|
|
504
|
+
*/
|
|
505
|
+
async executeCommand(machineId, command) {
|
|
506
|
+
this._validateMachineId(machineId);
|
|
507
|
+
|
|
508
|
+
// TODO: Implement real SSH command execution
|
|
509
|
+
// This would require:
|
|
510
|
+
// 1. Fetch private key from Vault
|
|
511
|
+
// 2. Establish SSH connection to machine
|
|
512
|
+
// 3. Execute command
|
|
513
|
+
// 4. Return stdout/stderr/exitCode
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
stdout: '',
|
|
517
|
+
stderr: '',
|
|
518
|
+
exitCode: 0,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Get machine logs
|
|
524
|
+
*
|
|
525
|
+
* Retrieves logs from the Fly machine's init process.
|
|
526
|
+
*
|
|
527
|
+
* @param {string} machineId - Fly machine ID
|
|
528
|
+
* @param {Object} [options] - Log retrieval options
|
|
529
|
+
* @param {number} [options.tail] - Number of recent lines to retrieve
|
|
530
|
+
* @param {boolean} [options.follow] - Stream logs (not implemented)
|
|
531
|
+
* @returns {Promise<Object>} Log data
|
|
532
|
+
* @returns {Array<string>} return.logs - Array of log lines
|
|
533
|
+
* @throws {Error} If log retrieval fails
|
|
534
|
+
*/
|
|
535
|
+
async getLogs(machineId, options = {}) {
|
|
536
|
+
this._validateMachineId(machineId);
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
const params = new URLSearchParams();
|
|
540
|
+
if (options.tail) {
|
|
541
|
+
params.set('limit', options.tail.toString());
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const url = `${this.apiUrl}/apps/${this.appName}/machines/${machineId}/logs?${params}`;
|
|
545
|
+
const response = await this._fetchWithTimeout(url, {
|
|
546
|
+
method: 'GET',
|
|
547
|
+
headers: this._headers(),
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
if (!response.ok) {
|
|
551
|
+
throw new Error(`Failed to get logs: ${response.statusText}`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const logs = await response.json();
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
logs: Array.isArray(logs) ? logs : [],
|
|
558
|
+
};
|
|
559
|
+
} catch (error) {
|
|
560
|
+
// Fallback to empty logs if API doesn't support log retrieval
|
|
561
|
+
console.warn(`Failed to retrieve logs for ${machineId}:`, error.message);
|
|
562
|
+
return { logs: [] };
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Stop a machine (without destroying)
|
|
568
|
+
*
|
|
569
|
+
* Stops the machine to save costs. Machine state is preserved.
|
|
570
|
+
*
|
|
571
|
+
* @param {string} machineId - Fly machine ID
|
|
572
|
+
* @returns {Promise<boolean>} True if stopped successfully
|
|
573
|
+
* @throws {Error} If stop fails
|
|
574
|
+
*/
|
|
575
|
+
async stopMachine(machineId) {
|
|
576
|
+
this._validateMachineId(machineId);
|
|
577
|
+
const response = await this._fetchWithTimeout(`${this.apiUrl}/apps/${this.appName}/machines/${machineId}/stop`, {
|
|
578
|
+
method: 'POST',
|
|
579
|
+
headers: this._headers(),
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
if (!response.ok) {
|
|
583
|
+
throw new Error(`Failed to stop machine: ${response.statusText}`);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Start a stopped machine
|
|
591
|
+
*
|
|
592
|
+
* Resumes a stopped machine. State is preserved from when it was stopped.
|
|
593
|
+
*
|
|
594
|
+
* @param {string} machineId - Fly machine ID
|
|
595
|
+
* @returns {Promise<boolean>} True if started successfully
|
|
596
|
+
* @throws {Error} If start fails
|
|
597
|
+
*/
|
|
598
|
+
async startMachine(machineId) {
|
|
599
|
+
this._validateMachineId(machineId);
|
|
600
|
+
const response = await this._fetchWithTimeout(`${this.apiUrl}/apps/${this.appName}/machines/${machineId}/start`, {
|
|
601
|
+
method: 'POST',
|
|
602
|
+
headers: this._headers(),
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
if (!response.ok) {
|
|
606
|
+
throw new Error(`Failed to start machine: ${response.statusText}`);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
}
|