teleportation-cli 1.0.0
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/config-loader.mjs +93 -0
- package/.claude/hooks/heartbeat.mjs +331 -0
- package/.claude/hooks/notification.mjs +35 -0
- package/.claude/hooks/permission_request.mjs +307 -0
- package/.claude/hooks/post_tool_use.mjs +137 -0
- package/.claude/hooks/pre_tool_use.mjs +451 -0
- package/.claude/hooks/session-register.mjs +274 -0
- package/.claude/hooks/session_end.mjs +256 -0
- package/.claude/hooks/session_start.mjs +308 -0
- package/.claude/hooks/stop.mjs +277 -0
- package/.claude/hooks/user_prompt_submit.mjs +91 -0
- package/LICENSE +21 -0
- package/README.md +243 -0
- package/lib/auth/api-key.js +110 -0
- package/lib/auth/credentials.js +341 -0
- package/lib/backup/manager.js +461 -0
- package/lib/cli/daemon-commands.js +299 -0
- package/lib/cli/index.js +303 -0
- package/lib/cli/session-commands.js +294 -0
- package/lib/cli/snapshot-commands.js +223 -0
- package/lib/cli/worktree-commands.js +291 -0
- package/lib/config/manager.js +306 -0
- package/lib/daemon/lifecycle.js +336 -0
- package/lib/daemon/pid-manager.js +160 -0
- package/lib/daemon/teleportation-daemon.js +2009 -0
- package/lib/handoff/config.js +102 -0
- package/lib/handoff/example.js +152 -0
- package/lib/handoff/git-handoff.js +351 -0
- package/lib/handoff/handoff.js +277 -0
- package/lib/handoff/index.js +25 -0
- package/lib/handoff/session-state.js +238 -0
- package/lib/install/installer.js +555 -0
- package/lib/machine-coders/claude-code-adapter.js +329 -0
- package/lib/machine-coders/example.js +239 -0
- package/lib/machine-coders/gemini-cli-adapter.js +406 -0
- package/lib/machine-coders/index.js +103 -0
- package/lib/machine-coders/interface.js +168 -0
- package/lib/router/classifier.js +251 -0
- package/lib/router/example.js +92 -0
- package/lib/router/index.js +69 -0
- package/lib/router/mech-llms-client.js +277 -0
- package/lib/router/models.js +188 -0
- package/lib/router/router.js +382 -0
- package/lib/session/cleanup.js +100 -0
- package/lib/session/metadata.js +258 -0
- package/lib/session/mute-checker.js +114 -0
- package/lib/session-registry/manager.js +302 -0
- package/lib/snapshot/manager.js +390 -0
- package/lib/utils/errors.js +166 -0
- package/lib/utils/logger.js +148 -0
- package/lib/utils/retry.js +155 -0
- package/lib/worktree/manager.js +301 -0
- package/package.json +66 -0
- package/teleportation-cli.cjs +2987 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Snapshot Management Module
|
|
4
|
+
* Handles creation, restoration, and management of code snapshots
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execSync } from 'child_process';
|
|
8
|
+
import { join, resolve, basename } from 'path';
|
|
9
|
+
import { mkdir, writeFile, readFile, readdir, rm } from 'fs/promises';
|
|
10
|
+
import { existsSync } from 'fs';
|
|
11
|
+
import { getRepoRoot, validateSessionId } from '../worktree/manager.js';
|
|
12
|
+
|
|
13
|
+
const SNAPSHOT_BASE = '.teleportation/snapshots';
|
|
14
|
+
|
|
15
|
+
// Validation pattern for snapshot IDs (session-type-timestamp format)
|
|
16
|
+
const VALID_SNAPSHOT_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}-[a-z-]+-\d+$/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Validate a snapshot ID format
|
|
20
|
+
* @param {string} snapshotId - Snapshot ID to validate
|
|
21
|
+
* @returns {string} Validated snapshot ID
|
|
22
|
+
* @throws {Error} If snapshot ID is invalid
|
|
23
|
+
*/
|
|
24
|
+
function validateSnapshotId(snapshotId) {
|
|
25
|
+
if (!snapshotId || typeof snapshotId !== 'string') {
|
|
26
|
+
throw new Error('Snapshot ID is required');
|
|
27
|
+
}
|
|
28
|
+
if (!VALID_SNAPSHOT_ID.test(snapshotId)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Invalid snapshot ID format: "${snapshotId}". Expected format: sessionId-type-timestamp`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return snapshotId;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Validate a commit hash (SHA-1 or abbreviated)
|
|
38
|
+
* @param {string} commitHash - Commit hash to validate
|
|
39
|
+
* @returns {string} Validated commit hash
|
|
40
|
+
* @throws {Error} If commit hash is invalid
|
|
41
|
+
*/
|
|
42
|
+
function validateCommitHash(commitHash) {
|
|
43
|
+
if (!commitHash || typeof commitHash !== 'string') {
|
|
44
|
+
throw new Error('Commit hash is required');
|
|
45
|
+
}
|
|
46
|
+
// SHA-1 hashes are 40 hex chars, but abbreviated can be 7+
|
|
47
|
+
if (!/^[a-f0-9]{7,40}$/i.test(commitHash)) {
|
|
48
|
+
throw new Error(`Invalid commit hash: "${commitHash}"`);
|
|
49
|
+
}
|
|
50
|
+
return commitHash;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validate a stash reference
|
|
55
|
+
* @param {string} stashRef - Stash reference to validate
|
|
56
|
+
* @returns {string} Validated stash reference
|
|
57
|
+
* @throws {Error} If stash reference is invalid
|
|
58
|
+
*/
|
|
59
|
+
function validateStashRef(stashRef) {
|
|
60
|
+
if (!stashRef || typeof stashRef !== 'string') {
|
|
61
|
+
throw new Error('Stash reference is required');
|
|
62
|
+
}
|
|
63
|
+
// stash@{n} format
|
|
64
|
+
if (!/^stash@\{\d+\}$/.test(stashRef)) {
|
|
65
|
+
throw new Error(`Invalid stash reference: "${stashRef}"`);
|
|
66
|
+
}
|
|
67
|
+
return stashRef;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Snapshot types
|
|
72
|
+
*/
|
|
73
|
+
export const SnapshotType = {
|
|
74
|
+
BASELINE: 'baseline', // Initial state when session starts
|
|
75
|
+
CHECKPOINT: 'checkpoint', // Manual checkpoint
|
|
76
|
+
PRE_MERGE: 'pre-merge', // Before merging from another branch
|
|
77
|
+
PRE_COMMIT: 'pre-commit', // Before committing
|
|
78
|
+
AUTO: 'auto', // Automatic periodic snapshot
|
|
79
|
+
PRE_DESTROY: 'pre-destroy' // Before destroying worktree
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create a snapshot of the current working directory state
|
|
84
|
+
* @param {string} sessionId - Session identifier
|
|
85
|
+
* @param {string} type - Snapshot type
|
|
86
|
+
* @param {string} message - Optional message describing the snapshot
|
|
87
|
+
* @returns {Promise<{id: string, type: string, timestamp: number, message: string, stashRef: string}>}
|
|
88
|
+
*/
|
|
89
|
+
export async function createSnapshot(sessionId, type = SnapshotType.AUTO, message = '') {
|
|
90
|
+
// Validate session ID to prevent command injection
|
|
91
|
+
const validSessionId = validateSessionId(sessionId);
|
|
92
|
+
|
|
93
|
+
// Validate snapshot type
|
|
94
|
+
const validTypes = Object.values(SnapshotType);
|
|
95
|
+
if (!validTypes.includes(type)) {
|
|
96
|
+
throw new Error(`Invalid snapshot type: "${type}". Valid types: ${validTypes.join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const repoRoot = getRepoRoot();
|
|
100
|
+
const timestamp = Date.now();
|
|
101
|
+
const snapshotId = `${validSessionId}-${type}-${timestamp}`;
|
|
102
|
+
|
|
103
|
+
// Create snapshot metadata
|
|
104
|
+
const metadata = {
|
|
105
|
+
id: snapshotId,
|
|
106
|
+
sessionId,
|
|
107
|
+
type,
|
|
108
|
+
timestamp,
|
|
109
|
+
message,
|
|
110
|
+
branch: getCurrentBranch(),
|
|
111
|
+
commitHash: getCurrentCommitHash(),
|
|
112
|
+
hasUncommittedChanges: hasUncommittedChanges(),
|
|
113
|
+
hasUntrackedFiles: hasUntrackedFiles()
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Ensure snapshot directory exists
|
|
117
|
+
const snapshotDir = join(repoRoot, SNAPSHOT_BASE, sessionId);
|
|
118
|
+
await mkdir(snapshotDir, { recursive: true });
|
|
119
|
+
|
|
120
|
+
// If there are uncommitted changes, create a git stash
|
|
121
|
+
let stashRef = null;
|
|
122
|
+
if (metadata.hasUncommittedChanges || metadata.hasUntrackedFiles) {
|
|
123
|
+
stashRef = createStash(snapshotId);
|
|
124
|
+
metadata.stashRef = stashRef;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Save metadata
|
|
128
|
+
const metadataPath = join(snapshotDir, `${snapshotId}.json`);
|
|
129
|
+
await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
|
|
130
|
+
|
|
131
|
+
return metadata;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Restore a snapshot
|
|
136
|
+
* @param {string} snapshotId - Snapshot ID to restore
|
|
137
|
+
* @param {boolean} force - Force restore even with uncommitted changes
|
|
138
|
+
* @returns {Promise<void>}
|
|
139
|
+
*/
|
|
140
|
+
export async function restoreSnapshot(snapshotId, force = false) {
|
|
141
|
+
const repoRoot = getRepoRoot();
|
|
142
|
+
|
|
143
|
+
// Find snapshot metadata
|
|
144
|
+
const metadata = await getSnapshotMetadata(snapshotId);
|
|
145
|
+
if (!metadata) {
|
|
146
|
+
throw new Error(`Snapshot not found: ${snapshotId}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Validate commit hash before using in command
|
|
150
|
+
const validCommitHash = validateCommitHash(metadata.commitHash);
|
|
151
|
+
|
|
152
|
+
// Check for uncommitted changes
|
|
153
|
+
if (!force && (hasUncommittedChanges() || hasUntrackedFiles())) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
'Cannot restore snapshot: uncommitted changes present. Use --force to override.'
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Restore commit state
|
|
160
|
+
try {
|
|
161
|
+
execSync(`git checkout "${validCommitHash}"`, {
|
|
162
|
+
encoding: 'utf8',
|
|
163
|
+
stdio: 'pipe'
|
|
164
|
+
});
|
|
165
|
+
} catch (error) {
|
|
166
|
+
throw new Error(`Failed to checkout commit ${validCommitHash}: ${error.message}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Restore stash if exists - use apply to allow multiple restores
|
|
170
|
+
if (metadata.stashRef) {
|
|
171
|
+
const validStashRef = validateStashRef(metadata.stashRef);
|
|
172
|
+
try {
|
|
173
|
+
// Use 'apply' instead of 'pop' to keep stash for multiple restores
|
|
174
|
+
// The stash is only dropped when the snapshot is deleted
|
|
175
|
+
execSync(`git stash apply "${validStashRef}"`, {
|
|
176
|
+
encoding: 'utf8',
|
|
177
|
+
stdio: 'pipe'
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Track restoration in metadata (but keep stashRef for future restores)
|
|
181
|
+
const metadataPath = join(
|
|
182
|
+
repoRoot,
|
|
183
|
+
SNAPSHOT_BASE,
|
|
184
|
+
metadata.sessionId,
|
|
185
|
+
`${snapshotId}.json`
|
|
186
|
+
);
|
|
187
|
+
metadata.lastRestoredAt = Date.now();
|
|
188
|
+
metadata.restoreCount = (metadata.restoreCount || 0) + 1;
|
|
189
|
+
await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
|
|
190
|
+
} catch (error) {
|
|
191
|
+
throw new Error(`Failed to apply stash ${validStashRef}: ${error.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* List all snapshots for a session
|
|
198
|
+
* @param {string} sessionId - Session identifier
|
|
199
|
+
* @returns {Promise<Array<Object>>}
|
|
200
|
+
*/
|
|
201
|
+
export async function listSnapshots(sessionId) {
|
|
202
|
+
const repoRoot = getRepoRoot();
|
|
203
|
+
const snapshotDir = join(repoRoot, SNAPSHOT_BASE, sessionId);
|
|
204
|
+
|
|
205
|
+
if (!existsSync(snapshotDir)) {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const files = await readdir(snapshotDir);
|
|
210
|
+
const snapshots = [];
|
|
211
|
+
|
|
212
|
+
for (const file of files) {
|
|
213
|
+
if (file.endsWith('.json')) {
|
|
214
|
+
const metadataPath = join(snapshotDir, file);
|
|
215
|
+
const content = await readFile(metadataPath, 'utf8');
|
|
216
|
+
snapshots.push(JSON.parse(content));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Sort by timestamp (newest first)
|
|
221
|
+
snapshots.sort((a, b) => b.timestamp - a.timestamp);
|
|
222
|
+
|
|
223
|
+
return snapshots;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Delete a snapshot
|
|
228
|
+
* @param {string} snapshotId - Snapshot ID to delete
|
|
229
|
+
* @returns {Promise<void>}
|
|
230
|
+
*/
|
|
231
|
+
export async function deleteSnapshot(snapshotId) {
|
|
232
|
+
const metadata = await getSnapshotMetadata(snapshotId);
|
|
233
|
+
if (!metadata) {
|
|
234
|
+
throw new Error(`Snapshot not found: ${snapshotId}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const repoRoot = getRepoRoot();
|
|
238
|
+
const metadataPath = join(
|
|
239
|
+
repoRoot,
|
|
240
|
+
SNAPSHOT_BASE,
|
|
241
|
+
metadata.sessionId,
|
|
242
|
+
`${snapshotId}.json`
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
// Delete stash if it exists
|
|
246
|
+
if (metadata.stashRef) {
|
|
247
|
+
const validStashRef = validateStashRef(metadata.stashRef);
|
|
248
|
+
try {
|
|
249
|
+
execSync(`git stash drop "${validStashRef}"`, {
|
|
250
|
+
encoding: 'utf8',
|
|
251
|
+
stdio: 'pipe'
|
|
252
|
+
});
|
|
253
|
+
} catch (error) {
|
|
254
|
+
// Check if stash still exists - if yes, it's a real error
|
|
255
|
+
const stashList = execSync('git stash list', { encoding: 'utf8' });
|
|
256
|
+
if (stashList.includes(validStashRef)) {
|
|
257
|
+
throw new Error(`Failed to drop stash ${validStashRef}: stash still exists`);
|
|
258
|
+
}
|
|
259
|
+
// Stash already gone (possibly from earlier operation), safe to continue
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Now safe to delete metadata file
|
|
264
|
+
await rm(metadataPath, { force: true });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Delete all snapshots for a session
|
|
269
|
+
* @param {string} sessionId - Session identifier
|
|
270
|
+
* @returns {Promise<number>} Number of snapshots deleted
|
|
271
|
+
*/
|
|
272
|
+
export async function deleteAllSnapshots(sessionId) {
|
|
273
|
+
const snapshots = await listSnapshots(sessionId);
|
|
274
|
+
|
|
275
|
+
for (const snapshot of snapshots) {
|
|
276
|
+
await deleteSnapshot(snapshot.id);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Remove session directory if empty
|
|
280
|
+
const repoRoot = getRepoRoot();
|
|
281
|
+
const snapshotDir = join(repoRoot, SNAPSHOT_BASE, sessionId);
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
await rm(snapshotDir, { recursive: true, force: true });
|
|
285
|
+
} catch {
|
|
286
|
+
// Directory might not be empty or might not exist, that's ok
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return snapshots.length;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Get diff between current state and a snapshot
|
|
294
|
+
* @param {string} snapshotId - Snapshot ID
|
|
295
|
+
* @returns {Promise<string>}
|
|
296
|
+
*/
|
|
297
|
+
export async function getSnapshotDiff(snapshotId) {
|
|
298
|
+
const metadata = await getSnapshotMetadata(snapshotId);
|
|
299
|
+
if (!metadata) {
|
|
300
|
+
throw new Error(`Snapshot not found: ${snapshotId}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Validate commit hash before using in command
|
|
304
|
+
const validCommitHash = validateCommitHash(metadata.commitHash);
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const diff = execSync(`git diff "${validCommitHash}"`, {
|
|
308
|
+
encoding: 'utf8'
|
|
309
|
+
});
|
|
310
|
+
return diff;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
throw new Error(`Failed to get diff: ${error.message}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Helper functions
|
|
317
|
+
|
|
318
|
+
function getCurrentBranch() {
|
|
319
|
+
try {
|
|
320
|
+
return execSync('git branch --show-current', { encoding: 'utf8' }).trim();
|
|
321
|
+
} catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function getCurrentCommitHash() {
|
|
327
|
+
try {
|
|
328
|
+
return execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function hasUncommittedChanges() {
|
|
335
|
+
try {
|
|
336
|
+
const status = execSync('git status --porcelain', { encoding: 'utf8' });
|
|
337
|
+
// Check for modified or staged files (lines starting with M, A, D, etc.)
|
|
338
|
+
return status.split('\n').some(line => {
|
|
339
|
+
const trimmed = line.trim();
|
|
340
|
+
return trimmed && !trimmed.startsWith('??');
|
|
341
|
+
});
|
|
342
|
+
} catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function hasUntrackedFiles() {
|
|
348
|
+
try {
|
|
349
|
+
const status = execSync('git status --porcelain', { encoding: 'utf8' });
|
|
350
|
+
// Check for untracked files (lines starting with ??)
|
|
351
|
+
return status.includes('??');
|
|
352
|
+
} catch {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function createStash(snapshotId) {
|
|
358
|
+
// snapshotId is already validated in createSnapshot, but double-check format
|
|
359
|
+
// to ensure no shell metacharacters in stash message
|
|
360
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*-[a-z-]+-\d+$/.test(snapshotId)) {
|
|
361
|
+
throw new Error(`Invalid snapshot ID for stash: ${snapshotId}`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
execSync(`git stash push -u -m "snapshot:${snapshotId}"`, {
|
|
366
|
+
encoding: 'utf8',
|
|
367
|
+
stdio: 'pipe'
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// Get the stash reference
|
|
371
|
+
const stashList = execSync('git stash list', { encoding: 'utf8' });
|
|
372
|
+
const match = stashList.match(/stash@\{0\}/);
|
|
373
|
+
return match ? match[0] : null;
|
|
374
|
+
} catch (error) {
|
|
375
|
+
throw new Error(`Failed to create stash: ${error.message}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function getSnapshotMetadata(snapshotId) {
|
|
380
|
+
const repoRoot = getRepoRoot();
|
|
381
|
+
const sessionId = snapshotId.split('-')[0];
|
|
382
|
+
const metadataPath = join(repoRoot, SNAPSHOT_BASE, sessionId, `${snapshotId}.json`);
|
|
383
|
+
|
|
384
|
+
if (!existsSync(metadataPath)) {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const content = await readFile(metadataPath, 'utf8');
|
|
389
|
+
return JSON.parse(content);
|
|
390
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Custom error classes for Teleportation CLI
|
|
4
|
+
* Provides structured error handling with user-friendly messages
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
class TeleportationError extends Error {
|
|
8
|
+
constructor(message, code = 'UNKNOWN_ERROR', details = null) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'TeleportationError';
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.details = details;
|
|
13
|
+
Error.captureStackTrace(this, this.constructor);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
toJSON() {
|
|
17
|
+
return {
|
|
18
|
+
name: this.name,
|
|
19
|
+
message: this.message,
|
|
20
|
+
code: this.code,
|
|
21
|
+
details: this.details
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class ConfigurationError extends TeleportationError {
|
|
27
|
+
constructor(message, details = null) {
|
|
28
|
+
super(message, 'CONFIG_ERROR', details);
|
|
29
|
+
this.name = 'ConfigurationError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class AuthenticationError extends TeleportationError {
|
|
34
|
+
constructor(message, details = null) {
|
|
35
|
+
super(message, 'AUTH_ERROR', details);
|
|
36
|
+
this.name = 'AuthenticationError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class NetworkError extends TeleportationError {
|
|
41
|
+
constructor(message, details = null) {
|
|
42
|
+
super(message, 'NETWORK_ERROR', details);
|
|
43
|
+
this.name = 'NetworkError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class ValidationError extends TeleportationError {
|
|
48
|
+
constructor(message, details = null) {
|
|
49
|
+
super(message, 'VALIDATION_ERROR', details);
|
|
50
|
+
this.name = 'ValidationError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
class FileSystemError extends TeleportationError {
|
|
55
|
+
constructor(message, details = null) {
|
|
56
|
+
super(message, 'FILESYSTEM_ERROR', details);
|
|
57
|
+
this.name = 'FileSystemError';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Format error for user display
|
|
63
|
+
*/
|
|
64
|
+
function formatError(error) {
|
|
65
|
+
if (error instanceof TeleportationError) {
|
|
66
|
+
return {
|
|
67
|
+
message: error.message,
|
|
68
|
+
code: error.code,
|
|
69
|
+
details: error.details,
|
|
70
|
+
userFriendly: getUserFriendlyMessage(error)
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Handle standard errors
|
|
75
|
+
return {
|
|
76
|
+
message: error.message,
|
|
77
|
+
code: 'UNKNOWN_ERROR',
|
|
78
|
+
details: null,
|
|
79
|
+
userFriendly: getUserFriendlyMessage(error)
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get user-friendly error message
|
|
85
|
+
*/
|
|
86
|
+
function getUserFriendlyMessage(error) {
|
|
87
|
+
if (error instanceof ConfigurationError) {
|
|
88
|
+
return `Configuration error: ${error.message}. Please check your settings with 'teleportation config list'.`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (error instanceof AuthenticationError) {
|
|
92
|
+
return `Authentication failed: ${error.message}. Please try 'teleportation login' again.`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (error instanceof NetworkError) {
|
|
96
|
+
return `Network error: ${error.message}. Please check your internet connection and try again.`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (error instanceof ValidationError) {
|
|
100
|
+
return `Invalid input: ${error.message}. Please check your command syntax.`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (error instanceof FileSystemError) {
|
|
104
|
+
return `File system error: ${error.message}. Please check file permissions.`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Handle common system errors
|
|
108
|
+
if (error.code === 'ENOENT') {
|
|
109
|
+
return `File not found: ${error.message}. The file may have been moved or deleted.`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (error.code === 'EACCES') {
|
|
113
|
+
return `Permission denied: ${error.message}. Please check file permissions.`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (error.code === 'ECONNREFUSED') {
|
|
117
|
+
return `Connection refused: ${error.message}. The server may be down or unreachable.`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (error.code === 'ETIMEDOUT') {
|
|
121
|
+
return `Connection timeout: ${error.message}. The server took too long to respond.`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Default message
|
|
125
|
+
return error.message || 'An unexpected error occurred. Please try again.';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Handle error and exit with appropriate code
|
|
130
|
+
*/
|
|
131
|
+
function handleError(error, logger = null) {
|
|
132
|
+
const formatted = formatError(error);
|
|
133
|
+
|
|
134
|
+
if (logger) {
|
|
135
|
+
logger.error('Error occurred', formatted);
|
|
136
|
+
} else {
|
|
137
|
+
console.error(`❌ Error [${formatted.code}]: ${formatted.userFriendly}`);
|
|
138
|
+
if (formatted.details && process.env.DEBUG) {
|
|
139
|
+
console.error('Details:', formatted.details);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Exit with appropriate code
|
|
144
|
+
const exitCodes = {
|
|
145
|
+
'CONFIG_ERROR': 2,
|
|
146
|
+
'AUTH_ERROR': 3,
|
|
147
|
+
'NETWORK_ERROR': 4,
|
|
148
|
+
'VALIDATION_ERROR': 5,
|
|
149
|
+
'FILESYSTEM_ERROR': 6
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
process.exit(exitCodes[formatted.code] || 1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export {
|
|
156
|
+
TeleportationError,
|
|
157
|
+
ConfigurationError,
|
|
158
|
+
AuthenticationError,
|
|
159
|
+
NetworkError,
|
|
160
|
+
ValidationError,
|
|
161
|
+
FileSystemError,
|
|
162
|
+
formatError,
|
|
163
|
+
getUserFriendlyMessage,
|
|
164
|
+
handleError
|
|
165
|
+
};
|
|
166
|
+
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Structured logging utility for Teleportation CLI
|
|
4
|
+
* Supports different log levels and output formats
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
|
|
11
|
+
const LOG_LEVELS = {
|
|
12
|
+
DEBUG: 0,
|
|
13
|
+
INFO: 1,
|
|
14
|
+
WARN: 2,
|
|
15
|
+
ERROR: 3,
|
|
16
|
+
NONE: 4
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const LOG_LEVEL_NAMES = {
|
|
20
|
+
0: 'DEBUG',
|
|
21
|
+
1: 'INFO',
|
|
22
|
+
2: 'WARN',
|
|
23
|
+
3: 'ERROR',
|
|
24
|
+
4: 'NONE'
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
class Logger {
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
this.level = options.level || (process.env.DEBUG ? LOG_LEVELS.DEBUG : LOG_LEVELS.INFO);
|
|
30
|
+
this.logFile = options.logFile || path.join(os.homedir(), '.teleportation', 'logs', 'cli.log');
|
|
31
|
+
this.enableFileLogging = options.enableFileLogging !== false;
|
|
32
|
+
this.enableColors = options.enableColors !== false && process.stdout.isTTY;
|
|
33
|
+
|
|
34
|
+
// Ensure log directory exists
|
|
35
|
+
if (this.enableFileLogging) {
|
|
36
|
+
const logDir = path.dirname(this.logFile);
|
|
37
|
+
if (!fs.existsSync(logDir)) {
|
|
38
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_colorize(level, message) {
|
|
44
|
+
if (!this.enableColors) return message;
|
|
45
|
+
|
|
46
|
+
const colors = {
|
|
47
|
+
DEBUG: '\x1b[0;36m', // Cyan
|
|
48
|
+
INFO: '\x1b[0;32m', // Green
|
|
49
|
+
WARN: '\x1b[1;33m', // Yellow
|
|
50
|
+
ERROR: '\x1b[0;31m' // Red
|
|
51
|
+
};
|
|
52
|
+
const reset = '\x1b[0m';
|
|
53
|
+
|
|
54
|
+
return `${colors[level] || ''}${message}${reset}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_formatMessage(level, message, data = null) {
|
|
58
|
+
const timestamp = new Date().toISOString();
|
|
59
|
+
const levelName = LOG_LEVEL_NAMES[level];
|
|
60
|
+
const prefix = `[${timestamp}] [${levelName}]`;
|
|
61
|
+
|
|
62
|
+
let formatted = `${prefix} ${message}`;
|
|
63
|
+
if (data) {
|
|
64
|
+
formatted += ` ${JSON.stringify(data)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return formatted;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_write(level, message, data = null) {
|
|
71
|
+
if (level < this.level) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const formatted = this._formatMessage(level, message, data);
|
|
76
|
+
const levelName = LOG_LEVEL_NAMES[level];
|
|
77
|
+
|
|
78
|
+
// Console output (with colors)
|
|
79
|
+
const consoleMessage = this._colorize(levelName, formatted);
|
|
80
|
+
if (level >= LOG_LEVELS.ERROR) {
|
|
81
|
+
console.error(consoleMessage);
|
|
82
|
+
} else {
|
|
83
|
+
console.log(consoleMessage);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// File output (without colors)
|
|
87
|
+
if (this.enableFileLogging) {
|
|
88
|
+
try {
|
|
89
|
+
fs.appendFileSync(this.logFile, formatted + '\n', { flag: 'a' });
|
|
90
|
+
} catch (e) {
|
|
91
|
+
// Silently fail if log file can't be written
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
debug(message, data) {
|
|
97
|
+
this._write(LOG_LEVELS.DEBUG, message, data);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
info(message, data) {
|
|
101
|
+
this._write(LOG_LEVELS.INFO, message, data);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
warn(message, data) {
|
|
105
|
+
this._write(LOG_LEVELS.WARN, message, data);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
error(message, data) {
|
|
109
|
+
this._write(LOG_LEVELS.ERROR, message, data);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Convenience methods for common patterns
|
|
113
|
+
success(message) {
|
|
114
|
+
if (this.enableColors) {
|
|
115
|
+
console.log(`\x1b[0;32m✓\x1b[0m ${message}`);
|
|
116
|
+
} else {
|
|
117
|
+
console.log(`✓ ${message}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
failure(message) {
|
|
122
|
+
if (this.enableColors) {
|
|
123
|
+
console.error(`\x1b[0;31m✗\x1b[0m ${message}`);
|
|
124
|
+
} else {
|
|
125
|
+
console.error(`✗ ${message}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Get log file path
|
|
130
|
+
getLogFile() {
|
|
131
|
+
return this.logFile;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Set log level
|
|
135
|
+
setLevel(level) {
|
|
136
|
+
if (typeof level === 'string') {
|
|
137
|
+
level = LOG_LEVELS[level.toUpperCase()] || LOG_LEVELS.INFO;
|
|
138
|
+
}
|
|
139
|
+
this.level = level;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Create default logger instance
|
|
144
|
+
const logger = new Logger();
|
|
145
|
+
|
|
146
|
+
export default logger;
|
|
147
|
+
export { Logger, LOG_LEVELS };
|
|
148
|
+
|