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,273 @@
1
+ /**
2
+ * RemoteSessionManager
3
+ *
4
+ * Manages remote session tracking and metadata:
5
+ * - Register/unregister sessions
6
+ * - Update session metadata
7
+ * - List and query sessions
8
+ * - Cleanup stale sessions
9
+ * - Session statistics
10
+ *
11
+ * Extends existing session registry pattern from lib/session-registry/
12
+ */
13
+
14
+ import { readFile, writeFile, mkdir } from 'fs/promises';
15
+ import { join } from 'path';
16
+ import { existsSync } from 'fs';
17
+ import { homedir } from 'os';
18
+
19
+ const DEFAULT_REGISTRY_PATH = join(homedir(), '.teleportation', 'remote-sessions');
20
+ const REGISTRY_FILE = 'sessions.json';
21
+ const DEFAULT_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
22
+
23
+ /**
24
+ * Remote session entry structure
25
+ * @typedef {Object} RemoteSessionEntry
26
+ * @property {string} id - Session ID
27
+ * @property {string} provider - Provider type (fly, daytona)
28
+ * @property {string} machineId - Remote machine ID
29
+ * @property {string} branch - Git branch
30
+ * @property {string} status - Session status (running, paused, completed, failed)
31
+ * @property {number} createdAt - Creation timestamp
32
+ * @property {number} lastActiveAt - Last activity timestamp
33
+ * @property {Object} metadata - Additional metadata (task, tunnelUrl, etc.)
34
+ */
35
+
36
+ /**
37
+ * RemoteSessionManager class
38
+ */
39
+ export class RemoteSessionManager {
40
+ /**
41
+ * @param {Object} options
42
+ * @param {string} [options.registryPath] - Custom registry directory path
43
+ */
44
+ constructor(options = {}) {
45
+ this.registryPath = options.registryPath || DEFAULT_REGISTRY_PATH;
46
+ this.registryFile = join(this.registryPath, REGISTRY_FILE);
47
+ }
48
+
49
+ /**
50
+ * Load the session registry
51
+ * @private
52
+ * @returns {Promise<Array<RemoteSessionEntry>>}
53
+ */
54
+ async _loadRegistry() {
55
+ await mkdir(this.registryPath, { recursive: true, mode: 0o700 });
56
+
57
+ if (!existsSync(this.registryFile)) {
58
+ return [];
59
+ }
60
+
61
+ try {
62
+ const content = await readFile(this.registryFile, 'utf8');
63
+ return JSON.parse(content);
64
+ } catch (error) {
65
+ console.error(`[RemoteSessionManager] Failed to load registry: ${error.message}`);
66
+ return [];
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Save the session registry
72
+ * @private
73
+ * @param {Array<RemoteSessionEntry>} sessions
74
+ * @returns {Promise<void>}
75
+ */
76
+ async _saveRegistry(sessions) {
77
+ await mkdir(this.registryPath, { recursive: true, mode: 0o700 });
78
+ await writeFile(this.registryFile, JSON.stringify(sessions, null, 2), { mode: 0o600 });
79
+ }
80
+
81
+ /**
82
+ * Register a new session
83
+ * @param {Object} options
84
+ * @param {string} options.id - Session ID
85
+ * @param {string} options.provider - Provider type (fly, daytona)
86
+ * @param {string} options.machineId - Remote machine ID
87
+ * @param {string} options.branch - Git branch
88
+ * @param {Object} [options.metadata] - Additional metadata
89
+ * @param {string} [options.status] - Initial status (default: running)
90
+ * @returns {Promise<RemoteSessionEntry>}
91
+ * @throws {Error} If session already exists or required fields are missing
92
+ *
93
+ * @note Known limitation: There is a potential race condition if multiple
94
+ * processes attempt to register the same session ID simultaneously. The check
95
+ * for existing sessions (line 103) and the save operation (line 120) are not
96
+ * atomic. This is an acceptable limitation for the current single-user CLI
97
+ * use case. Future versions may use file locking (e.g., proper-lockfile) or
98
+ * a database (e.g., SQLite) for multi-user/concurrent scenarios.
99
+ */
100
+ async registerSession(options) {
101
+ const { id, provider, machineId, branch, metadata = {}, status = 'running' } = options;
102
+
103
+ // Validate required fields
104
+ if (!id || !provider || !machineId || !branch) {
105
+ throw new Error('Required fields: id, provider, machineId, branch');
106
+ }
107
+
108
+ const sessions = await this._loadRegistry();
109
+
110
+ // Check if session already exists
111
+ const existing = sessions.find((s) => s.id === id);
112
+ if (existing) {
113
+ throw new Error(`Session ${id} already exists`);
114
+ }
115
+
116
+ const session = {
117
+ id,
118
+ provider,
119
+ machineId,
120
+ branch,
121
+ status,
122
+ createdAt: Date.now(),
123
+ lastActiveAt: Date.now(),
124
+ metadata
125
+ };
126
+
127
+ sessions.push(session);
128
+ await this._saveRegistry(sessions);
129
+
130
+ return session;
131
+ }
132
+
133
+ /**
134
+ * Update session metadata or status
135
+ * @param {string} sessionId - Session ID
136
+ * @param {Object} updates - Fields to update
137
+ * @returns {Promise<RemoteSessionEntry>}
138
+ */
139
+ async updateSession(sessionId, updates) {
140
+ const sessions = await this._loadRegistry();
141
+ const session = sessions.find((s) => s.id === sessionId);
142
+
143
+ if (!session) {
144
+ throw new Error(`Session ${sessionId} not found`);
145
+ }
146
+
147
+ // Update fields
148
+ if (updates.status !== undefined) {
149
+ session.status = updates.status;
150
+ }
151
+
152
+ if (updates.metadata !== undefined) {
153
+ session.metadata = { ...session.metadata, ...updates.metadata };
154
+ }
155
+
156
+ // Always update lastActiveAt
157
+ session.lastActiveAt = Date.now();
158
+
159
+ await this._saveRegistry(sessions);
160
+
161
+ return session;
162
+ }
163
+
164
+ /**
165
+ * List sessions with optional filtering
166
+ * @param {Object} filters
167
+ * @param {string} [filters.status] - Filter by status
168
+ * @param {string} [filters.provider] - Filter by provider
169
+ * @returns {Promise<Array<RemoteSessionEntry>>}
170
+ */
171
+ async listSessions(filters = {}) {
172
+ let sessions = await this._loadRegistry();
173
+
174
+ // Apply filters
175
+ if (filters.status) {
176
+ sessions = sessions.filter((s) => s.status === filters.status);
177
+ }
178
+
179
+ if (filters.provider) {
180
+ sessions = sessions.filter((s) => s.provider === filters.provider);
181
+ }
182
+
183
+ // Sort by createdAt descending (newest first)
184
+ sessions.sort((a, b) => b.createdAt - a.createdAt);
185
+
186
+ return sessions;
187
+ }
188
+
189
+ /**
190
+ * Get session by ID
191
+ * @param {string} sessionId - Session ID
192
+ * @returns {Promise<RemoteSessionEntry|null>}
193
+ */
194
+ async getSession(sessionId) {
195
+ const sessions = await this._loadRegistry();
196
+ return sessions.find((s) => s.id === sessionId) || null;
197
+ }
198
+
199
+ /**
200
+ * Unregister (remove) a session
201
+ * @param {string} sessionId - Session ID
202
+ * @returns {Promise<void>}
203
+ */
204
+ async unregisterSession(sessionId) {
205
+ const sessions = await this._loadRegistry();
206
+ const filtered = sessions.filter((s) => s.id !== sessionId);
207
+
208
+ if (filtered.length === sessions.length) {
209
+ throw new Error(`Session ${sessionId} not found`);
210
+ }
211
+
212
+ await this._saveRegistry(filtered);
213
+ }
214
+
215
+ /**
216
+ * Cleanup stale sessions
217
+ * @param {Object} options
218
+ * @param {number} [options.maxAge] - Max age in ms (default: 7 days)
219
+ * @returns {Promise<number>} Number of sessions removed
220
+ */
221
+ async cleanupStale(options = {}) {
222
+ const { maxAge = DEFAULT_MAX_AGE } = options;
223
+ const sessions = await this._loadRegistry();
224
+ const threshold = Date.now() - maxAge;
225
+
226
+ const active = sessions.filter((s) => {
227
+ return s.lastActiveAt > threshold;
228
+ });
229
+
230
+ const removed = sessions.length - active.length;
231
+
232
+ if (removed > 0) {
233
+ await this._saveRegistry(active);
234
+ }
235
+
236
+ return removed;
237
+ }
238
+
239
+ /**
240
+ * Get session statistics
241
+ * @returns {Promise<Object>}
242
+ */
243
+ async getStats() {
244
+ const sessions = await this._loadRegistry();
245
+
246
+ const stats = {
247
+ total: sessions.length,
248
+ running: 0,
249
+ paused: 0,
250
+ completed: 0,
251
+ failed: 0,
252
+ byProvider: {}
253
+ };
254
+
255
+ for (const session of sessions) {
256
+ // Count by status
257
+ if (session.status === 'running') stats.running++;
258
+ else if (session.status === 'paused') stats.paused++;
259
+ else if (session.status === 'completed') stats.completed++;
260
+ else if (session.status === 'failed') stats.failed++;
261
+
262
+ // Count by provider
263
+ if (!stats.byProvider[session.provider]) {
264
+ stats.byProvider[session.provider] = 0;
265
+ }
266
+ stats.byProvider[session.provider]++;
267
+ }
268
+
269
+ return stats;
270
+ }
271
+ }
272
+
273
+ export default RemoteSessionManager;
@@ -0,0 +1,324 @@
1
+ /**
2
+ * State Capturer for Remote Sessions
3
+ *
4
+ * Captures local state before initiating a remote session:
5
+ * - Git state (branch, commit, remote URL)
6
+ * - Environment variables (.env files + process.env)
7
+ * - Uncommitted changes
8
+ *
9
+ * Integrates with existing handoff utilities (GitHandoff, SessionState)
10
+ */
11
+
12
+ import { GitHandoff } from '../handoff/git-handoff.js';
13
+ import { readFileSync, existsSync } from 'fs';
14
+ import { join } from 'path';
15
+
16
+ /**
17
+ * StateCapturer class
18
+ */
19
+ export class StateCapturer {
20
+ /**
21
+ * @param {Object} options
22
+ * @param {string} options.repoPath - Path to git repository
23
+ * @param {string} options.sessionId - Session ID
24
+ * @param {boolean} [options.verbose] - Enable verbose logging
25
+ */
26
+ constructor(options) {
27
+ if (!options.repoPath) {
28
+ throw new Error('repoPath is required');
29
+ }
30
+ if (!options.sessionId) {
31
+ throw new Error('sessionId is required');
32
+ }
33
+
34
+ this.repoPath = options.repoPath;
35
+ this.sessionId = options.sessionId;
36
+ this.verbose = options.verbose || false;
37
+
38
+ // Create GitHandoff instance for git operations
39
+ this.gitHandoff = options.gitHandoff || new GitHandoff({
40
+ repoPath: this.repoPath,
41
+ sessionId: this.sessionId,
42
+ verbose: this.verbose,
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Capture current git state
48
+ *
49
+ * @returns {Promise<Object>} Git state
50
+ * @returns {boolean} return.isRepo - Whether directory is a git repo
51
+ * @returns {string|null} return.branch - Current branch name
52
+ * @returns {string|null} return.commit - Current commit hash
53
+ * @returns {string|null} return.remoteUrl - Remote origin URL
54
+ * @throws {Error} If git operations fail
55
+ */
56
+ async captureGitState() {
57
+ try {
58
+ const isRepo = await this.gitHandoff.isGitRepo();
59
+
60
+ if (!isRepo) {
61
+ return {
62
+ isRepo: false,
63
+ branch: null,
64
+ commit: null,
65
+ remoteUrl: null,
66
+ };
67
+ }
68
+
69
+ const [branch, commit, remoteUrl] = await Promise.all([
70
+ this.gitHandoff.getCurrentBranch(),
71
+ this.gitHandoff.getCurrentCommit(),
72
+ this.gitHandoff.getRemoteUrl(),
73
+ ]);
74
+
75
+ return {
76
+ isRepo: true,
77
+ branch,
78
+ commit,
79
+ remoteUrl,
80
+ };
81
+ } catch (error) {
82
+ throw new Error(`Failed to capture git state: ${error?.message || error}`);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Capture uncommitted changes
88
+ *
89
+ * @returns {Promise<Object>} Uncommitted changes info
90
+ * @returns {boolean} return.hasChanges - Whether there are uncommitted changes
91
+ * @returns {string[]} return.files - Array of changed file paths
92
+ * @returns {number} return.count - Number of changed files
93
+ * @throws {Error} If git operations fail
94
+ */
95
+ async captureUncommittedChanges() {
96
+ try {
97
+ const hasChanges = await this.gitHandoff.hasUncommittedChanges();
98
+ const files = hasChanges ? await this.gitHandoff.getChangedFiles() : [];
99
+
100
+ return {
101
+ hasChanges,
102
+ files,
103
+ count: files.length,
104
+ };
105
+ } catch (error) {
106
+ throw new Error(`Failed to capture uncommitted changes: ${error?.message || error}`);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Capture environment variables
112
+ *
113
+ * Reads from:
114
+ * - process.env (whitelist only for security)
115
+ * - .env files (.env, .env.local, .env.production, .env.development)
116
+ *
117
+ * SECURITY: By default, only captures whitelisted variables to prevent
118
+ * exposure of sensitive credentials (SSH keys, AWS credentials, etc.)
119
+ *
120
+ * @param {Object} [options]
121
+ * @param {RegExp} [options.filterPattern] - Only include vars matching this pattern (default: safe whitelist)
122
+ * @param {string[]} [options.exclude] - Exclude these specific keys
123
+ * @param {string[]} [options.dotenvFiles] - Custom .env file paths (defaults to standard files)
124
+ * @returns {Promise<Object>} Environment variables
125
+ * @returns {Object} return.process - Variables from process.env
126
+ * @returns {Array} return.dotenvFiles - Parsed .env files
127
+ * @returns {Object} [return.errors] - Parse errors for .env files
128
+ */
129
+ async captureEnvVars(options = {}) {
130
+ const {
131
+ // Default to safe whitelist: only capture known-safe prefixes
132
+ // Prevents exposure of SSH_PRIVATE_KEY, AWS_SECRET_ACCESS_KEY, etc.
133
+ filterPattern = /^(RELAY_|TELEPORTATION_|MECH_|NODE_ENV$|PATH$|HOME$|USER$|SHELL$)/,
134
+ exclude = [],
135
+ dotenvFiles = ['.env', '.env.local', '.env.production', '.env.development'],
136
+ } = options;
137
+
138
+ // Capture only whitelisted environment variables
139
+ const processEnv = {};
140
+ for (const [key, value] of Object.entries(process.env)) {
141
+ if (filterPattern.test(key) && !exclude.includes(key)) {
142
+ processEnv[key] = value;
143
+ }
144
+ }
145
+
146
+ // Parse .env files
147
+ const parsedDotenvFiles = [];
148
+ const errors = {};
149
+
150
+ for (const filename of dotenvFiles) {
151
+ const filepath = join(this.repoPath, filename);
152
+
153
+ if (!existsSync(filepath)) {
154
+ continue;
155
+ }
156
+
157
+ try {
158
+ const content = readFileSync(filepath, 'utf-8');
159
+ const vars = this._parseDotenvContent(content);
160
+
161
+ parsedDotenvFiles.push({
162
+ path: filepath,
163
+ vars,
164
+ });
165
+ } catch (error) {
166
+ errors[filepath] = error.message;
167
+ }
168
+ }
169
+
170
+ const result = {
171
+ process: processEnv,
172
+ dotenvFiles: parsedDotenvFiles,
173
+ };
174
+
175
+ if (Object.keys(errors).length > 0) {
176
+ result.errors = errors;
177
+ }
178
+
179
+ return result;
180
+ }
181
+
182
+ /**
183
+ * Parse .env file content
184
+ *
185
+ * Simple parser for KEY=value format
186
+ * Supports:
187
+ * - KEY=value
188
+ * - # comments
189
+ * - Empty lines
190
+ *
191
+ * SECURITY: Enforces limits to prevent DoS attacks via malicious .env files
192
+ *
193
+ * @private
194
+ * @param {string} content - .env file content
195
+ * @param {Object} [options] - Parsing options
196
+ * @param {number} [options.maxLines=1000] - Maximum number of lines to process
197
+ * @param {number} [options.maxLineLength=10000] - Maximum length per line
198
+ * @param {number} [options.maxVars=500] - Maximum number of variables
199
+ * @returns {Object} Parsed key-value pairs
200
+ */
201
+ _parseDotenvContent(content, options = {}) {
202
+ const { maxLines = 1000, maxLineLength = 10000, maxVars = 500 } = options;
203
+
204
+ const vars = {};
205
+ const lines = content.split('\n').slice(0, maxLines); // Limit total lines
206
+
207
+ for (const line of lines) {
208
+ // Skip lines that are too long (potential DoS)
209
+ if (line.length > maxLineLength) {
210
+ continue;
211
+ }
212
+
213
+ const trimmed = line.trim();
214
+
215
+ // Skip empty lines and comments
216
+ if (!trimmed || trimmed.startsWith('#')) {
217
+ continue;
218
+ }
219
+
220
+ // Enforce max variables limit
221
+ if (Object.keys(vars).length >= maxVars) {
222
+ break;
223
+ }
224
+
225
+ // Parse KEY=value
226
+ const match = trimmed.match(/^([^=]+)=(.*)$/);
227
+ if (match) {
228
+ const key = match[1].trim();
229
+ let value = match[2].trim();
230
+
231
+ // Remove surrounding quotes if present
232
+ if (
233
+ (value.startsWith('"') && value.endsWith('"')) ||
234
+ (value.startsWith("'") && value.endsWith("'"))
235
+ ) {
236
+ value = value.slice(1, -1);
237
+ }
238
+
239
+ vars[key] = value;
240
+ }
241
+ }
242
+
243
+ return vars;
244
+ }
245
+
246
+ /**
247
+ * Capture all state (git + env + uncommitted changes)
248
+ *
249
+ * @param {Object} [options]
250
+ * @param {Object} [options.envOptions] - Options to pass to captureEnvVars()
251
+ * @returns {Promise<Object>} Complete state snapshot
252
+ * @returns {Object} return.git - Git state
253
+ * @returns {Object} return.env - Environment variables
254
+ * @returns {Object} return.uncommittedChanges - Uncommitted changes
255
+ * @returns {string} return.timestamp - ISO 8601 timestamp
256
+ * @returns {string} return.sessionId - Session ID
257
+ * @returns {string} return.repoPath - Repository path
258
+ */
259
+ async captureAll(options = {}) {
260
+ const { envOptions = {} } = options;
261
+
262
+ const [git, env, uncommittedChanges] = await Promise.all([
263
+ this.captureGitState(),
264
+ this.captureEnvVars(envOptions),
265
+ this.captureUncommittedChanges(),
266
+ ]);
267
+
268
+ return {
269
+ git,
270
+ env,
271
+ uncommittedChanges,
272
+ timestamp: new Date().toISOString(),
273
+ sessionId: this.sessionId,
274
+ repoPath: this.repoPath,
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Commit and push uncommitted changes to WIP branch
280
+ *
281
+ * Uses GitHandoff to:
282
+ * 1. Commit all uncommitted changes with WIP message
283
+ * 2. Push to teleport/wip-{sessionId} branch
284
+ *
285
+ * @param {string} [message] - Optional message to include in commit
286
+ * @returns {Promise<Object>} Commit result
287
+ * @returns {boolean} return.committed - Whether changes were committed
288
+ * @returns {string|null} return.commit - Commit hash (if committed)
289
+ * @returns {string[]} return.files - Files that were committed
290
+ * @returns {string|null} return.branch - Branch that was pushed to
291
+ * @throws {Error} If commit/push fails
292
+ */
293
+ async commitAndPushChanges(message) {
294
+ try {
295
+ const hasChanges = await this.gitHandoff.hasUncommittedChanges();
296
+
297
+ if (!hasChanges) {
298
+ return {
299
+ committed: false,
300
+ commit: null,
301
+ files: [],
302
+ branch: null,
303
+ };
304
+ }
305
+
306
+ // Commit WIP changes
307
+ const { commit, files } = await this.gitHandoff.commitWip(message);
308
+
309
+ // Push to WIP branch (force=true for WIP branches)
310
+ const { branch } = await this.gitHandoff.pushToWipBranch(true);
311
+
312
+ return {
313
+ committed: true,
314
+ commit,
315
+ files,
316
+ branch,
317
+ };
318
+ } catch (error) {
319
+ throw new Error(`Failed to commit and push changes: ${error?.message || error}`);
320
+ }
321
+ }
322
+ }
323
+
324
+ export default StateCapturer;