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,407 @@
1
+ /**
2
+ * Base Provider Interface for Remote Development Environments
3
+ *
4
+ * All remote providers (Fly.io, Daytona, etc.) must implement this interface.
5
+ * This class provides common functionality for:
6
+ * - SSH key management
7
+ * - LivePort tunnel setup
8
+ * - Secret storage and retrieval
9
+ * - Resource cleanup
10
+ *
11
+ * @abstract
12
+ */
13
+
14
+ export class BaseProvider {
15
+ /**
16
+ * Initialize the base provider
17
+ * @param {Object} config - Provider configuration
18
+ * @param {VaultClient} config.vaultClient - Mech Vault client instance
19
+ * @param {LivePortClient} config.livePortClient - LivePort client instance
20
+ * @param {Object} [config.validationRules] - Custom validation rules for provider
21
+ */
22
+ constructor(config) {
23
+ if (!config) {
24
+ throw new Error('Provider config is required');
25
+ }
26
+ if (!config.vaultClient) {
27
+ throw new Error('VaultClient is required in provider config');
28
+ }
29
+ if (!config.livePortClient) {
30
+ throw new Error('LivePortClient is required in provider config');
31
+ }
32
+
33
+ this.config = config;
34
+ this.vaultClient = config.vaultClient;
35
+ this.livePortClient = config.livePortClient;
36
+ this.validationRules = config.validationRules || {};
37
+ }
38
+
39
+ /**
40
+ * Create a remote machine/workspace
41
+ *
42
+ * @abstract
43
+ * @param {Object} sessionConfig - Session configuration
44
+ * @param {string} sessionConfig.sessionId - Unique session identifier
45
+ * @param {string} sessionConfig.repoUrl - Git repository URL
46
+ * @param {string} [sessionConfig.branch] - Git branch to checkout
47
+ * @param {string} sessionConfig.relayApiUrl - Relay API endpoint
48
+ * @param {string} sessionConfig.relayApiKey - Relay API key
49
+ * @param {string} sessionConfig.githubToken - GitHub personal access token
50
+ * @param {string} [sessionConfig.mechApiKey] - Mech API key for router
51
+ * @param {Object} [sessionConfig.additionalSecrets] - Additional secrets to store
52
+ * @returns {Promise<Object>} Machine details
53
+ * @returns {string} return.machineId - Unique machine identifier
54
+ * @returns {string} return.sshHost - SSH hostname
55
+ * @returns {number} return.sshPort - SSH port
56
+ * @returns {string} return.tunnelUrl - LivePort tunnel URL
57
+ * @returns {string} return.namespace - Vault namespace for secrets
58
+ * @returns {string[]} return.secretIds - Array of stored secret IDs
59
+ * @returns {string} return.sshKeyId - SSH key ID in Vault
60
+ * @returns {string} return.bridgeKeyId - LivePort bridge key ID
61
+ * @throws {Error} If machine creation fails
62
+ */
63
+ async createMachine(sessionConfig) {
64
+ throw new Error('createMachine must be implemented');
65
+ }
66
+
67
+ /**
68
+ * Destroy the remote machine/workspace
69
+ *
70
+ * @abstract
71
+ * @param {string} machineId - Machine identifier
72
+ * @returns {Promise<boolean>} True if destroyed successfully
73
+ * @throws {Error} If machine destruction fails
74
+ */
75
+ async destroyMachine(machineId) {
76
+ throw new Error('destroyMachine must be implemented');
77
+ }
78
+
79
+ /**
80
+ * Get machine status
81
+ *
82
+ * @abstract
83
+ * @param {string} machineId - Machine identifier
84
+ * @returns {Promise<Object>} Machine status
85
+ * @returns {string} return.status - Machine status (running, stopped, error)
86
+ * @returns {string} [return.ip] - Machine IP address
87
+ * @returns {string} [return.uptime] - Machine uptime
88
+ * @throws {Error} If status retrieval fails
89
+ */
90
+ async getMachineStatus(machineId) {
91
+ throw new Error('getMachineStatus must be implemented');
92
+ }
93
+
94
+ /**
95
+ * Execute command on remote machine
96
+ *
97
+ * @abstract
98
+ * @param {string} machineId - Machine identifier
99
+ * @param {string} command - Command to execute
100
+ * @returns {Promise<Object>} Command execution result
101
+ * @returns {string} return.stdout - Standard output
102
+ * @returns {string} return.stderr - Standard error
103
+ * @returns {number} return.exitCode - Exit code
104
+ * @throws {Error} If command execution fails
105
+ */
106
+ async executeCommand(machineId, command) {
107
+ throw new Error('executeCommand must be implemented');
108
+ }
109
+
110
+ /**
111
+ * Get machine logs
112
+ *
113
+ * @abstract
114
+ * @param {string} machineId - Machine identifier
115
+ * @param {Object} [options] - Log retrieval options
116
+ * @param {number} [options.tail] - Number of lines to retrieve
117
+ * @param {boolean} [options.follow] - Stream logs continuously
118
+ * @returns {Promise<Object>} Log data
119
+ * @returns {Array<string>} return.logs - Array of log lines
120
+ * @throws {Error} If log retrieval fails
121
+ */
122
+ async getLogs(machineId, options = {}) {
123
+ throw new Error('getLogs must be implemented');
124
+ }
125
+
126
+ /**
127
+ * Validate session configuration before machine creation
128
+ *
129
+ * @protected
130
+ * @param {Object} sessionConfig - Session configuration to validate
131
+ * @throws {Error} If validation fails
132
+ */
133
+ _validateSessionConfig(sessionConfig) {
134
+ if (!sessionConfig) {
135
+ throw new Error('Session config is required');
136
+ }
137
+
138
+ const requiredFields = ['sessionId', 'repoUrl', 'relayApiUrl', 'relayApiKey'];
139
+ for (const field of requiredFields) {
140
+ if (!sessionConfig[field]) {
141
+ throw new Error(`Session config missing required field: ${field}`);
142
+ }
143
+ }
144
+
145
+ // Validate sessionId format
146
+ if (!/^[a-zA-Z0-9_-]+$/.test(sessionConfig.sessionId)) {
147
+ throw new Error('Invalid sessionId: must contain only alphanumeric characters, dashes, and underscores');
148
+ }
149
+
150
+ // Validate repoUrl format and hostname
151
+ this._validateRepoUrl(sessionConfig.repoUrl);
152
+ }
153
+
154
+ /**
155
+ * Validate repository URL format and hostname
156
+ *
157
+ * @protected
158
+ * @param {string} repoUrl - Repository URL to validate
159
+ * @throws {Error} If URL is invalid or hostname not allowed
160
+ */
161
+ _validateRepoUrl(repoUrl) {
162
+ let hostname;
163
+
164
+ try {
165
+ if (repoUrl.startsWith('http://') || repoUrl.startsWith('https://')) {
166
+ // Parse HTTP(S) URLs
167
+ const url = new URL(repoUrl);
168
+ hostname = url.hostname;
169
+ } else if (repoUrl.startsWith('git@')) {
170
+ // Parse git@github.com:user/repo.git format
171
+ const match = repoUrl.match(/^git@([^:]+):(.+)$/);
172
+ if (!match) {
173
+ throw new Error('Invalid git URL format. Expected: git@hostname:user/repo.git');
174
+ }
175
+ hostname = match[1];
176
+ } else {
177
+ throw new Error('Invalid repoUrl: must start with http://, https://, or git@');
178
+ }
179
+
180
+ // Validate against allowed Git hosts
181
+ const allowedHosts = this.validationRules.allowedGitHosts || [
182
+ 'github.com',
183
+ 'gitlab.com',
184
+ 'bitbucket.org',
185
+ 'git.sr.ht', // SourceHut
186
+ 'codeberg.org',
187
+ ];
188
+
189
+ if (!allowedHosts.includes(hostname)) {
190
+ throw new Error(`Git host not allowed: ${hostname}. Allowed hosts: ${allowedHosts.join(', ')}`);
191
+ }
192
+ } catch (error) {
193
+ if (error.message.includes('Invalid URL')) {
194
+ throw new Error(`Invalid repoUrl format: ${error.message}`);
195
+ }
196
+ throw error;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Validate machine ID format
202
+ *
203
+ * @protected
204
+ * @param {string} machineId - Machine identifier to validate
205
+ * @throws {Error} If validation fails
206
+ */
207
+ _validateMachineId(machineId) {
208
+ if (!machineId || typeof machineId !== 'string' || machineId.trim() === '') {
209
+ throw new Error('Invalid machineId: must be a non-empty string');
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Setup SSH access to the machine
215
+ *
216
+ * Generates an ED25519 SSH key pair via Vault and returns the credentials.
217
+ *
218
+ * @protected
219
+ * @param {string} machineId - Machine identifier
220
+ * @param {string} namespace - Vault namespace for the key
221
+ * @returns {Promise<Object>} SSH key details
222
+ * @returns {string} return.keyId - Vault secret ID for the SSH key
223
+ * @returns {string} return.publicKey - SSH public key
224
+ * @returns {string} return.privateKey - SSH private key
225
+ * @returns {string} return.fingerprint - SSH key fingerprint
226
+ * @throws {Error} If SSH key generation fails
227
+ */
228
+ async _setupSSHAccess(machineId, namespace) {
229
+ try {
230
+ // Generate SSH key via Vault
231
+ const sshKey = await this.vaultClient.generateSSHKey('ed25519', {
232
+ namespace,
233
+ name: `machine-${machineId}`,
234
+ metadata: {
235
+ machineId,
236
+ provider: this.constructor.name,
237
+ createdAt: new Date().toISOString(),
238
+ },
239
+ });
240
+
241
+ return {
242
+ keyId: sshKey.secretId,
243
+ publicKey: sshKey.publicKey,
244
+ privateKey: sshKey.privateKey,
245
+ fingerprint: sshKey.fingerprint,
246
+ };
247
+ } catch (error) {
248
+ throw new Error(`Failed to setup SSH access: ${error.message}`);
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Setup LivePort tunnel for the machine
254
+ *
255
+ * Creates a bridge key and generates tunnel configuration for the remote machine.
256
+ *
257
+ * @protected
258
+ * @param {string} sessionId - Session identifier
259
+ * @param {string} machineId - Machine identifier
260
+ * @returns {Promise<Object>} Tunnel details
261
+ * @returns {string} return.bridgeKey - LivePort bridge key
262
+ * @returns {string} return.keyId - Bridge key ID
263
+ * @returns {string} return.tunnelUrl - Public tunnel URL
264
+ * @returns {string} return.tunnelCommand - Command to run on remote machine
265
+ * @throws {Error} If tunnel setup fails
266
+ */
267
+ async _setupTunnel(sessionId, machineId) {
268
+ try {
269
+ // Create bridge key for this session
270
+ const bridgeKey = await this.livePortClient.createBridgeKey({
271
+ name: `session-${sessionId}`,
272
+ metadata: {
273
+ sessionId,
274
+ machineId,
275
+ provider: this.constructor.name,
276
+ },
277
+ });
278
+
279
+ // Get the tunnel command to run on remote machine
280
+ const tunnelCmd = this.livePortClient.getTunnelCommand(bridgeKey.key, {
281
+ port: 3000, // Default port for remote services
282
+ });
283
+
284
+ return {
285
+ bridgeKey: bridgeKey.key,
286
+ keyId: bridgeKey.keyId,
287
+ tunnelUrl: this.livePortClient.getTunnelUrl(sessionId),
288
+ tunnelCommand: tunnelCmd,
289
+ };
290
+ } catch (error) {
291
+ throw new Error(`Failed to setup LivePort tunnel: ${error.message}`);
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Store secrets for the remote machine
297
+ *
298
+ * Stores multiple secrets in Vault under the session namespace.
299
+ *
300
+ * @protected
301
+ * @param {string} namespace - Vault namespace
302
+ * @param {Object} secrets - Key-value pairs of secrets to store
303
+ * @returns {Promise<string[]>} Array of secret IDs
304
+ * @throws {Error} If secret storage fails
305
+ */
306
+ async _storeSecrets(namespace, secrets) {
307
+ const secretIds = [];
308
+
309
+ try {
310
+ for (const [name, value] of Object.entries(secrets)) {
311
+ const result = await this.vaultClient.storeSecret(
312
+ namespace,
313
+ name,
314
+ value,
315
+ {
316
+ description: `Secret for remote session: ${name}`,
317
+ tags: ['teleportation', 'remote'],
318
+ }
319
+ );
320
+
321
+ secretIds.push(result.secretId);
322
+ }
323
+
324
+ return secretIds;
325
+ } catch (error) {
326
+ throw new Error(`Failed to store secrets: ${error.message}`);
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Export environment file for the remote machine
332
+ *
333
+ * Exports all secrets in the namespace as a .env file format.
334
+ *
335
+ * @protected
336
+ * @param {string} namespace - Vault namespace
337
+ * @returns {Promise<string>} Environment file content
338
+ * @throws {Error} If export fails
339
+ */
340
+ async _exportEnvFile(namespace) {
341
+ try {
342
+ return await this.vaultClient.exportEnvFile(namespace, 'env');
343
+ } catch (error) {
344
+ throw new Error(`Failed to export env file: ${error.message}`);
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Cleanup resources after session ends
350
+ *
351
+ * Removes all session resources: bridge keys, SSH keys, and stored secrets.
352
+ * Failures are collected and returned but don't throw to ensure cleanup continues.
353
+ *
354
+ * @protected
355
+ * @param {string} namespace - Vault namespace to clean up
356
+ * @param {string} [bridgeKeyId] - LivePort bridge key ID to revoke
357
+ * @param {string} [sshKeyId] - SSH key ID to delete
358
+ * @returns {Promise<Object>} Cleanup result with success status and errors
359
+ * @returns {boolean} return.success - True if all operations succeeded
360
+ * @returns {string[]} return.errors - Array of error messages (empty if successful)
361
+ */
362
+ async _cleanup(namespace, bridgeKeyId, sshKeyId) {
363
+ const errors = [];
364
+
365
+ // Revoke LivePort bridge key
366
+ if (bridgeKeyId) {
367
+ try {
368
+ await this.livePortClient.revokeBridgeKey(bridgeKeyId);
369
+ } catch (error) {
370
+ errors.push(`Failed to revoke bridge key: ${error.message}`);
371
+ }
372
+ }
373
+
374
+ // Delete SSH key from vault
375
+ if (sshKeyId) {
376
+ try {
377
+ await this.vaultClient.deleteSecret(sshKeyId);
378
+ } catch (error) {
379
+ errors.push(`Failed to delete SSH key: ${error.message}`);
380
+ }
381
+ }
382
+
383
+ // Delete all secrets in the namespace
384
+ try {
385
+ const secrets = await this.vaultClient.listSecrets(namespace);
386
+ for (const secret of secrets.data || []) {
387
+ try {
388
+ await this.vaultClient.deleteSecret(secret.secretId);
389
+ } catch (error) {
390
+ errors.push(`Failed to delete secret ${secret.secretId}: ${error.message}`);
391
+ }
392
+ }
393
+ } catch (error) {
394
+ errors.push(`Failed to list secrets: ${error.message}`);
395
+ }
396
+
397
+ // Log warnings if there were errors
398
+ if (errors.length > 0) {
399
+ console.warn('Cleanup encountered errors:', errors.join('; '));
400
+ }
401
+
402
+ return {
403
+ success: errors.length === 0,
404
+ errors,
405
+ };
406
+ }
407
+ }