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.
@@ -0,0 +1,506 @@
1
+ /**
2
+ * Daytona Provider for Remote Development Environments
3
+ *
4
+ * Uses Daytona API for ephemeral, cloud-native development workspaces.
5
+ * Workspaces can be created from git repos and automatically configured.
6
+ *
7
+ * Ideal for:
8
+ * - Quick tasks (feature branches, bug fixes, PR reviews)
9
+ * - Clean, isolated environments
10
+ * - Auto-stop to save costs when inactive
11
+ * - Snapshot support for state preservation
12
+ *
13
+ * @see https://daytona.io/docs/api
14
+ */
15
+
16
+ import { BaseProvider } from './base-provider.js';
17
+
18
+ export class DaytonaProvider extends BaseProvider {
19
+ /**
20
+ * Initialize DaytonaProvider
21
+ * @param {Object} config - Provider configuration
22
+ * @param {VaultClient} config.vaultClient - Mech Vault client
23
+ * @param {LivePortClient} config.livePortClient - LivePort client
24
+ * @param {string} [config.daytonaApiKey] - Daytona API key (or DAYTONA_API_KEY env var)
25
+ * @param {string} [config.apiUrl] - Daytona API URL (or DAYTONA_API_URL env var, defaults to https://app.daytona.io/api)
26
+ * @param {string} [config.profileId] - Daytona profile ID (or DAYTONA_PROFILE_ID env var, defaults to 'default')
27
+ * @param {string} [config.organizationId] - Daytona organization ID (or DAYTONA_ORGANIZATION_ID env var)
28
+ */
29
+ constructor(config) {
30
+ super(config);
31
+ this.apiKey = config.daytonaApiKey || process.env.DAYTONA_API_KEY;
32
+ this.apiUrl = config.apiUrl || process.env.DAYTONA_API_URL || 'https://app.daytona.io/api';
33
+ this.profileId = config.profileId || process.env.DAYTONA_PROFILE_ID || 'default';
34
+ this.organizationId = config.organizationId || process.env.DAYTONA_ORGANIZATION_ID || '';
35
+ this.timeout = config.timeout || 30000; // 30 second default timeout
36
+
37
+ if (!this.apiKey) {
38
+ throw new Error('DAYTONA_API_KEY is required for DaytonaProvider');
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Create headers for Daytona API
44
+ * @private
45
+ */
46
+ _headers() {
47
+ const headers = {
48
+ 'Content-Type': 'application/json',
49
+ 'Authorization': `Bearer ${this.apiKey}`,
50
+ };
51
+
52
+ if (this.organizationId) {
53
+ headers['X-Daytona-Organization-ID'] = this.organizationId;
54
+ }
55
+
56
+ return headers;
57
+ }
58
+
59
+ async _formatApiError(response, url) {
60
+ const contentType = response.headers?.get?.('content-type') || '';
61
+
62
+ let raw = '';
63
+ try {
64
+ const bodyReader = typeof response.clone === 'function' ? response.clone() : response;
65
+ raw = await bodyReader.text();
66
+ } catch {
67
+ raw = '';
68
+ }
69
+
70
+ const trimmed = raw?.trim?.() || '';
71
+
72
+ const truncated = trimmed.length > 1000
73
+ ? `${trimmed.slice(0, 1000)}…`
74
+ : trimmed;
75
+
76
+ const contentTypeLower = String(contentType).toLowerCase();
77
+ const isHtml = contentTypeLower.includes('text/html')
78
+ || /<\s*!doctype\s+html/i.test(truncated)
79
+ || /<\s*html/i.test(truncated);
80
+ const hint = isHtml
81
+ ? ' (received HTML; check DAYTONA_API_URL and ensure DAYTONA_API_KEY is valid for API access)'
82
+ : '';
83
+
84
+ return `Daytona API request failed: ${response.status} ${response.statusText} (${url})${hint}\n${truncated}`;
85
+ }
86
+
87
+ /**
88
+ * Escape shell argument by wrapping in single quotes and escaping any single quotes
89
+ * @private
90
+ * @param {string} arg - Argument to escape
91
+ * @returns {string} Shell-safe escaped argument
92
+ */
93
+ _escapeShellArg(arg) {
94
+ if (!arg) return "''";
95
+ // Replace any single quotes with '\'' (end quote, escaped quote, start quote)
96
+ return `'${arg.replace(/'/g, "'\\''")}'`;
97
+ }
98
+
99
+ /**
100
+ * Fetch with timeout support
101
+ * @private
102
+ * @param {string} url - URL to fetch
103
+ * @param {Object} options - Fetch options
104
+ * @returns {Promise<Response>} Fetch response
105
+ * @throws {Error} If request times out or fails
106
+ */
107
+ async _fetchWithTimeout(url, options = {}) {
108
+ const controller = new AbortController();
109
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
110
+
111
+ try {
112
+ const response = await fetch(url, {
113
+ ...options,
114
+ signal: controller.signal,
115
+ });
116
+ clearTimeout(timeoutId);
117
+ return response;
118
+ } catch (error) {
119
+ clearTimeout(timeoutId);
120
+ if (error.name === 'AbortError') {
121
+ throw new Error(`Request timed out after ${this.timeout}ms: ${url}`);
122
+ }
123
+ throw error;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Create a remote Daytona workspace
129
+ *
130
+ * Provisions a new Daytona workspace with:
131
+ * - Bun runtime (oven/bun:latest)
132
+ * - Git repository cloned automatically
133
+ * - SSH access configured
134
+ * - LivePort tunnel setup
135
+ * - All secrets stored in Vault
136
+ * - Auto-stop when inactive
137
+ *
138
+ * @param {Object} sessionConfig - Session configuration (see BaseProvider)
139
+ * @returns {Promise<Object>} Workspace details with IDs for cleanup
140
+ * @throws {Error} If workspace provisioning fails
141
+ */
142
+ async createMachine(sessionConfig) {
143
+ const sessionId = sessionConfig.sessionId;
144
+ const namespace = `teleportationremote${String(sessionId).replace(/[^a-zA-Z0-9]/g, '')}`;
145
+
146
+ // 1. Setup SSH access
147
+ const ssh = await this._setupSSHAccess(sessionId, namespace);
148
+
149
+ // 2. Setup LivePort tunnel
150
+ const tunnel = await this._setupTunnel(sessionId, sessionId);
151
+
152
+ // 3. Store necessary secrets in Vault
153
+ const secrets = {
154
+ RELAY_API_URL: sessionConfig.relayApiUrl,
155
+ RELAY_API_KEY: sessionConfig.relayApiKey,
156
+ GITHUB_TOKEN: sessionConfig.githubToken,
157
+ MECH_API_KEY: sessionConfig.mechApiKey,
158
+ LIVEPORT_BRIDGE_KEY: tunnel.bridgeKey,
159
+ ...sessionConfig.additionalSecrets,
160
+ };
161
+
162
+ const secretIds = await this._storeSecrets(namespace, secrets);
163
+
164
+ // 4. Create workspace configuration
165
+ const workspaceConfig = {
166
+ name: `teleportation-${sessionId}`,
167
+ profileId: this.profileId,
168
+ git: {
169
+ repository: sessionConfig.repoUrl,
170
+ branch: sessionConfig.branch || 'main',
171
+ },
172
+ envVars: {
173
+ SESSION_ID: sessionId,
174
+ VAULT_NAMESPACE: namespace,
175
+ MECH_VAULT_URL: this.vaultClient.apiUrl,
176
+ MECH_API_KEY: this.vaultClient.apiKey,
177
+ MECH_APP_ID: this.vaultClient.appId,
178
+ TUNNEL_URL: tunnel.tunnelUrl,
179
+ LIVEPORT_BRIDGE_KEY: tunnel.bridgeKey,
180
+ },
181
+ devcontainer: {
182
+ image: 'oven/bun:latest',
183
+ postCreateCommand: this._generatePostCreateScript(sessionConfig, tunnel, ssh),
184
+ },
185
+ ideConfig: {
186
+ defaultIde: 'none', // No IDE needed, using CLI
187
+ },
188
+ resources: {
189
+ cpus: sessionConfig.cpus || 1,
190
+ memory: sessionConfig.memory || 2048, // MB
191
+ disk: sessionConfig.disk || 10, // GB
192
+ },
193
+ autoStop: {
194
+ enabled: true,
195
+ inactivityMinutes: sessionConfig.autoStopMinutes || 60,
196
+ },
197
+ };
198
+
199
+ const createUrl = `${this.apiUrl}/workspace`;
200
+ const response = await this._fetchWithTimeout(createUrl, {
201
+ method: 'POST',
202
+ headers: this._headers(),
203
+ body: JSON.stringify(workspaceConfig),
204
+ });
205
+
206
+ if (!response.ok) {
207
+ throw new Error(await this._formatApiError(response, createUrl));
208
+ }
209
+
210
+ const workspace = await response.json();
211
+
212
+ return {
213
+ machineId: workspace.id,
214
+ workspaceId: workspace.id,
215
+ sshHost: workspace.sshHost || `${workspace.id}.daytona.io`,
216
+ sshPort: workspace.sshPort || 22,
217
+ tunnelUrl: tunnel.tunnelUrl,
218
+ namespace,
219
+ secretIds,
220
+ sshKeyId: ssh.keyId,
221
+ bridgeKeyId: tunnel.keyId,
222
+ ideUrl: workspace.ideUrl,
223
+ };
224
+ }
225
+
226
+ /**
227
+ * Generate post-create script for Daytona workspace
228
+ *
229
+ * This script runs after the workspace is created and sets up:
230
+ * - SSH authorized keys
231
+ * - Teleportation CLI installation
232
+ * - LivePort tunnel
233
+ * - Teleportation daemon
234
+ *
235
+ * @private
236
+ * @param {Object} sessionConfig - Session configuration
237
+ * @param {Object} tunnel - Tunnel configuration with tunnelCommand
238
+ * @param {Object} ssh - SSH key with publicKey
239
+ * @returns {string} Shell script for workspace initialization
240
+ */
241
+ _generatePostCreateScript(sessionConfig, tunnel, ssh) {
242
+ // Escape all user-provided values to prevent command injection
243
+ const safePublicKey = this._escapeShellArg(ssh.publicKey);
244
+ const safeSessionId = this._escapeShellArg(sessionConfig.sessionId);
245
+ const safeTask = this._escapeShellArg(sessionConfig.task || '');
246
+
247
+ return `
248
+ set -e
249
+
250
+ # Setup SSH key
251
+ mkdir -p ~/.ssh
252
+ echo ${safePublicKey} >> ~/.ssh/authorized_keys
253
+ chmod 600 ~/.ssh/authorized_keys
254
+
255
+ REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
256
+ cd "$REPO_ROOT"
257
+
258
+ # Install repo dependencies
259
+ bun install
260
+
261
+ # Start LivePort tunnel in background
262
+ ${tunnel.tunnelCommand} &
263
+
264
+ TUNNEL_PID=$!
265
+
266
+ # Wait briefly for the tunnel process to stay alive (avoid blind sleep)
267
+ for i in 1 2 3 4 5; do
268
+ if ! kill -0 "$TUNNEL_PID" >/dev/null 2>&1; then
269
+ echo "LivePort tunnel failed to start" >&2
270
+ exit 1
271
+ fi
272
+ sleep 1
273
+ done
274
+
275
+ # Start teleportation daemon
276
+ bun teleportation-cli.cjs daemon start --session-id ${safeSessionId} --task ${safeTask}
277
+ `.trim();
278
+ }
279
+
280
+ /**
281
+ * Destroy the Daytona workspace
282
+ *
283
+ * Permanently deletes the workspace and all its data.
284
+ * This action cannot be undone.
285
+ *
286
+ * @param {string} workspaceId - Daytona workspace ID
287
+ * @returns {Promise<boolean>} True if destroyed successfully
288
+ * @throws {Error} If workspace deletion fails
289
+ */
290
+ async destroyMachine(workspaceId) {
291
+ this._validateMachineId(workspaceId);
292
+ const url = `${this.apiUrl}/workspace/${workspaceId}`;
293
+ const response = await this._fetchWithTimeout(url, {
294
+ method: 'DELETE',
295
+ headers: this._headers(),
296
+ });
297
+
298
+ if (!response.ok) {
299
+ throw new Error(await this._formatApiError(response, url));
300
+ }
301
+
302
+ return true;
303
+ }
304
+
305
+ /**
306
+ * Get workspace status
307
+ *
308
+ * Returns current state of the Daytona workspace including auto-stop countdown.
309
+ *
310
+ * @param {string} workspaceId - Daytona workspace ID
311
+ * @returns {Promise<Object>} Workspace status
312
+ * @returns {string} return.status - Workspace state (running, stopped, error)
313
+ * @returns {number} return.uptime - Uptime in seconds
314
+ * @returns {string} return.lastActivity - Last activity timestamp
315
+ * @returns {number} return.autoStopIn - Seconds until auto-stop
316
+ * @returns {Object} return.resources - Resource allocation (cpus, memory)
317
+ * @throws {Error} If status fetch fails
318
+ */
319
+ async getMachineStatus(workspaceId) {
320
+ this._validateMachineId(workspaceId);
321
+ const url = `${this.apiUrl}/workspace/${workspaceId}`;
322
+ const response = await this._fetchWithTimeout(url, {
323
+ method: 'GET',
324
+ headers: this._headers(),
325
+ });
326
+
327
+ if (!response.ok) {
328
+ throw new Error(await this._formatApiError(response, url));
329
+ }
330
+
331
+ const workspace = await response.json();
332
+
333
+ return {
334
+ status: workspace.state || workspace.status,
335
+ uptime: workspace.uptime,
336
+ lastActivity: workspace.lastActivity,
337
+ autoStopIn: workspace.autoStopIn,
338
+ resources: workspace.resources,
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Execute command in workspace
344
+ *
345
+ * Runs a command directly in the workspace via Daytona exec API.
346
+ *
347
+ * @param {string} workspaceId - Daytona workspace ID
348
+ * @param {string} command - Command to execute
349
+ * @returns {Promise<Object>} Command result
350
+ * @returns {string} return.stdout - Standard output
351
+ * @returns {string} return.stderr - Standard error
352
+ * @returns {number} return.exitCode - Exit code
353
+ * @throws {Error} If command execution fails
354
+ */
355
+ async executeCommand(workspaceId, command) {
356
+ this._validateMachineId(workspaceId);
357
+ const url = `${this.apiUrl}/toolbox/${workspaceId}/toolbox/process/execute`;
358
+ const response = await this._fetchWithTimeout(url, {
359
+ method: 'POST',
360
+ headers: this._headers(),
361
+ body: JSON.stringify({ command }),
362
+ });
363
+
364
+ if (!response.ok) {
365
+ throw new Error(await this._formatApiError(response, url));
366
+ }
367
+
368
+ return response.json();
369
+ }
370
+
371
+ /**
372
+ * Get workspace logs
373
+ *
374
+ * Retrieves logs from the workspace's processes.
375
+ *
376
+ * @param {string} workspaceId - Daytona workspace ID
377
+ * @param {Object} [options] - Log retrieval options
378
+ * @param {number} [options.tail] - Number of recent lines to retrieve (default: 100)
379
+ * @param {boolean} [options.follow] - Stream logs continuously (default: false)
380
+ * @returns {Promise<Object>} Log data
381
+ * @returns {Array<string>} return.logs - Array of log lines
382
+ * @throws {Error} If log retrieval fails
383
+ */
384
+ async getLogs(workspaceId, options = {}) {
385
+ this._validateMachineId(workspaceId);
386
+ const params = new URLSearchParams({
387
+ tail: options.tail || 100,
388
+ follow: options.follow || false,
389
+ });
390
+
391
+ const url = `${this.apiUrl}/workspace/${workspaceId}/logs?${params}`;
392
+ const response = await this._fetchWithTimeout(url, {
393
+ method: 'GET',
394
+ headers: this._headers(),
395
+ });
396
+
397
+ if (!response.ok) {
398
+ throw new Error(await this._formatApiError(response, url));
399
+ }
400
+
401
+ return response.json();
402
+ }
403
+
404
+ /**
405
+ * Stop workspace (keeps it for later restart)
406
+ *
407
+ * Stops the workspace to save costs. Workspace state is preserved.
408
+ *
409
+ * @param {string} workspaceId - Daytona workspace ID
410
+ * @returns {Promise<boolean>} True if stopped successfully
411
+ * @throws {Error} If stop fails
412
+ */
413
+ async stopMachine(workspaceId) {
414
+ this._validateMachineId(workspaceId);
415
+ const url = `${this.apiUrl}/workspace/${workspaceId}/stop`;
416
+ const response = await this._fetchWithTimeout(url, {
417
+ method: 'POST',
418
+ headers: this._headers(),
419
+ });
420
+
421
+ if (!response.ok) {
422
+ throw new Error(await this._formatApiError(response, url));
423
+ }
424
+
425
+ return true;
426
+ }
427
+
428
+ /**
429
+ * Start stopped workspace
430
+ *
431
+ * Resumes a stopped workspace. State is preserved from when it was stopped.
432
+ *
433
+ * @param {string} workspaceId - Daytona workspace ID
434
+ * @returns {Promise<boolean>} True if started successfully
435
+ * @throws {Error} If start fails
436
+ */
437
+ async startMachine(workspaceId) {
438
+ this._validateMachineId(workspaceId);
439
+ const url = `${this.apiUrl}/workspace/${workspaceId}/start`;
440
+ const response = await this._fetchWithTimeout(url, {
441
+ method: 'POST',
442
+ headers: this._headers(),
443
+ });
444
+
445
+ if (!response.ok) {
446
+ throw new Error(await this._formatApiError(response, url));
447
+ }
448
+
449
+ return true;
450
+ }
451
+
452
+ /**
453
+ * Extend auto-stop timeout
454
+ *
455
+ * Adds additional time before the workspace auto-stops due to inactivity.
456
+ *
457
+ * @param {string} workspaceId - Daytona workspace ID
458
+ * @param {number} additionalMinutes - Minutes to add to auto-stop timer
459
+ * @returns {Promise<Object>} Extended auto-stop information
460
+ * @throws {Error} If extension fails
461
+ */
462
+ async extendAutoStop(workspaceId, additionalMinutes) {
463
+ this._validateMachineId(workspaceId);
464
+ const url = `${this.apiUrl}/workspace/${workspaceId}/extend`;
465
+ const response = await this._fetchWithTimeout(url, {
466
+ method: 'POST',
467
+ headers: this._headers(),
468
+ body: JSON.stringify({
469
+ additionalMinutes,
470
+ }),
471
+ });
472
+
473
+ if (!response.ok) {
474
+ throw new Error(await this._formatApiError(response, url));
475
+ }
476
+
477
+ return response.json();
478
+ }
479
+
480
+ /**
481
+ * Create snapshot of workspace
482
+ *
483
+ * Creates a snapshot of the workspace's current state.
484
+ * Snapshots can be used to restore workspace to a previous state.
485
+ *
486
+ * @param {string} workspaceId - Daytona workspace ID
487
+ * @param {string} name - Snapshot name
488
+ * @returns {Promise<Object>} Snapshot details
489
+ * @throws {Error} If snapshot creation fails
490
+ */
491
+ async createSnapshot(workspaceId, name) {
492
+ this._validateMachineId(workspaceId);
493
+ const url = `${this.apiUrl}/workspace/${workspaceId}/snapshots`;
494
+ const response = await this._fetchWithTimeout(url, {
495
+ method: 'POST',
496
+ headers: this._headers(),
497
+ body: JSON.stringify({ name }),
498
+ });
499
+
500
+ if (!response.ok) {
501
+ throw new Error(await this._formatApiError(response, url));
502
+ }
503
+
504
+ return response.json();
505
+ }
506
+ }