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,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;
|