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.
- 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/stop.mjs +205 -1
- package/.claude/hooks/user_prompt_submit.mjs +111 -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/daemon/teleportation-daemon.js +131 -41
- package/lib/install/installer.js +22 -7
- package/lib/machine-coders/claude-code-adapter.js +191 -37
- 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,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
|
+
}
|