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.
Files changed (54) hide show
  1. package/.claude/hooks/config-loader.mjs +93 -0
  2. package/.claude/hooks/heartbeat.mjs +331 -0
  3. package/.claude/hooks/notification.mjs +35 -0
  4. package/.claude/hooks/permission_request.mjs +307 -0
  5. package/.claude/hooks/post_tool_use.mjs +137 -0
  6. package/.claude/hooks/pre_tool_use.mjs +451 -0
  7. package/.claude/hooks/session-register.mjs +274 -0
  8. package/.claude/hooks/session_end.mjs +256 -0
  9. package/.claude/hooks/session_start.mjs +308 -0
  10. package/.claude/hooks/stop.mjs +277 -0
  11. package/.claude/hooks/user_prompt_submit.mjs +91 -0
  12. package/LICENSE +21 -0
  13. package/README.md +243 -0
  14. package/lib/auth/api-key.js +110 -0
  15. package/lib/auth/credentials.js +341 -0
  16. package/lib/backup/manager.js +461 -0
  17. package/lib/cli/daemon-commands.js +299 -0
  18. package/lib/cli/index.js +303 -0
  19. package/lib/cli/session-commands.js +294 -0
  20. package/lib/cli/snapshot-commands.js +223 -0
  21. package/lib/cli/worktree-commands.js +291 -0
  22. package/lib/config/manager.js +306 -0
  23. package/lib/daemon/lifecycle.js +336 -0
  24. package/lib/daemon/pid-manager.js +160 -0
  25. package/lib/daemon/teleportation-daemon.js +2009 -0
  26. package/lib/handoff/config.js +102 -0
  27. package/lib/handoff/example.js +152 -0
  28. package/lib/handoff/git-handoff.js +351 -0
  29. package/lib/handoff/handoff.js +277 -0
  30. package/lib/handoff/index.js +25 -0
  31. package/lib/handoff/session-state.js +238 -0
  32. package/lib/install/installer.js +555 -0
  33. package/lib/machine-coders/claude-code-adapter.js +329 -0
  34. package/lib/machine-coders/example.js +239 -0
  35. package/lib/machine-coders/gemini-cli-adapter.js +406 -0
  36. package/lib/machine-coders/index.js +103 -0
  37. package/lib/machine-coders/interface.js +168 -0
  38. package/lib/router/classifier.js +251 -0
  39. package/lib/router/example.js +92 -0
  40. package/lib/router/index.js +69 -0
  41. package/lib/router/mech-llms-client.js +277 -0
  42. package/lib/router/models.js +188 -0
  43. package/lib/router/router.js +382 -0
  44. package/lib/session/cleanup.js +100 -0
  45. package/lib/session/metadata.js +258 -0
  46. package/lib/session/mute-checker.js +114 -0
  47. package/lib/session-registry/manager.js +302 -0
  48. package/lib/snapshot/manager.js +390 -0
  49. package/lib/utils/errors.js +166 -0
  50. package/lib/utils/logger.js +148 -0
  51. package/lib/utils/retry.js +155 -0
  52. package/lib/worktree/manager.js +301 -0
  53. package/package.json +66 -0
  54. package/teleportation-cli.cjs +2987 -0
package/README.md ADDED
@@ -0,0 +1,243 @@
1
+ # Teleportation
2
+
3
+ **Remote approval system for Claude Code**
4
+
5
+ Approve AI coding changes from your phone—work continues while you're away.
6
+
7
+ ## Overview
8
+
9
+ Teleportation enables developers to approve Claude Code actions remotely from any device. The system intercepts Claude Code's tool requests via hooks, routes them to a relay API, and displays them in a mobile-friendly UI for instant approval.
10
+
11
+ ## Installation
12
+
13
+ ### Method 1: Quick Install (curl)
14
+
15
+ ```bash
16
+ curl -fsSL https://raw.githubusercontent.com/dundas/teleportation-private/main/scripts/install.sh | bash
17
+ ```
18
+
19
+ ### Method 2: From Source (Recommended)
20
+
21
+ **Requirements:** Bun >=1.3.0
22
+
23
+ ```bash
24
+ # Install Bun if you haven't already
25
+ curl -fsSL https://bun.sh/install | bash
26
+
27
+ # Clone and install
28
+ git clone https://github.com/dundas/teleportation-private.git
29
+ cd teleportation-private
30
+ bun install
31
+ bun link # Makes 'teleportation' command available globally
32
+ ```
33
+
34
+ ### Method 3: GitHub Releases
35
+
36
+ Download the latest release from [GitHub Releases](https://github.com/dundas/teleportation-private/releases/latest) and extract to `~/.teleportation/`.
37
+
38
+ ## Requirements
39
+
40
+ - **Bun >=1.3.0** (JavaScript runtime)
41
+ - **Claude Code CLI** installed
42
+
43
+ ### Installing Bun
44
+
45
+ ```bash
46
+ # macOS, Linux, WSL
47
+ curl -fsSL https://bun.sh/install | bash
48
+
49
+ # Windows (PowerShell)
50
+ powershell -c "irm bun.sh/install.ps1 | iex"
51
+ ```
52
+
53
+ ## Migrating from Node.js Version
54
+
55
+ If you're upgrading from a previous Node.js version (v0.x):
56
+
57
+ ```bash
58
+ # 1. Uninstall old version (if installed globally)
59
+ npm uninstall -g @teleportation/cli
60
+
61
+ # 2. Install Bun
62
+ curl -fsSL https://bun.sh/install | bash
63
+
64
+ # 3. Remove old dependencies
65
+ cd /path/to/teleportation
66
+ rm -rf node_modules package-lock.json
67
+
68
+ # 4. Install with Bun
69
+ bun install
70
+
71
+ # 5. Verify installation
72
+ ./teleportation-cli.cjs --version
73
+ ```
74
+
75
+ **Note:** Your hooks and credentials will be preserved during the upgrade.
76
+
77
+ ## Quick Start
78
+
79
+ ```bash
80
+ # 1. Enable hooks
81
+ teleportation on
82
+
83
+ # 2. Authenticate with your account
84
+ teleportation login
85
+
86
+ # 3. Check status
87
+ teleportation status
88
+
89
+ # 4. Start Claude Code - approvals will be routed to your phone!
90
+ ```
91
+
92
+ ## Usage
93
+
94
+ ### Basic Commands
95
+
96
+ ```bash
97
+ teleportation on # Enable remote approval hooks
98
+ teleportation off # Disable hooks (local mode)
99
+ teleportation status # Show current configuration
100
+ teleportation login # Authenticate with relay server
101
+ teleportation logout # Clear credentials
102
+ teleportation help # Show all commands
103
+ ```
104
+
105
+ ### Session Management
106
+
107
+ ```bash
108
+ teleportation session list # List active sessions
109
+ teleportation session info # Show current session details
110
+ teleportation session pause # Pause current session
111
+ teleportation session resume # Resume paused session
112
+ ```
113
+
114
+ ### Worktree Commands (Multi-session)
115
+
116
+ ```bash
117
+ teleportation worktree create <name> # Create isolated worktree
118
+ teleportation worktree list # List all worktrees
119
+ teleportation worktree use <name> # Switch to worktree
120
+ teleportation worktree merge <name> # Merge worktree changes
121
+ ```
122
+
123
+ ### Snapshot Commands
124
+
125
+ ```bash
126
+ teleportation snapshot create <name> # Create code snapshot
127
+ teleportation snapshot list # List snapshots
128
+ teleportation snapshot restore <name> # Restore to snapshot
129
+ teleportation snapshot diff <name> # Compare with snapshot
130
+ ```
131
+
132
+ ## How It Works
133
+
134
+ ```
135
+ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
136
+ │ Claude Code │────▶│ Relay API │────▶│ Mobile UI │
137
+ │ (Your Mac) │ │ (Cloud) │ │ (Your Phone) │
138
+ └─────────────────┘ └─────────────────┘ └─────────────────┘
139
+ │ │ │
140
+ │ 1. Tool request │ 2. Push to queue │
141
+ │ intercepted │ │
142
+ │ │ 3. Display for │
143
+ │ │ approval │
144
+ │ │ │
145
+ │ 5. Continue or │ 4. User approves/ │
146
+ │ block action │◀───denies │
147
+ │ │ │
148
+ ```
149
+
150
+ 1. **Intercept**: Claude Code hooks capture tool requests
151
+ 2. **Route**: Requests are sent to the relay API
152
+ 3. **Display**: Mobile UI shows pending approvals
153
+ 4. **Decide**: You approve or deny from your phone
154
+ 5. **Execute**: Claude Code continues or blocks the action
155
+
156
+ ## Project Structure
157
+
158
+ ```
159
+ teleportation/
160
+ ├── lib/
161
+ │ ├── auth/ # Credential encryption & management
162
+ │ ├── cli/ # CLI command implementations
163
+ │ ├── config/ # Configuration management
164
+ │ ├── install/ # Installation logic
165
+ │ └── session/ # Session metadata extraction
166
+ ├── relay/ # Relay API server
167
+ ├── mobile-ui/ # Mobile-friendly web UI
168
+ ├── .claude/
169
+ │ └── hooks/ # Claude Code hooks
170
+ ├── scripts/
171
+ │ └── install.sh # Installation script
172
+ ├── teleportation-cli.cjs # Main CLI tool
173
+ └── tests/ # Test suites
174
+ ```
175
+
176
+ ## Configuration
177
+
178
+ Configuration is stored in `~/.teleportation/`:
179
+
180
+ ```
181
+ ~/.teleportation/
182
+ ├── config.json # User preferences
183
+ ├── credentials.enc # Encrypted credentials
184
+ └── bin/
185
+ └── teleportation # CLI symlink
186
+ ```
187
+
188
+ Environment variables:
189
+ - `TELEPORTATION_RELAY_URL` - Custom relay server URL
190
+ - `TELEPORTATION_API_KEY` - API key for authentication
191
+
192
+ ## Self-Hosting
193
+
194
+ Want to run your own relay server?
195
+
196
+ ```bash
197
+ cd relay
198
+ cp .env.example .env
199
+ # Edit .env with your configuration
200
+ bun install
201
+ bun start
202
+ ```
203
+
204
+ See [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) for full deployment instructions.
205
+
206
+ ## Development
207
+
208
+ ```bash
209
+ # Install dependencies
210
+ bun install
211
+
212
+ # Run tests
213
+ bun test
214
+
215
+ # Run relay server locally
216
+ bun run dev:relay
217
+
218
+ # Run mobile UI locally
219
+ bun run dev:mobile
220
+
221
+ # Run everything
222
+ bun run dev:all
223
+ ```
224
+
225
+ ## Security
226
+
227
+ - **End-to-end encryption**: Credentials encrypted with AES-256
228
+ - **OAuth authentication**: Secure login via Google/GitHub
229
+ - **Multi-tenant isolation**: Your data is isolated from other users
230
+ - **Privacy-preserving**: Session existence not revealed to unauthorized users
231
+
232
+ ## License
233
+
234
+ MIT
235
+
236
+ ## Contributing
237
+
238
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
239
+
240
+ ## Support
241
+
242
+ - [GitHub Issues](https://github.com/dundas/teleportation-private/issues)
243
+ - [Documentation](https://github.com/dundas/teleportation-private/wiki)
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * API Key authentication utilities
4
+ * Validates API keys and tests them against the relay API
5
+ */
6
+
7
+ import { retryFetch } from '../utils/retry.js';
8
+
9
+ /**
10
+ * Validate API key format
11
+ * Basic validation - checks for reasonable length and format
12
+ */
13
+ export function validateApiKeyFormat(apiKey) {
14
+ if (!apiKey || typeof apiKey !== 'string') {
15
+ return { valid: false, error: 'API key must be a non-empty string' };
16
+ }
17
+
18
+ if (apiKey.length < 10) {
19
+ return { valid: false, error: 'API key is too short (minimum 10 characters)' };
20
+ }
21
+
22
+ if (apiKey.length > 256) {
23
+ return { valid: false, error: 'API key is too long (maximum 256 characters)' };
24
+ }
25
+
26
+ // Basic format check - should contain alphanumeric and some special chars
27
+ if (!/^[a-zA-Z0-9\-_\.]+$/.test(apiKey)) {
28
+ return { valid: false, error: 'API key contains invalid characters' };
29
+ }
30
+
31
+ return { valid: true };
32
+ }
33
+
34
+ /**
35
+ * Test API key against relay API with retry logic
36
+ */
37
+ export async function testApiKey(apiKey, relayApiUrl, retryOptions = {}) {
38
+ if (!relayApiUrl) {
39
+ return { valid: false, error: 'Relay API URL is required' };
40
+ }
41
+
42
+ try {
43
+ const response = await retryFetch(
44
+ `${relayApiUrl}/api/version`,
45
+ {
46
+ method: 'GET',
47
+ headers: {
48
+ 'Authorization': `Bearer ${apiKey}`,
49
+ 'Content-Type': 'application/json'
50
+ }
51
+ },
52
+ {
53
+ maxRetries: 2,
54
+ initialDelay: 1000,
55
+ timeout: 10000,
56
+ shouldRetry: (error) => {
57
+ // Don't retry on 401 (authentication failures)
58
+ if (error.status === 401) {
59
+ return false;
60
+ }
61
+ // Retry on network errors and 5xx server errors
62
+ return true;
63
+ },
64
+ ...retryOptions
65
+ }
66
+ );
67
+
68
+ if (response.ok) {
69
+ const data = await response.json();
70
+ return { valid: true, data };
71
+ } else if (response.status === 401) {
72
+ return { valid: false, error: 'Invalid API key - authentication failed. Please check your API key and try again.' };
73
+ } else if (response.status >= 500) {
74
+ return { valid: false, error: `Relay API server error (${response.status}). The server may be temporarily unavailable. Please try again later.` };
75
+ } else {
76
+ return { valid: false, error: `API returned unexpected status ${response.status}. Please check your relay API configuration.` };
77
+ }
78
+ } catch (error) {
79
+ if (error.name === 'AbortError' || error.message?.includes('timeout')) {
80
+ return { valid: false, error: `Connection timeout: Could not reach relay API at ${relayApiUrl}. Please check your network connection and try again.` };
81
+ }
82
+ if (error.code === 'ENOTFOUND') {
83
+ return { valid: false, error: `Cannot resolve relay API hostname. Please verify the URL is correct: ${relayApiUrl}` };
84
+ }
85
+ if (error.code === 'ECONNREFUSED') {
86
+ return { valid: false, error: `Connection refused: Relay API is not running at ${relayApiUrl}. Start it with 'teleportation start' or check your configuration.` };
87
+ }
88
+ return { valid: false, error: `Failed to test API key: ${error.message}. Please check your network connection and relay API status.` };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Validate and test API key
94
+ */
95
+ export async function validateApiKey(apiKey, relayApiUrl) {
96
+ // First validate format
97
+ const formatCheck = validateApiKeyFormat(apiKey);
98
+ if (!formatCheck.valid) {
99
+ return formatCheck;
100
+ }
101
+
102
+ // Then test against API if URL provided
103
+ if (relayApiUrl) {
104
+ return await testApiKey(apiKey, relayApiUrl);
105
+ }
106
+
107
+ // Format is valid but can't test without URL
108
+ return { valid: true, warning: 'API key format is valid but not tested (no relay URL)' };
109
+ }
110
+
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Credential encryption and storage management
4
+ * Uses AES-256 encryption with system keychain for key management
5
+ */
6
+
7
+ import { createCipheriv, createDecipheriv, randomBytes, scrypt } from 'crypto';
8
+ import { promisify } from 'util';
9
+ import { readFile, writeFile, unlink, stat, mkdir } from 'fs/promises';
10
+ import { join, dirname } from 'path';
11
+ import { homedir } from 'os';
12
+
13
+ const scryptAsync = promisify(scrypt);
14
+ const KEY_LENGTH = 32; // 256 bits for AES-256
15
+ const IV_LENGTH = 16; // 128 bits for AES IV
16
+ const SALT_LENGTH = 32;
17
+ const DEFAULT_CREDENTIALS_PATH = join(homedir(), '.teleportation', 'credentials');
18
+ const DEFAULT_KEY_PATH = join(homedir(), '.teleportation', '.key');
19
+
20
+ /**
21
+ * Get encryption key from system keychain or fallback to file-based key
22
+ */
23
+ async function getEncryptionKey(keyPath = DEFAULT_KEY_PATH) {
24
+ // Try to use system keychain first (platform-specific)
25
+ // For macOS: use security command
26
+ // For Linux: use secret-tool or fallback to file
27
+ // For Windows: use Credential Manager or fallback to file
28
+
29
+ const platform = process.platform;
30
+
31
+ if (platform === 'darwin') {
32
+ // macOS - try to use keychain
33
+ try {
34
+ const { execSync } = await import('child_process');
35
+ try {
36
+ const key = execSync(
37
+ 'security find-generic-password -a teleportation -s encryption-key -w 2>/dev/null',
38
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
39
+ ).trim();
40
+ if (key && key.length >= 32) {
41
+ return Buffer.from(key.slice(0, 64), 'hex'); // Use first 32 bytes
42
+ }
43
+ } catch (e) {
44
+ // Keychain entry doesn't exist, fall through to file-based
45
+ }
46
+ } catch (e) {
47
+ // security command not available, fall through
48
+ }
49
+ }
50
+
51
+ // Fallback: use file-based key with scrypt derivation
52
+ try {
53
+ const keyData = await readFile(keyPath, 'utf8');
54
+ const parsed = JSON.parse(keyData);
55
+
56
+ // If we have a stored derived key, use it directly
57
+ if (parsed.derivedKey) {
58
+ return Buffer.from(parsed.derivedKey, 'hex');
59
+ }
60
+
61
+ // Legacy format: derive from salt and master key
62
+ if (parsed.salt && parsed.masterKey) {
63
+ const key = await scryptAsync(Buffer.from(parsed.masterKey, 'hex'), Buffer.from(parsed.salt, 'hex'), KEY_LENGTH);
64
+ // Update to new format
65
+ await writeFile(
66
+ keyPath,
67
+ JSON.stringify({
68
+ derivedKey: Buffer.from(key).toString('hex')
69
+ }),
70
+ { mode: 0o600 }
71
+ );
72
+ return Buffer.from(key);
73
+ }
74
+
75
+ throw new Error('Invalid key file format');
76
+ } catch (e) {
77
+ if (e.code === 'ENOENT' || e.message === 'Invalid key file format') {
78
+ // Key file doesn't exist or is invalid, create a new one
79
+ const masterKey = randomBytes(KEY_LENGTH);
80
+ const salt = randomBytes(SALT_LENGTH);
81
+ const derivedKey = await scryptAsync(masterKey, salt, KEY_LENGTH);
82
+
83
+ // Ensure directory exists
84
+ await mkdir(dirname(keyPath), { recursive: true });
85
+
86
+ // Save derived key directly (32 bytes for AES-256)
87
+ await writeFile(
88
+ keyPath,
89
+ JSON.stringify({
90
+ derivedKey: Buffer.from(derivedKey).toString('hex')
91
+ }),
92
+ { mode: 0o600 } // Owner read/write only
93
+ );
94
+
95
+ return Buffer.from(derivedKey);
96
+ }
97
+ throw e;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Encrypt data using AES-256-GCM
103
+ */
104
+ function encrypt(data, key) {
105
+ const iv = randomBytes(IV_LENGTH);
106
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
107
+
108
+ let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'hex');
109
+ encrypted += cipher.final('hex');
110
+
111
+ const authTag = cipher.getAuthTag();
112
+
113
+ return {
114
+ data: encrypted,
115
+ iv: iv.toString('hex'),
116
+ authTag: authTag.toString('hex')
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Decrypt data using AES-256-GCM
122
+ */
123
+ function decrypt(encryptedData, key) {
124
+ const { data, iv, authTag } = encryptedData;
125
+
126
+ if (!data || !iv || !authTag) {
127
+ throw new Error('Invalid encrypted data structure');
128
+ }
129
+
130
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex'));
131
+ decipher.setAuthTag(Buffer.from(authTag, 'hex'));
132
+
133
+ let decrypted = decipher.update(data, 'hex', 'utf8');
134
+ decrypted += decipher.final('utf8');
135
+
136
+ return JSON.parse(decrypted);
137
+ }
138
+
139
+ /**
140
+ * CredentialManager - handles encryption, storage, and retrieval of credentials
141
+ */
142
+ export class CredentialManager {
143
+ constructor(credentialsPath = DEFAULT_CREDENTIALS_PATH, keyPath = DEFAULT_KEY_PATH) {
144
+ this.credentialsPath = credentialsPath;
145
+ this.keyPath = keyPath;
146
+ }
147
+
148
+ /**
149
+ * Save credentials to encrypted file
150
+ */
151
+ async save(credentials) {
152
+ if (!credentials || typeof credentials !== 'object') {
153
+ throw new Error('Credentials must be a non-null object');
154
+ }
155
+
156
+ // Ensure directory exists
157
+ await mkdir(dirname(this.credentialsPath), { recursive: true });
158
+
159
+ // Get encryption key
160
+ const key = await getEncryptionKey(this.keyPath);
161
+
162
+ // Encrypt credentials
163
+ const encrypted = encrypt(credentials, key);
164
+
165
+ // Add metadata
166
+ const fileData = {
167
+ version: '1.0',
168
+ createdAt: Date.now(),
169
+ ...encrypted
170
+ };
171
+
172
+ // Write encrypted file with 600 permissions
173
+ await writeFile(
174
+ this.credentialsPath,
175
+ JSON.stringify(fileData, null, 2),
176
+ { mode: 0o600 }
177
+ );
178
+ }
179
+
180
+ /**
181
+ * Load and decrypt credentials from file
182
+ */
183
+ async load() {
184
+ try {
185
+ const fileContent = await readFile(this.credentialsPath, 'utf8');
186
+ const fileData = JSON.parse(fileContent);
187
+
188
+ if (!fileData.data || !fileData.iv || !fileData.authTag) {
189
+ throw new Error('Invalid credential file format');
190
+ }
191
+
192
+ // Get encryption key
193
+ const key = await getEncryptionKey(this.keyPath);
194
+
195
+ // Decrypt credentials
196
+ const credentials = decrypt(
197
+ {
198
+ data: fileData.data,
199
+ iv: fileData.iv,
200
+ authTag: fileData.authTag
201
+ },
202
+ key
203
+ );
204
+
205
+ return credentials;
206
+ } catch (e) {
207
+ if (e.code === 'ENOENT') {
208
+ // File doesn't exist, return null
209
+ return null;
210
+ }
211
+ if (e.message === 'Invalid credential file format') {
212
+ throw new Error('Credential file is corrupted or invalid. Please re-authenticate by running: teleportation login\n\nThe existing credential file will be replaced.');
213
+ }
214
+ if (e.message.includes('decrypt') || e.message.includes('Unsupported state') || e.message.includes('bad decrypt')) {
215
+ throw new Error('Failed to decrypt credentials. This usually means:\n - The encryption key is missing or has been changed\n - The credential file was corrupted\n\nPlease re-authenticate by running: teleportation login');
216
+ }
217
+ if (e.message.includes('Invalid key file format')) {
218
+ throw new Error('Encryption key file is corrupted. Please re-authenticate by running: teleportation login\n\nThis will regenerate the encryption key.');
219
+ }
220
+ // Re-throw other errors with helpful message
221
+ throw new Error(`Failed to load credentials: ${e.message}\n\nIf this problem persists, try running: teleportation logout && teleportation login`);
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Check if credentials are expired
227
+ */
228
+ async isExpired() {
229
+ const credentials = await this.load();
230
+ if (!credentials || !credentials.expiresAt) {
231
+ return false; // No expiry set, consider valid
232
+ }
233
+
234
+ return Date.now() >= credentials.expiresAt;
235
+ }
236
+
237
+ /**
238
+ * Check if credentials need rotation (within 7 days of expiry or expired)
239
+ */
240
+ async needsRotation(warningDays = 7) {
241
+ const credentials = await this.load();
242
+ if (!credentials || !credentials.expiresAt) {
243
+ return false; // No expiry set, consider valid
244
+ }
245
+
246
+ const now = Date.now();
247
+ const expiresAt = credentials.expiresAt;
248
+ const warningThreshold = expiresAt - (warningDays * 24 * 60 * 60 * 1000);
249
+
250
+ return now >= warningThreshold;
251
+ }
252
+
253
+ /**
254
+ * Get days until expiry (negative if expired)
255
+ */
256
+ async daysUntilExpiry() {
257
+ const credentials = await this.load();
258
+ if (!credentials || !credentials.expiresAt) {
259
+ return null; // No expiry set
260
+ }
261
+
262
+ const now = Date.now();
263
+ const expiresAt = credentials.expiresAt;
264
+ const diffMs = expiresAt - now;
265
+ return Math.floor(diffMs / (24 * 60 * 60 * 1000));
266
+ }
267
+
268
+ /**
269
+ * Update credentials (for token refresh or re-authentication)
270
+ */
271
+ async update(updates) {
272
+ const current = await this.load();
273
+ if (!current) {
274
+ throw new Error('No existing credentials to update');
275
+ }
276
+
277
+ const updated = {
278
+ ...current,
279
+ ...updates,
280
+ updatedAt: Date.now()
281
+ };
282
+
283
+ await this.save(updated);
284
+ return updated;
285
+ }
286
+
287
+ /**
288
+ * Refresh access token if refresh token is available
289
+ * This is a placeholder - actual implementation depends on OAuth provider
290
+ */
291
+ async refreshToken() {
292
+ const credentials = await this.load();
293
+ if (!credentials || !credentials.refreshToken) {
294
+ throw new Error('No refresh token available');
295
+ }
296
+
297
+ // TODO: Implement actual token refresh with OAuth provider
298
+ // For now, this is a placeholder that would be implemented in oauth-client.js
299
+ throw new Error('Token refresh not yet implemented. Please run "teleportation login" to re-authenticate.');
300
+ }
301
+
302
+ /**
303
+ * Delete credentials file
304
+ */
305
+ async delete() {
306
+ try {
307
+ await unlink(this.credentialsPath);
308
+ } catch (e) {
309
+ if (e.code !== 'ENOENT') {
310
+ throw e;
311
+ }
312
+ // File doesn't exist, that's fine
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Verify file permissions are correct (600)
318
+ */
319
+ async verifyPermissions() {
320
+ try {
321
+ const stats = await stat(this.credentialsPath);
322
+ const mode = stats.mode & parseInt('777', 8);
323
+ return mode === parseInt('600', 8);
324
+ } catch (e) {
325
+ return false;
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Check if credentials exist
331
+ */
332
+ async exists() {
333
+ try {
334
+ await stat(this.credentialsPath);
335
+ return true;
336
+ } catch (e) {
337
+ return false;
338
+ }
339
+ }
340
+ }
341
+