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,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
|
+
}
|