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,228 @@
1
+ /**
2
+ * Provider Factory for Intelligent Provider Selection
3
+ *
4
+ * Automatically selects the best cloud provider (Fly.io or Daytona) based on:
5
+ * - Task description analysis
6
+ * - Estimated duration
7
+ * - Keyword detection
8
+ * - User preferences
9
+ */
10
+
11
+ import { FlyProvider } from './fly-provider.js';
12
+ import { DaytonaProvider } from './daytona-provider.js';
13
+
14
+ export class ProviderFactory {
15
+ /**
16
+ * Initialize ProviderFactory
17
+ * @param {Object} config - Factory configuration
18
+ * @param {VaultClient} config.vaultClient - Mech Vault client (required)
19
+ * @param {LivePortClient} config.livePortClient - LivePort client (required)
20
+ * @param {string} [config.flyApiToken] - Fly.io API token
21
+ * @param {string} [config.daytonaApiKey] - Daytona API key
22
+ * @param {string} [config.defaultProvider] - Default provider ('fly' or 'daytona')
23
+ */
24
+ constructor(config = {}) {
25
+ if (!config.vaultClient) {
26
+ throw new Error('VaultClient is required');
27
+ }
28
+ if (!config.livePortClient) {
29
+ throw new Error('LivePortClient is required');
30
+ }
31
+
32
+ this.config = config;
33
+ this.vaultClient = config.vaultClient;
34
+ this.livePortClient = config.livePortClient;
35
+ this.defaultProvider = config.defaultProvider || 'daytona';
36
+
37
+ // Keywords that suggest long-running tasks (prefer Fly)
38
+ this.flyKeywords = [
39
+ 'overnight', 'multi-day', 'several days', 'long-running',
40
+ 'migration', 'migrate', 'load test', 'stress test', 'benchmark',
41
+ 'hours', 'days', 'extended', 'persistent',
42
+ ];
43
+
44
+ // Keywords that suggest quick tasks (prefer Daytona)
45
+ this.daytonaKeywords = [
46
+ 'quick', 'fast', 'review', 'pr', 'pull request',
47
+ 'feature', 'branch', 'bug fix', 'hotfix',
48
+ 'test', 'experiment', 'prototype', 'spike',
49
+ ];
50
+ }
51
+
52
+ /**
53
+ * Select the best provider based on task description
54
+ *
55
+ * Decision logic:
56
+ * 1. If provider explicitly specified in options, use it
57
+ * 2. Analyze task description for duration indicators
58
+ * 3. Match keywords to provider preferences
59
+ * 4. Default to Daytona for quick, ephemeral tasks
60
+ *
61
+ * @param {string} taskDescription - Description of the task to perform
62
+ * @param {Object} [options] - Selection options
63
+ * @param {string} [options.provider] - Force specific provider ('fly' or 'daytona')
64
+ * @returns {string} Selected provider type ('fly' or 'daytona')
65
+ */
66
+ selectProvider(taskDescription, options = {}) {
67
+ // 1. Explicit override takes precedence
68
+ if (options.provider) {
69
+ return options.provider;
70
+ }
71
+
72
+ // 2. Estimate task duration
73
+ const duration = this._estimateDuration(taskDescription);
74
+
75
+ // 3. Analyze keywords
76
+ const keywords = this._analyzeKeywords(taskDescription);
77
+
78
+ // 4. Decision logic
79
+ if (duration === 'long') {
80
+ return 'fly';
81
+ }
82
+
83
+ // Check for Fly-specific keywords
84
+ const hasFlyKeyword = this.flyKeywords.some(keyword =>
85
+ taskDescription.toLowerCase().includes(keyword)
86
+ );
87
+
88
+ if (hasFlyKeyword) {
89
+ return 'fly';
90
+ }
91
+
92
+ // Default to Daytona for quick, ephemeral tasks
93
+ return 'daytona';
94
+ }
95
+
96
+ /**
97
+ * Create a provider instance of the specified type
98
+ *
99
+ * @param {string} type - Provider type ('fly' or 'daytona')
100
+ * @param {Object} [providerConfig] - Provider-specific configuration
101
+ * @returns {FlyProvider|DaytonaProvider} Provider instance
102
+ * @throws {Error} If provider type is unknown
103
+ */
104
+ createProvider(type, providerConfig = {}) {
105
+ const baseConfig = {
106
+ vaultClient: this.vaultClient,
107
+ livePortClient: this.livePortClient,
108
+ };
109
+
110
+ switch (type) {
111
+ case 'fly':
112
+ return new FlyProvider({
113
+ ...baseConfig,
114
+ flyApiToken: this.config.flyApiToken,
115
+ ...providerConfig,
116
+ });
117
+
118
+ case 'daytona':
119
+ return new DaytonaProvider({
120
+ ...baseConfig,
121
+ daytonaApiKey: this.config.daytonaApiKey,
122
+ ...providerConfig,
123
+ });
124
+
125
+ default:
126
+ throw new Error(`Unknown provider type: ${type}`);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Select provider and create instance in one step
132
+ *
133
+ * Convenience method that combines selectProvider() and createProvider().
134
+ *
135
+ * @param {string} taskDescription - Description of the task to perform
136
+ * @param {Object} [options] - Selection and provider options
137
+ * @param {string} [options.provider] - Force specific provider ('fly' or 'daytona')
138
+ * @returns {FlyProvider|DaytonaProvider} Configured provider instance
139
+ */
140
+ selectAndCreate(taskDescription, options = {}) {
141
+ const providerType = this.selectProvider(taskDescription, options);
142
+
143
+ // Separate provider override from provider-specific config
144
+ const { provider, ...providerConfig } = options;
145
+
146
+ return this.createProvider(providerType, providerConfig);
147
+ }
148
+
149
+ /**
150
+ * Estimate task duration from description
151
+ *
152
+ * @private
153
+ * @param {string} taskDescription - Task description
154
+ * @returns {string} Duration estimate ('short' or 'long')
155
+ */
156
+ _estimateDuration(taskDescription) {
157
+ const lowerDesc = taskDescription.toLowerCase();
158
+
159
+ // Long duration indicators
160
+ const longIndicators = [
161
+ 'overnight', 'multi-day', 'several days', 'multiple days',
162
+ 'long-running', 'extended', 'hours', 'days',
163
+ 'migration', 'refactor', 'refactoring',
164
+ ];
165
+
166
+ for (const indicator of longIndicators) {
167
+ if (lowerDesc.includes(indicator)) {
168
+ return 'long';
169
+ }
170
+ }
171
+
172
+ // Short duration by default
173
+ return 'short';
174
+ }
175
+
176
+ /**
177
+ * Analyze task description for provider-specific keywords
178
+ *
179
+ * @private
180
+ * @param {string} taskDescription - Task description
181
+ * @returns {string[]} Array of matched keywords
182
+ */
183
+ _analyzeKeywords(taskDescription) {
184
+ const lowerDesc = taskDescription.toLowerCase();
185
+ const matched = [];
186
+
187
+ // Check Fly keywords
188
+ for (const keyword of this.flyKeywords) {
189
+ if (lowerDesc.includes(keyword)) {
190
+ matched.push(keyword);
191
+ }
192
+ }
193
+
194
+ // Check Daytona keywords
195
+ for (const keyword of this.daytonaKeywords) {
196
+ if (lowerDesc.includes(keyword)) {
197
+ matched.push(keyword);
198
+ }
199
+ }
200
+
201
+ return matched;
202
+ }
203
+
204
+ /**
205
+ * Get default configuration for a provider type
206
+ *
207
+ * @param {string} type - Provider type ('fly' or 'daytona')
208
+ * @returns {Object} Default configuration for the provider
209
+ */
210
+ getDefaultProviderConfig(type) {
211
+ switch (type) {
212
+ case 'fly':
213
+ return {
214
+ region: 'iad', // Ashburn, VA
215
+ appName: 'teleportation-remote',
216
+ };
217
+
218
+ case 'daytona':
219
+ return {
220
+ profileId: 'default',
221
+ autoStopMinutes: 60,
222
+ };
223
+
224
+ default:
225
+ return {};
226
+ }
227
+ }
228
+ }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Results Delivery - Remote Session Results Handler
3
+ *
4
+ * Delivers results from remote sessions back to local repository using
5
+ * a fallback strategy:
6
+ * 1. Try to create Pull Request (preferred)
7
+ * 2. Fallback to pushing to a local branch
8
+ * 3. Handles conflict detection and resolution
9
+ */
10
+
11
+ import { GitHandoff } from '../handoff/git-handoff.js';
12
+ import { PRCreator } from './pr-creator.js';
13
+
14
+ /**
15
+ * ResultsDelivery class
16
+ */
17
+ export class ResultsDelivery {
18
+ /**
19
+ * @param {Object} options
20
+ * @param {string} options.repoPath - Path to git repository
21
+ * @param {string} options.sessionId - Session ID
22
+ * @param {string} options.remoteBranch - Remote branch with results (e.g., teleport/wip-session-123)
23
+ * @param {boolean} [options.verbose] - Enable verbose logging
24
+ */
25
+ constructor(options) {
26
+ if (!options || !options.repoPath) {
27
+ throw new Error('repoPath is required');
28
+ }
29
+
30
+ if (!options.sessionId) {
31
+ throw new Error('sessionId is required');
32
+ }
33
+
34
+ if (!options.remoteBranch) {
35
+ throw new Error('remoteBranch is required');
36
+ }
37
+
38
+ this.repoPath = options.repoPath;
39
+ this.sessionId = options.sessionId;
40
+ this.remoteBranch = options.remoteBranch;
41
+ this.verbose = options.verbose || false;
42
+
43
+ // Create GitHandoff for git operations
44
+ this.gitHandoff = options.gitHandoff || new GitHandoff({
45
+ repoPath: this.repoPath,
46
+ sessionId: this.sessionId,
47
+ verbose: this.verbose,
48
+ });
49
+
50
+ // Create PRCreator for GitHub PR operations
51
+ this.prCreator = options.prCreator || new PRCreator({
52
+ repoPath: this.repoPath,
53
+ verbose: this.verbose,
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Execute git command via GitHandoff
59
+ * @private
60
+ * @param {string} command - Git command (without 'git' prefix)
61
+ * @returns {Promise<string>} Command output
62
+ */
63
+ async _git(command) {
64
+ return this.gitHandoff.git(command);
65
+ }
66
+
67
+ /**
68
+ * Detect conflicts with remote branch
69
+ *
70
+ * Performs a dry-run merge to detect if merging the remote branch
71
+ * would cause conflicts. Uses try-finally pattern to ensure cleanup
72
+ * even if process is interrupted.
73
+ *
74
+ * @returns {Promise<Object>} Conflict details
75
+ * @returns {boolean} return.hasConflicts - Whether conflicts exist
76
+ * @returns {string[]} return.conflictFiles - Array of conflicting files
77
+ * @throws {Error} If conflict detection fails
78
+ */
79
+ async detectConflicts() {
80
+ let mergeStarted = false;
81
+
82
+ try {
83
+ // Fetch remote branch
84
+ await this._git(`fetch origin ${this.remoteBranch}`);
85
+
86
+ // Try merge with --no-commit --no-ff to simulate merge without actually committing
87
+ try {
88
+ await this._git(`merge --no-commit --no-ff origin/${this.remoteBranch}`);
89
+ mergeStarted = true;
90
+
91
+ // No conflicts - merge succeeded
92
+ return {
93
+ hasConflicts: false,
94
+ conflictFiles: [],
95
+ };
96
+ } catch (mergeError) {
97
+ mergeStarted = true;
98
+
99
+ // Check if this is a conflict error
100
+ const status = await this._git('status --porcelain');
101
+
102
+ const conflictFiles = status
103
+ .split('\n')
104
+ .filter(line => {
105
+ // Conflict markers: UU (both modified), AA (both added), etc.
106
+ return line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD') ||
107
+ line.startsWith('AU') || line.startsWith('UA') || line.startsWith('DU') ||
108
+ line.startsWith('UD');
109
+ })
110
+ .map(line => line.slice(3).trim());
111
+
112
+ if (conflictFiles.length > 0) {
113
+ return {
114
+ hasConflicts: true,
115
+ conflictFiles,
116
+ };
117
+ }
118
+
119
+ // Some other merge error (not a conflict)
120
+ throw mergeError;
121
+ }
122
+ } catch (error) {
123
+ throw new Error(`Failed to detect conflicts: ${error?.message || error}`);
124
+ } finally {
125
+ // Always attempt to abort merge, even on success or error
126
+ // This ensures repo is never left in merge state
127
+ if (mergeStarted) {
128
+ try {
129
+ await this._git('merge --abort');
130
+ if (this.verbose) {
131
+ console.log('[results-delivery] Merge aborted, repo clean');
132
+ }
133
+ } catch (abortError) {
134
+ // Log warning but don't fail - repo might already be clean
135
+ if (this.verbose) {
136
+ console.warn(`[results-delivery] Merge abort warning: ${abortError?.message || abortError}`);
137
+ }
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Push remote branch to a new local branch
145
+ *
146
+ * @param {Object} [options]
147
+ * @param {string} [options.localBranch] - Local branch name (defaults to remote-results/{sessionId})
148
+ * @returns {Promise<Object>} Push result
149
+ * @returns {string} return.branch - Local branch name
150
+ * @returns {string} return.commit - Commit hash
151
+ * @returns {string} return.strategy - Always 'branch'
152
+ * @throws {Error} If branch creation fails
153
+ */
154
+ async pushToBranch(options = {}) {
155
+ const { localBranch = `remote-results/${this.sessionId}` } = options;
156
+
157
+ try {
158
+ // Fetch remote branch
159
+ await this._git(`fetch origin ${this.remoteBranch}`);
160
+
161
+ // Create local branch from remote branch
162
+ await this._git(`branch ${localBranch} origin/${this.remoteBranch}`);
163
+
164
+ // Get commit hash
165
+ const commit = await this._git(`rev-parse ${localBranch}`);
166
+
167
+ if (this.verbose) {
168
+ console.log(`[results-delivery] Created local branch: ${localBranch}`);
169
+ }
170
+
171
+ return {
172
+ branch: localBranch,
173
+ commit: commit.trim(),
174
+ strategy: 'branch',
175
+ };
176
+ } catch (error) {
177
+ throw new Error(`Failed to push to branch: ${error?.message || error}`);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Create pull request with results
183
+ *
184
+ * @param {Object} options
185
+ * @param {string} options.task - Task description
186
+ * @param {string} [options.baseBranch] - Base branch (defaults to current branch)
187
+ * @param {string} [options.timelineUrl] - Timeline URL
188
+ * @param {Object} [options.conflicts] - Conflict details
189
+ * @param {boolean} [options.conflicts.hasConflicts] - Whether conflicts exist
190
+ * @param {string[]} [options.conflicts.conflictFiles] - Conflicting files
191
+ * @param {Object} [options.metadata] - Session metadata
192
+ * @returns {Promise<Object>} PR result
193
+ * @returns {number} return.prNumber - PR number
194
+ * @returns {string} return.prUrl - PR URL
195
+ * @returns {string} return.strategy - Always 'pr'
196
+ * @throws {Error} If PR creation fails
197
+ */
198
+ async createPullRequest(options) {
199
+ const { task, baseBranch, timelineUrl, conflicts, metadata } = options;
200
+
201
+ try {
202
+ // Fetch remote branch to ensure it exists
203
+ await this._git(`fetch origin ${this.remoteBranch}`);
204
+
205
+ // Get current branch as base if not specified
206
+ const base = baseBranch || await this.gitHandoff.getCurrentBranch();
207
+
208
+ // Generate PR title and body
209
+ const title = this.prCreator.generatePRTitle({
210
+ sessionId: this.sessionId,
211
+ task,
212
+ hasConflicts: conflicts?.hasConflicts || false,
213
+ });
214
+
215
+ const body = this.prCreator.generatePRBody({
216
+ task,
217
+ sessionId: this.sessionId,
218
+ timelineUrl,
219
+ conflicts,
220
+ metadata,
221
+ });
222
+
223
+ // Create PR
224
+ const pr = await this.prCreator.createPR({
225
+ head: this.remoteBranch,
226
+ base,
227
+ title,
228
+ body,
229
+ });
230
+
231
+ if (this.verbose) {
232
+ console.log(`[results-delivery] Created PR #${pr.number}: ${pr.url}`);
233
+ }
234
+
235
+ return {
236
+ prNumber: pr.number,
237
+ prUrl: pr.url,
238
+ strategy: 'pr',
239
+ };
240
+ } catch (error) {
241
+ throw new Error(`Failed to create pull request: ${error?.message || error}`);
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Deliver results using fallback strategy
247
+ *
248
+ * Strategy:
249
+ * 1. Detect conflicts first
250
+ * 2. If conflicts exist, always create PR (never auto-merge conflicts)
251
+ * 3. If no conflicts, try PR creation
252
+ * 4. If PR creation fails, fallback to branch creation
253
+ *
254
+ * @param {Object} options
255
+ * @param {string} options.task - Task description
256
+ * @param {string} [options.baseBranch] - Base branch for PR
257
+ * @param {string} [options.timelineUrl] - Timeline URL
258
+ * @param {Object} [options.metadata] - Session metadata
259
+ * @returns {Promise<Object>} Delivery result
260
+ * @returns {boolean} return.success - Whether delivery succeeded
261
+ * @returns {string} return.strategy - Strategy used ('pr' or 'branch')
262
+ * @returns {number} [return.prNumber] - PR number (if strategy='pr')
263
+ * @returns {string} [return.prUrl] - PR URL (if strategy='pr')
264
+ * @returns {string} [return.branch] - Branch name (if strategy='branch')
265
+ * @returns {string} [return.commit] - Commit hash (if strategy='branch')
266
+ * @returns {Object} [return.conflicts] - Conflict details (if conflicts detected)
267
+ * @returns {Object} [return.metadata] - Session metadata
268
+ * @throws {Error} If all strategies fail
269
+ */
270
+ async deliverResults(options) {
271
+ const { task, baseBranch, timelineUrl, metadata } = options;
272
+
273
+ try {
274
+ // Step 1: Detect conflicts
275
+ const conflicts = await this.detectConflicts();
276
+
277
+ if (this.verbose && conflicts.hasConflicts) {
278
+ console.log(`[results-delivery] Conflicts detected: ${conflicts.conflictFiles.join(', ')}`);
279
+ }
280
+
281
+ // Step 2: Try PR creation (preferred)
282
+ try {
283
+ const prResult = await this.createPullRequest({
284
+ task,
285
+ baseBranch,
286
+ timelineUrl,
287
+ conflicts: conflicts.hasConflicts ? conflicts : undefined,
288
+ metadata,
289
+ });
290
+
291
+ return {
292
+ success: true,
293
+ strategy: 'pr',
294
+ prNumber: prResult.prNumber,
295
+ prUrl: prResult.prUrl,
296
+ conflicts: conflicts.hasConflicts ? conflicts : undefined,
297
+ metadata,
298
+ };
299
+ } catch (prError) {
300
+ if (this.verbose) {
301
+ console.log(`[results-delivery] PR creation failed: ${prError.message}`);
302
+ }
303
+
304
+ // Step 3: Fallback to branch creation
305
+ try {
306
+ const branchResult = await this.pushToBranch();
307
+
308
+ if (this.verbose) {
309
+ console.log(`[results-delivery] Created local branch: ${branchResult.branch}`);
310
+ }
311
+
312
+ return {
313
+ success: true,
314
+ strategy: 'branch',
315
+ branch: branchResult.branch,
316
+ commit: branchResult.commit,
317
+ conflicts: conflicts.hasConflicts ? conflicts : undefined,
318
+ metadata,
319
+ };
320
+ } catch (branchError) {
321
+ // Both strategies failed
322
+ throw new Error(
323
+ `Failed to deliver results. PR creation: ${prError.message}. Branch creation: ${branchError.message}`
324
+ );
325
+ }
326
+ }
327
+ } catch (error) {
328
+ throw new Error(`Failed to deliver results: ${error?.message || error}`);
329
+ }
330
+ }
331
+ }
332
+
333
+ export default ResultsDelivery;