teleportation-cli 1.0.0 → 1.0.2

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.
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Robust Init Script Generator
3
+ *
4
+ * Generates an init script with:
5
+ * - Network readiness checks
6
+ * - Detailed error logging
7
+ * - Retry logic for network calls
8
+ * - Step-by-step validation
9
+ */
10
+
11
+ export function generateRobustInitScript(sessionConfig, ssh, tunnel, vaultClient, repoUrl, branch) {
12
+ const safeSessionId = escapeShellArg(sessionConfig.id);
13
+ const safeTask = escapeShellArg(sessionConfig.task || '');
14
+ const safePublicKey = escapeShellArg(ssh.publicKey);
15
+ const safeBranch = escapeShellArg(branch);
16
+ const safeRepoUrl = escapeShellArg(repoUrl);
17
+ const safeVaultUrl = escapeShellArg(vaultClient.apiUrl);
18
+ const safeVaultKey = escapeShellArg(vaultClient.apiKey);
19
+ const safeAppId = escapeShellArg(vaultClient.appId);
20
+ const namespace = sessionConfig.vaultNamespace;
21
+
22
+ return `
23
+ set -e
24
+
25
+ log_step() {
26
+ echo "=== [$1] $2 ==="
27
+ }
28
+
29
+ log_error() {
30
+ echo "❌ ERROR: $1" >&2
31
+ }
32
+
33
+ log_success() {
34
+ echo "✅ $1"
35
+ }
36
+
37
+ # Wait for network to be ready
38
+ wait_for_network() {
39
+ log_step "0" "Waiting for network readiness"
40
+ local max_attempts=30
41
+ local attempt=1
42
+
43
+ while [ $attempt -le $max_attempts ]; do
44
+ if curl -fsSL --max-time 5 https://vault.mechdna.net/api/health >/dev/null 2>&1; then
45
+ log_success "Network is ready (attempt $attempt)"
46
+ return 0
47
+ fi
48
+ echo "Network not ready yet (attempt $attempt/$max_attempts)"
49
+ sleep 2
50
+ attempt=$((attempt + 1))
51
+ done
52
+
53
+ log_error "Network did not become ready after $max_attempts attempts"
54
+ return 1
55
+ }
56
+
57
+ # Retry a curl command with exponential backoff
58
+ retry_curl() {
59
+ local max_attempts=3
60
+ local attempt=1
61
+ local wait_time=2
62
+
63
+ while [ $attempt -le $max_attempts ]; do
64
+ if "$@"; then
65
+ return 0
66
+ fi
67
+ local exit_code=$?
68
+
69
+ if [ $attempt -lt $max_attempts ]; then
70
+ echo "⚠️ Curl failed (exit $exit_code), retrying in $wait_time seconds (attempt $attempt/$max_attempts)"
71
+ sleep $wait_time
72
+ wait_time=$((wait_time * 2))
73
+ attempt=$((attempt + 1))
74
+ else
75
+ log_error "Curl failed after $max_attempts attempts (exit code: $exit_code)"
76
+ return $exit_code
77
+ fi
78
+ done
79
+ }
80
+
81
+ log_step "1" "Installing dependencies"
82
+ apt-get update -qq && apt-get install -y -qq git openssh-server curl jq dnsutils
83
+ log_success "Dependencies installed"
84
+
85
+ log_step "2" "Checking DNS resolution"
86
+ if nslookup vault.mechdna.net >/dev/null 2>&1; then
87
+ log_success "DNS resolution working"
88
+ else
89
+ log_error "DNS resolution failed for vault.mechdna.net"
90
+ exit 1
91
+ fi
92
+
93
+ log_step "3" "Waiting for network connectivity"
94
+ wait_for_network || exit 1
95
+
96
+ log_step "4" "Setting up SSH"
97
+ mkdir -p ~/.ssh
98
+ echo ${safePublicKey} >> ~/.ssh/authorized_keys
99
+ chmod 600 ~/.ssh/authorized_keys
100
+ log_success "SSH configured"
101
+
102
+ log_step "5" "Configuring environment variables"
103
+ export VAULT_NAMESPACE="${namespace}"
104
+ export MECH_VAULT_URL=${safeVaultUrl}
105
+ export MECH_API_KEY=${safeVaultKey}
106
+ export MECH_APP_ID=${safeAppId}
107
+ export SESSION_ID=${safeSessionId}
108
+
109
+ echo "VAULT_NAMESPACE=$VAULT_NAMESPACE"
110
+ echo "MECH_VAULT_URL=$MECH_VAULT_URL"
111
+ echo "SESSION_ID=$SESSION_ID"
112
+ log_success "Environment configured"
113
+
114
+ log_step "6" "Fetching secrets from Vault"
115
+ ENV_FILE=/tmp/teleportation.env
116
+
117
+ retry_curl curl -fsSL -X POST "$MECH_VAULT_URL/env-files/export" \
118
+ -H "Content-Type: application/json" \
119
+ -H "X-API-Key: $MECH_API_KEY" \
120
+ -H "X-App-ID: $MECH_APP_ID" \
121
+ -d "$(jq -n --arg namespace "$VAULT_NAMESPACE" --arg format 'env' '{namespace:$namespace,format:$format}')" \
122
+ -o "$ENV_FILE" || exit 1
123
+
124
+ if [ ! -s "$ENV_FILE" ]; then
125
+ log_error "Vault env file is empty"
126
+ exit 1
127
+ fi
128
+
129
+ log_success "Secrets fetched ($(wc -l < "$ENV_FILE") lines)"
130
+
131
+ log_step "7" "Loading environment variables"
132
+ set +x
133
+ set -a
134
+ . "$ENV_FILE"
135
+ set +a
136
+ log_success "Environment loaded"
137
+
138
+ log_step "8" "Configuring Git authentication"
139
+ if [ -n "$GITHUB_TOKEN" ]; then
140
+ git config --global url."https://x-access-token:$GITHUB_TOKEN@github.com/".insteadOf "https://github.com/"
141
+ git config --global url."https://x-access-token:$GITHUB_TOKEN@github.com/".insteadOf "git@github.com:"
142
+ log_success "Git authentication configured"
143
+ else
144
+ echo "⚠️ No GITHUB_TOKEN found, proceeding without authentication"
145
+ fi
146
+
147
+ log_step "9" "Cloning repository"
148
+ git clone ${safeRepoUrl} /workspace || exit 1
149
+ cd /workspace
150
+ log_success "Repository cloned"
151
+
152
+ log_step "10" "Checking out branch"
153
+ git checkout ${safeBranch} || exit 1
154
+ log_success "Branch checked out: ${safeBranch}"
155
+
156
+ log_step "11" "Installing project dependencies"
157
+ bun install || exit 1
158
+ log_success "Dependencies installed"
159
+
160
+ log_step "12" "Starting LivePort tunnel"
161
+ ${tunnel.tunnelCommand} &
162
+ TUNNEL_PID=$!
163
+ log_success "Tunnel started (PID: $TUNNEL_PID)"
164
+
165
+ log_step "13" "Registering session with relay"
166
+ retry_curl curl -fsSL -X POST "$RELAY_API_URL/api/sessions/register" \
167
+ -H "Content-Type: application/json" \
168
+ -H "Authorization: Bearer $RELAY_API_KEY" \
169
+ -d "$(jq -n --arg session_id \"$SESSION_ID\" '{session_id:$session_id,meta:{provider:"fly",remote:true}}')" \
170
+ >/dev/null || echo "⚠️ Session registration failed (non-fatal)"
171
+
172
+ log_step "14" "Starting teleportation daemon"
173
+ echo "Task: ${safeTask}"
174
+ bun teleportation-cli.cjs daemon start --session-id ${safeSessionId} --task ${safeTask} || {
175
+ log_error "Daemon failed to start (exit code $?)"
176
+ exit 1
177
+ }
178
+
179
+ log_success "Initialization completed at $(date)"
180
+ echo "=== Daemon is running, container will stay alive ==="
181
+ tail -f /dev/null
182
+ `.trim();
183
+ }
184
+
185
+ function escapeShellArg(str) {
186
+ return "'" + String(str || '').replace(/'/g, "'\\''") + "'";
187
+ }
@@ -0,0 +1,417 @@
1
+ /**
2
+ * LivePort Client for remote development environment access
3
+ *
4
+ * Handles:
5
+ * - Creating tunnels to remote machines
6
+ * - Managing bridge keys for authentication
7
+ * - Programmatic tunnel control via Agent SDK
8
+ * - Waiting for connections
9
+ */
10
+
11
+ export class LivePortClient {
12
+ constructor(config = {}) {
13
+ this.apiKey = config.apiKey || process.env.LIVEPORT_API_KEY;
14
+ this.apiUrl = config.apiUrl || process.env.LIVEPORT_API_URL || 'https://api.liveport.dev/v1';
15
+ this.maxRetries = config.maxRetries || 3;
16
+
17
+ // Support environment variable override for retry delays
18
+ const defaultRetries = process.env.LIVEPORT_RETRY_DELAYS
19
+ ? process.env.LIVEPORT_RETRY_DELAYS.split(',').map(Number)
20
+ : [1000, 2000, 4000]; // Exponential backoff
21
+ this.retryDelays = config.retryDelays || defaultRetries;
22
+
23
+ this.timeout = config.timeout || 30000; // 30 second default timeout
24
+ this.debug = config.debug ?? (process.env.LIVEPORT_DEBUG === 'true');
25
+
26
+ if (!this.apiKey) {
27
+ throw new Error('LIVEPORT_API_KEY is required');
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Create common headers for all requests
33
+ */
34
+ _headers() {
35
+ return {
36
+ 'Content-Type': 'application/json',
37
+ 'Authorization': `Bearer ${this.apiKey}`,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Fetch with timeout support
43
+ * @private
44
+ * @param {string} url - URL to fetch
45
+ * @param {Object} options - Fetch options
46
+ * @returns {Promise<Response>} Fetch response
47
+ * @throws {Error} If request times out or fails
48
+ */
49
+ async _fetchWithTimeout(url, options = {}) {
50
+ const controller = new AbortController();
51
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
52
+
53
+ try {
54
+ const response = await fetch(url, {
55
+ ...options,
56
+ signal: controller.signal,
57
+ });
58
+ clearTimeout(timeoutId);
59
+ return response;
60
+ } catch (error) {
61
+ clearTimeout(timeoutId);
62
+ if (error.name === 'AbortError') {
63
+ throw new Error(`Request timed out after ${this.timeout}ms: ${url}`);
64
+ }
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Log debug messages if debug mode is enabled
71
+ * @private
72
+ * @param {string} message - Message to log
73
+ */
74
+ _log(message) {
75
+ if (this.debug) {
76
+ console.error(`[LivePortClient] ${message}`);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Validate name format (alphanumeric, dashes, underscores)
82
+ */
83
+ _validateName(name) {
84
+ if (!name || name.trim() === '') {
85
+ throw new Error('name is required');
86
+ }
87
+ const trimmed = name.trim();
88
+ if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
89
+ throw new Error('Invalid name format: must contain only alphanumeric characters, dashes, and underscores');
90
+ }
91
+ return trimmed;
92
+ }
93
+
94
+ /**
95
+ * Validate keyId is not empty
96
+ */
97
+ _validateKeyId(keyId) {
98
+ if (!keyId || keyId.trim() === '') {
99
+ throw new Error('keyId is required');
100
+ }
101
+ return keyId;
102
+ }
103
+
104
+ /**
105
+ * Validate expiresAt is in the future
106
+ */
107
+ _validateExpiresAt(expiresAt) {
108
+ if (expiresAt) {
109
+ const expiryDate = new Date(expiresAt);
110
+ if (expiryDate <= new Date()) {
111
+ throw new Error('expiresAt must be in the future');
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Execute fetch with retry logic and exponential backoff
118
+ */
119
+ async _fetchWithRetry(url, options, retryCount = 0) {
120
+ try {
121
+ const response = await this._fetchWithTimeout(url, options);
122
+
123
+ // Don't retry on client errors (4xx)
124
+ if (!response.ok && response.status >= 400 && response.status < 500) {
125
+ if (response.status === 401) {
126
+ throw new Error('Authentication failed: Invalid API key');
127
+ }
128
+ if (response.status === 404) {
129
+ throw new Error('Tunnel not found');
130
+ }
131
+ throw new Error(`Request failed: ${response.status} ${response.statusText}`);
132
+ }
133
+
134
+ // Retry on server errors (5xx)
135
+ if (!response.ok && response.status >= 500) {
136
+ if (retryCount < this.maxRetries - 1) {
137
+ const delay = this.retryDelays[retryCount] || 4000;
138
+ this._log(`Retry attempt ${retryCount + 1} after ${delay}ms due to ${response.status}`);
139
+ await this._delay(delay);
140
+ return this._fetchWithRetry(url, options, retryCount + 1);
141
+ }
142
+ throw new Error(`Server error: ${response.status} ${response.statusText}`);
143
+ }
144
+
145
+ return response;
146
+ } catch (error) {
147
+ // Don't retry client errors (4xx) - they won't succeed on retry
148
+ if (error.message.includes('Authentication failed') ||
149
+ error.message.includes('Tunnel not found') ||
150
+ error.message.includes('Request failed')) {
151
+ throw error;
152
+ }
153
+
154
+ // Network errors - retry with exponential backoff
155
+ if (retryCount < this.maxRetries - 1) {
156
+ const delay = this.retryDelays[retryCount] || 4000;
157
+ this._log(`Retry attempt ${retryCount + 1} after ${delay}ms due to: ${error.message}`);
158
+ await this._delay(delay);
159
+ return this._fetchWithRetry(url, options, retryCount + 1);
160
+ }
161
+
162
+ // Exhausted retries - wrap error
163
+ throw new Error(`Failed after ${this.maxRetries} retries: ${error.message}`);
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Delay helper for exponential backoff
169
+ */
170
+ _delay(ms) {
171
+ return new Promise(resolve => setTimeout(resolve, ms));
172
+ }
173
+
174
+ /**
175
+ * Generate a bridge key for a remote session
176
+ * @param {Object} options - { name, expiresAt, rateLimit, metadata }
177
+ * @returns {Object} - { keyId, key, expiresAt }
178
+ */
179
+ async createBridgeKey(options = {}) {
180
+ if (typeof this.apiKey === 'string' && this.apiKey.startsWith('lpk_')) {
181
+ const expiresAt = options.expiresAt || this._defaultExpiration();
182
+ this._validateExpiresAt(expiresAt);
183
+
184
+ return {
185
+ keyId: null,
186
+ key: this.apiKey,
187
+ expiresAt,
188
+ metadata: options.metadata || {},
189
+ rateLimit: options.rateLimit,
190
+ };
191
+ }
192
+
193
+ // Validate inputs
194
+ const validName = this._validateName(options.name);
195
+ const expiresAt = options.expiresAt || this._defaultExpiration();
196
+ this._validateExpiresAt(expiresAt);
197
+
198
+ const response = await this._fetchWithRetry(`${this.apiUrl}/bridge-keys`, {
199
+ method: 'POST',
200
+ headers: this._headers(),
201
+ body: JSON.stringify({
202
+ name: validName,
203
+ expiresAt,
204
+ rateLimit: options.rateLimit,
205
+ metadata: options.metadata || {},
206
+ }),
207
+ });
208
+
209
+ return response.json();
210
+ }
211
+
212
+ /**
213
+ * Default expiration: 24 hours from now
214
+ */
215
+ _defaultExpiration() {
216
+ const tomorrow = new Date();
217
+ tomorrow.setHours(tomorrow.getHours() + 24);
218
+ return tomorrow.toISOString();
219
+ }
220
+
221
+ /**
222
+ * Get tunnel configuration for remote machine
223
+ * This generates the command that the remote machine should run
224
+ * @param {string} bridgeKey - Bridge key for authentication
225
+ * @param {Object} options - { port, subdomain, protocol }
226
+ */
227
+ getTunnelCommand(bridgeKey, options = {}) {
228
+ const port = options.port || 3000;
229
+ const subdomain = options.subdomain || null;
230
+ const protocol = options.protocol || 'http';
231
+
232
+ let cmd = `bunx @liveport/cli tunnel --port ${port} --key ${bridgeKey}`;
233
+
234
+ if (subdomain) {
235
+ cmd += ` --subdomain ${subdomain}`;
236
+ }
237
+
238
+ if (protocol === 'https') {
239
+ cmd += ` --https`;
240
+ }
241
+
242
+ return cmd;
243
+ }
244
+
245
+ /**
246
+ * Get tunnel URL for a session
247
+ * @param {string} sessionId - Remote session ID
248
+ * @param {string} subdomain - Custom subdomain (optional)
249
+ */
250
+ getTunnelUrl(sessionId, subdomain = null) {
251
+ if (subdomain) {
252
+ return `https://${subdomain}.liveport.dev`;
253
+ }
254
+
255
+ // Generate predictable subdomain from session ID
256
+ const cleanSessionId = sessionId.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
257
+ return `https://${cleanSessionId}.liveport.dev`;
258
+ }
259
+
260
+ /**
261
+ * Agent SDK configuration for remote machine
262
+ * This is used by the remote daemon to programmatically control tunnels
263
+ * @param {string} bridgeKey - Bridge key for authentication
264
+ * @param {Object} options - Optional configuration overrides
265
+ * @param {boolean} [options.verbose] - Enable verbose logging on remote machine
266
+ */
267
+ getAgentConfig(bridgeKey, options = {}) {
268
+ return {
269
+ key: bridgeKey,
270
+ autoReconnect: true,
271
+ timeout: options.timeout || 30000,
272
+ reconnectInterval: options.reconnectInterval || 5000,
273
+ maxReconnectAttempts: options.maxReconnectAttempts || 10,
274
+ verbose: options.verbose ?? false,
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Generate TypeScript code for remote agent to use LivePort SDK
280
+ * @param {string} bridgeKey - Bridge key
281
+ * @param {number} port - Local port to expose
282
+ * @param {Object} [options] - Optional configuration
283
+ * @param {boolean} [options.verbose] - Enable verbose logging on remote machine
284
+ */
285
+ generateAgentCode(bridgeKey, port = 3000, options = {}) {
286
+ const config = this.getAgentConfig(bridgeKey, options);
287
+ const verbose = config.verbose;
288
+
289
+ // Helper for conditional logging in generated code
290
+ const log = (msg) => verbose ? `console.log(${msg});` : '// Logging disabled';
291
+ const errorLog = (msg) => `console.error(${msg});`; // Always log errors
292
+
293
+ return `
294
+ import { LivePortAgent } from '@liveport/cli';
295
+
296
+ const VERBOSE = ${verbose};
297
+ const log = (msg) => VERBOSE && console.log(msg);
298
+
299
+ // Initialize LivePort agent with auto-reconnect
300
+ const agent = new LivePortAgent({
301
+ key: "${bridgeKey}",
302
+ autoReconnect: ${config.autoReconnect},
303
+ timeout: ${config.timeout},
304
+ reconnectInterval: ${config.reconnectInterval},
305
+ maxReconnectAttempts: ${config.maxReconnectAttempts}
306
+ });
307
+
308
+ // Reconnection event handlers
309
+ agent.on('reconnecting', (attempt) => {
310
+ log(\`LivePort reconnecting (attempt \${attempt}/\${${config.maxReconnectAttempts}})...\`);
311
+ });
312
+
313
+ agent.on('reconnected', () => {
314
+ log('LivePort reconnected successfully');
315
+ });
316
+
317
+ agent.on('disconnected', (reason) => {
318
+ log(\`LivePort disconnected: \${reason}\`);
319
+ });
320
+
321
+ agent.on('error', (error) => {
322
+ console.error('LivePort error:', error.message);
323
+ });
324
+
325
+ // Main tunnel setup
326
+ async function setupTunnel() {
327
+ try {
328
+ // Wait for tunnel to be established
329
+ const tunnel = await agent.waitForTunnel();
330
+ log(\`Tunnel ready: \${tunnel.url}\`);
331
+
332
+ // Expose local port ${port}
333
+ await agent.createTunnel({
334
+ port: ${port},
335
+ protocol: 'http'
336
+ });
337
+
338
+ log('Successfully exposed port ${port} via LivePort tunnel');
339
+ return tunnel;
340
+ } catch (error) {
341
+ console.error('Failed to setup LivePort tunnel:', error.message);
342
+ process.exit(1);
343
+ }
344
+ }
345
+
346
+ // Setup tunnel and handle lifecycle
347
+ setupTunnel().catch((error) => {
348
+ console.error('Fatal error:', error);
349
+ process.exit(1);
350
+ });
351
+
352
+ // Graceful shutdown
353
+ process.on('SIGINT', async () => {
354
+ log('Shutting down LivePort tunnel...');
355
+ await agent.close();
356
+ process.exit(0);
357
+ });
358
+
359
+ process.on('SIGTERM', async () => {
360
+ log('Received SIGTERM, shutting down...');
361
+ await agent.close();
362
+ process.exit(0);
363
+ });
364
+ `.trim();
365
+ }
366
+
367
+ /**
368
+ * Revoke a bridge key
369
+ * @param {string} keyId - Key ID to revoke
370
+ */
371
+ async revokeBridgeKey(keyId) {
372
+ const validKeyId = this._validateKeyId(keyId);
373
+
374
+ try {
375
+ await this._fetchWithRetry(`${this.apiUrl}/bridge-keys/${validKeyId}`, {
376
+ method: 'DELETE',
377
+ headers: this._headers(),
378
+ });
379
+
380
+ return true;
381
+ } catch (error) {
382
+ // Handle 404 gracefully - key already deleted
383
+ if (error.message.includes('Tunnel not found')) {
384
+ return false;
385
+ }
386
+ throw error;
387
+ }
388
+ }
389
+
390
+ /**
391
+ * List active tunnels for a bridge key
392
+ * @param {string} bridgeKey - Bridge key
393
+ */
394
+ async listTunnels(bridgeKey) {
395
+ const params = new URLSearchParams({ bridgeKey });
396
+
397
+ const response = await this._fetchWithRetry(`${this.apiUrl}/tunnels?${params}`, {
398
+ method: 'GET',
399
+ headers: this._headers(),
400
+ });
401
+
402
+ return response.json();
403
+ }
404
+
405
+ /**
406
+ * Get tunnel status
407
+ * @param {string} tunnelId - Tunnel ID
408
+ */
409
+ async getTunnelStatus(tunnelId) {
410
+ const response = await this._fetchWithRetry(`${this.apiUrl}/tunnels/${tunnelId}`, {
411
+ method: 'GET',
412
+ headers: this._headers(),
413
+ });
414
+
415
+ return response.json();
416
+ }
417
+ }