teleportation-cli 1.1.5 → 1.2.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/permission_request.mjs +326 -59
- package/.claude/hooks/post_tool_use.mjs +90 -0
- package/.claude/hooks/pre_tool_use.mjs +212 -293
- package/.claude/hooks/session-register.mjs +89 -104
- package/.claude/hooks/session_end.mjs +41 -42
- package/.claude/hooks/session_start.mjs +45 -60
- package/.claude/hooks/stop.mjs +752 -99
- package/.claude/hooks/user_prompt_submit.mjs +26 -3
- package/lib/cli/daemon-commands.js +1 -1
- package/lib/cli/teleport-commands.js +469 -0
- package/lib/daemon/daemon-v2.js +104 -0
- package/lib/daemon/lifecycle.js +56 -171
- package/lib/daemon/services/index.js +3 -0
- package/lib/daemon/services/polling-service.js +173 -0
- package/lib/daemon/services/queue-service.js +318 -0
- package/lib/daemon/services/session-service.js +115 -0
- package/lib/daemon/state.js +35 -0
- package/lib/daemon/task-executor-v2.js +413 -0
- package/lib/daemon/task-executor.js +270 -96
- package/lib/daemon/teleportation-daemon.js +709 -126
- package/lib/daemon/timeline-analyzer.js +215 -0
- package/lib/daemon/transcript-ingestion.js +696 -0
- package/lib/daemon/utils.js +91 -0
- package/lib/install/installer.js +184 -20
- package/lib/install/uhr-installer.js +136 -0
- package/lib/remote/providers/base-provider.js +46 -0
- package/lib/remote/providers/daytona-provider.js +58 -0
- package/lib/remote/providers/provider-factory.js +90 -19
- package/lib/remote/providers/sprites-provider.js +711 -0
- package/lib/teleport/exporters/claude-exporter.js +302 -0
- package/lib/teleport/exporters/gemini-exporter.js +307 -0
- package/lib/teleport/exporters/index.js +93 -0
- package/lib/teleport/exporters/interface.js +153 -0
- package/lib/teleport/fork-tracker.js +415 -0
- package/lib/teleport/git-committer.js +337 -0
- package/lib/teleport/index.js +48 -0
- package/lib/teleport/manager.js +620 -0
- package/lib/teleport/session-capture.js +282 -0
- package/package.json +9 -5
- package/teleportation-cli.cjs +488 -453
- package/.claude/hooks/heartbeat.mjs +0 -396
- package/lib/daemon/pid-manager.js +0 -183
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sprites.dev Provider for Remote Development Environments
|
|
3
|
+
*
|
|
4
|
+
* Uses Sprites.dev (Fly.io's Firecracker micro-VMs) for ephemeral development workspaces.
|
|
5
|
+
* Sprites offer:
|
|
6
|
+
* - Sub-10 second boot times
|
|
7
|
+
* - 300ms checkpoint/restore
|
|
8
|
+
* - Auto-hibernate after 30 seconds of inactivity
|
|
9
|
+
* - Pay-per-compute pricing
|
|
10
|
+
*
|
|
11
|
+
* Ideal for:
|
|
12
|
+
* - Long-running tasks (overnight builds, large refactors)
|
|
13
|
+
* - Tasks requiring checkpoints/rollback
|
|
14
|
+
* - State preservation across sessions
|
|
15
|
+
*
|
|
16
|
+
* @see https://sprites.dev/docs/api
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { BaseProvider } from './base-provider.js';
|
|
20
|
+
|
|
21
|
+
export class SpritesProvider extends BaseProvider {
|
|
22
|
+
/**
|
|
23
|
+
* Initialize SpritesProvider
|
|
24
|
+
* @param {Object} config - Provider configuration
|
|
25
|
+
* @param {VaultClient} config.vaultClient - Mech Vault client
|
|
26
|
+
* @param {LivePortClient} config.livePortClient - LivePort client
|
|
27
|
+
* @param {string} [config.spritesToken] - Sprites API token (or SPRITES_TOKEN env var)
|
|
28
|
+
* @param {string} [config.apiUrl] - Sprites API URL (default: https://api.sprites.dev/v1)
|
|
29
|
+
* @param {number} [config.timeout] - Request timeout in ms (default: 60000)
|
|
30
|
+
*/
|
|
31
|
+
constructor(config) {
|
|
32
|
+
super(config);
|
|
33
|
+
this.apiKey = config.spritesToken || process.env.SPRITES_TOKEN;
|
|
34
|
+
this.apiUrl = config.apiUrl || process.env.SPRITES_API_URL || 'https://api.sprites.dev/v1';
|
|
35
|
+
this.timeout = config.timeout || 60000; // 60 second default (sprites boot is fast but setup may take time)
|
|
36
|
+
|
|
37
|
+
if (!this.apiKey) {
|
|
38
|
+
throw new Error('SPRITES_TOKEN is required for SpritesProvider');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create headers for Sprites API
|
|
44
|
+
* @private
|
|
45
|
+
* @returns {Object} HTTP headers
|
|
46
|
+
*/
|
|
47
|
+
_headers() {
|
|
48
|
+
return {
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Format API error response for better debugging
|
|
56
|
+
* @private
|
|
57
|
+
* @param {Response} response - Fetch response
|
|
58
|
+
* @param {string} url - Request URL
|
|
59
|
+
* @returns {Promise<string>} Formatted error message
|
|
60
|
+
*/
|
|
61
|
+
async _formatApiError(response, url) {
|
|
62
|
+
let body = '';
|
|
63
|
+
try {
|
|
64
|
+
body = await response.text();
|
|
65
|
+
} catch {
|
|
66
|
+
body = '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const truncated = body.length > 500 ? `${body.slice(0, 500)}…` : body;
|
|
70
|
+
return `Sprites API error: ${response.status} ${response.statusText} (${url})\n${truncated}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fetch with timeout support
|
|
75
|
+
* @private
|
|
76
|
+
* @param {string} url - URL to fetch
|
|
77
|
+
* @param {Object} options - Fetch options
|
|
78
|
+
* @param {number} [customTimeout] - Custom timeout in ms (overrides this.timeout)
|
|
79
|
+
* @returns {Promise<Response>} Fetch response
|
|
80
|
+
* @throws {Error} If request times out or fails
|
|
81
|
+
*/
|
|
82
|
+
async _fetchWithTimeout(url, options = {}, customTimeout = null) {
|
|
83
|
+
const timeoutMs = customTimeout || this.timeout;
|
|
84
|
+
const controller = new AbortController();
|
|
85
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const response = await fetch(url, {
|
|
89
|
+
...options,
|
|
90
|
+
signal: controller.signal,
|
|
91
|
+
});
|
|
92
|
+
clearTimeout(timeoutId);
|
|
93
|
+
return response;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
clearTimeout(timeoutId);
|
|
96
|
+
if (error.name === 'AbortError') {
|
|
97
|
+
throw new Error(`Request timed out after ${timeoutMs}ms: ${url}`);
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Escape shell argument by wrapping in single quotes and escaping any single quotes.
|
|
105
|
+
* This is critical for preventing shell injection attacks.
|
|
106
|
+
*
|
|
107
|
+
* @private
|
|
108
|
+
* @param {string} arg - Argument to escape
|
|
109
|
+
* @returns {string} Shell-safe escaped argument
|
|
110
|
+
*/
|
|
111
|
+
_escapeShellArg(arg) {
|
|
112
|
+
if (arg === null || arg === undefined) return "''";
|
|
113
|
+
// Convert to string and replace any single quotes with '\'' (end quote, escaped quote, start quote)
|
|
114
|
+
return `'${String(arg).replace(/'/g, "'\\''")}'`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Sanitize sprite name to match API requirements (alphanumeric, dashes, underscores only)
|
|
119
|
+
* @private
|
|
120
|
+
* @param {string} sessionId - Session identifier
|
|
121
|
+
* @returns {string} Sanitized sprite name
|
|
122
|
+
*/
|
|
123
|
+
_sanitizeSpriteName(sessionId) {
|
|
124
|
+
// Remove non-alphanumeric except dashes and underscores, limit length
|
|
125
|
+
const sanitized = String(sessionId)
|
|
126
|
+
.replace(/[^a-zA-Z0-9_-]/g, '')
|
|
127
|
+
.substring(0, 50);
|
|
128
|
+
return `teleport-${sanitized}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Create a remote Sprites workspace
|
|
133
|
+
*
|
|
134
|
+
* Provisions a new Sprites micro-VM with:
|
|
135
|
+
* - Git repository cloned automatically
|
|
136
|
+
* - SSH access configured
|
|
137
|
+
* - LivePort tunnel setup
|
|
138
|
+
* - All secrets stored in Vault
|
|
139
|
+
* - Auto-hibernate when inactive
|
|
140
|
+
*
|
|
141
|
+
* @param {Object} sessionConfig - Session configuration (see BaseProvider)
|
|
142
|
+
* @returns {Promise<Object>} Sprite details with IDs for cleanup
|
|
143
|
+
* @throws {Error} If sprite creation fails
|
|
144
|
+
*/
|
|
145
|
+
async createMachine(sessionConfig) {
|
|
146
|
+
this._validateSessionConfig(sessionConfig);
|
|
147
|
+
|
|
148
|
+
const sessionId = sessionConfig.sessionId;
|
|
149
|
+
const spriteName = this._sanitizeSpriteName(sessionId);
|
|
150
|
+
const namespace = `teleportationsprite${String(sessionId).replace(/[^a-zA-Z0-9]/g, '')}`;
|
|
151
|
+
|
|
152
|
+
// 1. Setup SSH access
|
|
153
|
+
const ssh = await this._setupSSHAccess(sessionId, namespace);
|
|
154
|
+
|
|
155
|
+
// 2. Setup LivePort tunnel
|
|
156
|
+
const tunnel = await this._setupTunnel(sessionId, spriteName);
|
|
157
|
+
|
|
158
|
+
// 3. Store necessary secrets in Vault
|
|
159
|
+
const secrets = {
|
|
160
|
+
RELAY_API_URL: sessionConfig.relayApiUrl,
|
|
161
|
+
RELAY_API_KEY: sessionConfig.relayApiKey,
|
|
162
|
+
GITHUB_TOKEN: sessionConfig.githubToken,
|
|
163
|
+
MECH_API_KEY: sessionConfig.mechApiKey,
|
|
164
|
+
LIVEPORT_BRIDGE_KEY: tunnel.bridgeKey,
|
|
165
|
+
...sessionConfig.additionalSecrets,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const secretIds = await this._storeSecrets(namespace, secrets);
|
|
169
|
+
|
|
170
|
+
// 4. Create sprite configuration
|
|
171
|
+
const spriteConfig = {
|
|
172
|
+
name: spriteName,
|
|
173
|
+
url_settings: {
|
|
174
|
+
auth: 'sprite', // Use Sprites-native auth
|
|
175
|
+
},
|
|
176
|
+
// Image with development tools pre-installed
|
|
177
|
+
image: sessionConfig.image || 'oven/bun:latest',
|
|
178
|
+
// Resource allocation
|
|
179
|
+
guest: {
|
|
180
|
+
cpus: sessionConfig.cpus || 1,
|
|
181
|
+
memory_mb: sessionConfig.memory || 2048,
|
|
182
|
+
},
|
|
183
|
+
// Metadata for tracking
|
|
184
|
+
metadata: {
|
|
185
|
+
session_id: sessionId,
|
|
186
|
+
provider: 'SpritesProvider',
|
|
187
|
+
created_at: new Date().toISOString(),
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// 5. Create the sprite
|
|
192
|
+
const createUrl = `${this.apiUrl}/sprites`;
|
|
193
|
+
const response = await this._fetchWithTimeout(createUrl, {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
headers: this._headers(),
|
|
196
|
+
body: JSON.stringify(spriteConfig),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
// Cleanup on failure
|
|
201
|
+
await this._cleanup(namespace, tunnel.keyId, ssh.keyId);
|
|
202
|
+
throw new Error(await this._formatApiError(response, createUrl));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const sprite = await response.json();
|
|
206
|
+
|
|
207
|
+
// 6. Execute bootstrap script on the sprite
|
|
208
|
+
try {
|
|
209
|
+
const bootstrapScript = this._generateBootstrapScript(sessionConfig, tunnel, ssh);
|
|
210
|
+
await this._executeBootstrap(spriteName, bootstrapScript);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
// Cleanup on bootstrap failure
|
|
213
|
+
console.error(`[sprites-provider] Bootstrap failed: ${error.message}`);
|
|
214
|
+
await this.destroyMachine(spriteName).catch(() => {});
|
|
215
|
+
await this._cleanup(namespace, tunnel.keyId, ssh.keyId);
|
|
216
|
+
throw new Error(`Sprite bootstrap failed: ${error.message}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
machineId: spriteName,
|
|
221
|
+
spriteName,
|
|
222
|
+
spriteUrl: sprite.url || `https://${spriteName}.sprites.dev`,
|
|
223
|
+
tunnelUrl: tunnel.tunnelUrl,
|
|
224
|
+
namespace,
|
|
225
|
+
secretIds,
|
|
226
|
+
sshKeyId: ssh.keyId,
|
|
227
|
+
bridgeKeyId: tunnel.keyId,
|
|
228
|
+
status: sprite.state || 'running',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Generate bootstrap script for Sprites workspace
|
|
234
|
+
*
|
|
235
|
+
* This script runs after the sprite is created and sets up:
|
|
236
|
+
* - SSH authorized keys
|
|
237
|
+
* - Git repository clone
|
|
238
|
+
* - Apply uncommitted changes (if any)
|
|
239
|
+
* - Environment variables
|
|
240
|
+
* - Transcript file placement
|
|
241
|
+
* - LivePort tunnel
|
|
242
|
+
* - CLI resume
|
|
243
|
+
*
|
|
244
|
+
* SECURITY: All user-provided values MUST be escaped to prevent shell injection.
|
|
245
|
+
*
|
|
246
|
+
* @private
|
|
247
|
+
* @param {Object} sessionConfig - Session configuration
|
|
248
|
+
* @param {Object} tunnel - Tunnel configuration with tunnelCommand
|
|
249
|
+
* @param {Object} ssh - SSH key with publicKey
|
|
250
|
+
* @returns {string} Shell script for sprite initialization
|
|
251
|
+
*/
|
|
252
|
+
_generateBootstrapScript(sessionConfig, tunnel, ssh) {
|
|
253
|
+
// CRITICAL: Escape all user-provided values to prevent command injection
|
|
254
|
+
const safePublicKey = this._escapeShellArg(ssh.publicKey);
|
|
255
|
+
const safeRepoUrl = this._escapeShellArg(sessionConfig.repoUrl);
|
|
256
|
+
const safeBranch = this._escapeShellArg(sessionConfig.branch || 'main');
|
|
257
|
+
const safeSessionId = this._escapeShellArg(sessionConfig.sessionId);
|
|
258
|
+
const safeTask = this._escapeShellArg(sessionConfig.task || '');
|
|
259
|
+
const safeRelayApiUrl = this._escapeShellArg(sessionConfig.relayApiUrl);
|
|
260
|
+
const safeRelayApiKey = this._escapeShellArg(sessionConfig.relayApiKey);
|
|
261
|
+
const safeGithubToken = this._escapeShellArg(sessionConfig.githubToken || '');
|
|
262
|
+
const safeTunnelCommand = tunnel.tunnelCommand; // This is generated internally, not user input
|
|
263
|
+
|
|
264
|
+
// Build script with escaped values
|
|
265
|
+
return `#!/bin/bash
|
|
266
|
+
set -e
|
|
267
|
+
|
|
268
|
+
echo "[bootstrap] Starting sprite initialization..."
|
|
269
|
+
|
|
270
|
+
# Setup SSH key for access
|
|
271
|
+
mkdir -p ~/.ssh
|
|
272
|
+
echo ${safePublicKey} >> ~/.ssh/authorized_keys
|
|
273
|
+
chmod 700 ~/.ssh
|
|
274
|
+
chmod 600 ~/.ssh/authorized_keys
|
|
275
|
+
|
|
276
|
+
# Configure git to use token for authentication
|
|
277
|
+
# SECURITY: Use printf with %s to safely interpolate the token without shell interpretation
|
|
278
|
+
# This prevents command injection even if the token contains special characters
|
|
279
|
+
git config --global credential.helper store
|
|
280
|
+
if [ -n ${safeGithubToken} ] && [ ${safeGithubToken} != "''" ]; then
|
|
281
|
+
printf 'https://oauth2:%s@github.com\n' ${safeGithubToken} > ~/.git-credentials
|
|
282
|
+
chmod 600 ~/.git-credentials
|
|
283
|
+
fi
|
|
284
|
+
|
|
285
|
+
# Clone repository
|
|
286
|
+
echo "[bootstrap] Cloning repository..."
|
|
287
|
+
cd /workspace
|
|
288
|
+
git clone ${safeRepoUrl} repo || { echo "Git clone failed"; exit 1; }
|
|
289
|
+
cd repo
|
|
290
|
+
|
|
291
|
+
# Checkout specified branch
|
|
292
|
+
git checkout ${safeBranch} || git checkout -b ${safeBranch}
|
|
293
|
+
|
|
294
|
+
# Apply uncommitted changes if provided
|
|
295
|
+
if [ -n "${sessionConfig.patch ? '1' : ''}" ]; then
|
|
296
|
+
echo "[bootstrap] Applying uncommitted changes..."
|
|
297
|
+
echo ${this._escapeShellArg(sessionConfig.patch || '')} | base64 -d | git apply --allow-empty || echo "Patch apply warning (continuing)"
|
|
298
|
+
fi
|
|
299
|
+
|
|
300
|
+
# Set environment variables
|
|
301
|
+
export RELAY_API_URL=${safeRelayApiUrl}
|
|
302
|
+
export RELAY_API_KEY=${safeRelayApiKey}
|
|
303
|
+
export SESSION_ID=${safeSessionId}
|
|
304
|
+
|
|
305
|
+
# Install dependencies
|
|
306
|
+
echo "[bootstrap] Installing dependencies..."
|
|
307
|
+
bun install || npm install
|
|
308
|
+
|
|
309
|
+
# Start LivePort tunnel in background
|
|
310
|
+
echo "[bootstrap] Starting LivePort tunnel..."
|
|
311
|
+
${safeTunnelCommand} &
|
|
312
|
+
TUNNEL_PID=$!
|
|
313
|
+
|
|
314
|
+
# Wait for tunnel to be ready
|
|
315
|
+
for i in 1 2 3 4 5; do
|
|
316
|
+
if ! kill -0 "$TUNNEL_PID" 2>/dev/null; then
|
|
317
|
+
echo "[bootstrap] LivePort tunnel failed to start"
|
|
318
|
+
exit 1
|
|
319
|
+
fi
|
|
320
|
+
sleep 1
|
|
321
|
+
done
|
|
322
|
+
|
|
323
|
+
echo "[bootstrap] Sprite ready!"
|
|
324
|
+
|
|
325
|
+
# If a task was provided, start the teleportation daemon
|
|
326
|
+
if [ -n ${safeTask} ] && [ ${safeTask} != "''" ]; then
|
|
327
|
+
echo "[bootstrap] Starting task: ${safeTask}"
|
|
328
|
+
bun teleportation-cli.cjs daemon start --session-id ${safeSessionId} --task ${safeTask} &
|
|
329
|
+
fi
|
|
330
|
+
`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Execute bootstrap script on sprite
|
|
335
|
+
*
|
|
336
|
+
* Bootstrap operations (git clone, package install, etc.) can take significant time,
|
|
337
|
+
* so we use a longer timeout (5 minutes) than normal API requests.
|
|
338
|
+
*
|
|
339
|
+
* @private
|
|
340
|
+
* @param {string} spriteName - Sprite name
|
|
341
|
+
* @param {string} script - Bootstrap script to execute
|
|
342
|
+
* @returns {Promise<Object>} Execution result
|
|
343
|
+
* @throws {Error} If bootstrap times out or fails
|
|
344
|
+
*/
|
|
345
|
+
async _executeBootstrap(spriteName, script) {
|
|
346
|
+
const BOOTSTRAP_TIMEOUT_MS = 300000; // 5 minutes for git clone + npm/bun install
|
|
347
|
+
|
|
348
|
+
const result = await this.executeCommand(spriteName, script, { timeout: BOOTSTRAP_TIMEOUT_MS });
|
|
349
|
+
if (result.exitCode !== 0) {
|
|
350
|
+
throw new Error(`Bootstrap script failed with exit code ${result.exitCode}: ${result.stderr}`);
|
|
351
|
+
}
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Execute command on sprite
|
|
357
|
+
*
|
|
358
|
+
* @param {string} spriteName - Sprite name
|
|
359
|
+
* @param {string} command - Command to execute
|
|
360
|
+
* @param {Object} [options] - Execution options
|
|
361
|
+
* @param {number} [options.timeout] - Custom timeout in ms (default: this.timeout)
|
|
362
|
+
* @returns {Promise<Object>} Execution result
|
|
363
|
+
* @returns {string} return.stdout - Standard output
|
|
364
|
+
* @returns {string} return.stderr - Standard error
|
|
365
|
+
* @returns {number} return.exitCode - Exit code
|
|
366
|
+
* @throws {Error} If execution fails or times out
|
|
367
|
+
*/
|
|
368
|
+
async executeCommand(spriteName, command, options = {}) {
|
|
369
|
+
this._validateMachineId(spriteName);
|
|
370
|
+
|
|
371
|
+
const url = `${this.apiUrl}/sprites/${spriteName}/exec`;
|
|
372
|
+
const response = await this._fetchWithTimeout(url, {
|
|
373
|
+
method: 'POST',
|
|
374
|
+
headers: this._headers(),
|
|
375
|
+
body: JSON.stringify({ command }),
|
|
376
|
+
}, options.timeout);
|
|
377
|
+
|
|
378
|
+
if (!response.ok) {
|
|
379
|
+
throw new Error(await this._formatApiError(response, url));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const result = await response.json();
|
|
383
|
+
return {
|
|
384
|
+
stdout: result.stdout || '',
|
|
385
|
+
stderr: result.stderr || '',
|
|
386
|
+
exitCode: result.exit_code ?? result.exitCode ?? 0,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Create a checkpoint of the sprite's current state
|
|
392
|
+
*
|
|
393
|
+
* Sprites checkpoints are fast (~300ms) and allow instant restore.
|
|
394
|
+
*
|
|
395
|
+
* @param {string} spriteName - Sprite name
|
|
396
|
+
* @param {string} [comment] - Optional comment for the checkpoint
|
|
397
|
+
* @returns {Promise<Object>} Checkpoint details
|
|
398
|
+
* @returns {string} return.checkpointId - Unique checkpoint ID
|
|
399
|
+
* @returns {string} return.createdAt - Checkpoint creation timestamp
|
|
400
|
+
* @throws {Error} If checkpoint creation fails
|
|
401
|
+
*/
|
|
402
|
+
async createCheckpoint(spriteName, comment = '') {
|
|
403
|
+
this._validateMachineId(spriteName);
|
|
404
|
+
|
|
405
|
+
const url = `${this.apiUrl}/sprites/${spriteName}/checkpoints`;
|
|
406
|
+
const response = await this._fetchWithTimeout(url, {
|
|
407
|
+
method: 'POST',
|
|
408
|
+
headers: this._headers(),
|
|
409
|
+
body: JSON.stringify({
|
|
410
|
+
comment: comment || `Checkpoint at ${new Date().toISOString()}`,
|
|
411
|
+
}),
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
if (!response.ok) {
|
|
415
|
+
throw new Error(await this._formatApiError(response, url));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const checkpoint = await response.json();
|
|
419
|
+
return {
|
|
420
|
+
checkpointId: checkpoint.id,
|
|
421
|
+
createdAt: checkpoint.created_at || checkpoint.createdAt,
|
|
422
|
+
comment: checkpoint.comment,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* List all checkpoints for a sprite
|
|
428
|
+
*
|
|
429
|
+
* @param {string} spriteName - Sprite name
|
|
430
|
+
* @returns {Promise<Array>} Array of checkpoint objects
|
|
431
|
+
* @throws {Error} If listing fails
|
|
432
|
+
*/
|
|
433
|
+
async listCheckpoints(spriteName) {
|
|
434
|
+
this._validateMachineId(spriteName);
|
|
435
|
+
|
|
436
|
+
const url = `${this.apiUrl}/sprites/${spriteName}/checkpoints`;
|
|
437
|
+
const response = await this._fetchWithTimeout(url, {
|
|
438
|
+
method: 'GET',
|
|
439
|
+
headers: this._headers(),
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
if (!response.ok) {
|
|
443
|
+
throw new Error(await this._formatApiError(response, url));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const data = await response.json();
|
|
447
|
+
return data.checkpoints || data || [];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Restore sprite to a previous checkpoint
|
|
452
|
+
*
|
|
453
|
+
* @param {string} spriteName - Sprite name
|
|
454
|
+
* @param {string} checkpointId - Checkpoint ID to restore
|
|
455
|
+
* @returns {Promise<Object>} Restore result
|
|
456
|
+
* @throws {Error} If restore fails
|
|
457
|
+
*/
|
|
458
|
+
async restoreCheckpoint(spriteName, checkpointId) {
|
|
459
|
+
this._validateMachineId(spriteName);
|
|
460
|
+
|
|
461
|
+
if (!checkpointId) {
|
|
462
|
+
throw new Error('Checkpoint ID is required for restore');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const url = `${this.apiUrl}/sprites/${spriteName}/restore`;
|
|
466
|
+
const response = await this._fetchWithTimeout(url, {
|
|
467
|
+
method: 'POST',
|
|
468
|
+
headers: this._headers(),
|
|
469
|
+
body: JSON.stringify({ checkpoint_id: checkpointId }),
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
if (!response.ok) {
|
|
473
|
+
throw new Error(await this._formatApiError(response, url));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return response.json();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Destroy the sprite
|
|
481
|
+
*
|
|
482
|
+
* Permanently deletes the sprite and all its checkpoints.
|
|
483
|
+
*
|
|
484
|
+
* @param {string} spriteName - Sprite name
|
|
485
|
+
* @returns {Promise<boolean>} True if destroyed successfully
|
|
486
|
+
* @throws {Error} If destruction fails (except 404 which is success)
|
|
487
|
+
*/
|
|
488
|
+
async destroyMachine(spriteName) {
|
|
489
|
+
this._validateMachineId(spriteName);
|
|
490
|
+
|
|
491
|
+
const url = `${this.apiUrl}/sprites/${spriteName}`;
|
|
492
|
+
const response = await this._fetchWithTimeout(url, {
|
|
493
|
+
method: 'DELETE',
|
|
494
|
+
headers: this._headers(),
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// 404 means already deleted - treat as success
|
|
498
|
+
if (response.status === 404) {
|
|
499
|
+
return true;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (!response.ok) {
|
|
503
|
+
throw new Error(await this._formatApiError(response, url));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Get sprite status
|
|
511
|
+
*
|
|
512
|
+
* Returns current state of the sprite.
|
|
513
|
+
*
|
|
514
|
+
* @param {string} spriteName - Sprite name
|
|
515
|
+
* @returns {Promise<Object>} Sprite status
|
|
516
|
+
* @returns {string} return.status - Sprite state ('cold', 'warm', 'running')
|
|
517
|
+
* @returns {string} return.url - Sprite URL
|
|
518
|
+
* @returns {string} return.createdAt - Creation timestamp
|
|
519
|
+
* @returns {string} return.lastActive - Last activity timestamp
|
|
520
|
+
* @throws {Error} If status fetch fails
|
|
521
|
+
*/
|
|
522
|
+
async getMachineStatus(spriteName) {
|
|
523
|
+
this._validateMachineId(spriteName);
|
|
524
|
+
|
|
525
|
+
const url = `${this.apiUrl}/sprites/${spriteName}`;
|
|
526
|
+
const response = await this._fetchWithTimeout(url, {
|
|
527
|
+
method: 'GET',
|
|
528
|
+
headers: this._headers(),
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
if (!response.ok) {
|
|
532
|
+
throw new Error(await this._formatApiError(response, url));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const sprite = await response.json();
|
|
536
|
+
|
|
537
|
+
// Map Sprites states to standardized status
|
|
538
|
+
const statusMap = {
|
|
539
|
+
'cold': 'stopped',
|
|
540
|
+
'warm': 'hibernating',
|
|
541
|
+
'running': 'running',
|
|
542
|
+
'starting': 'starting',
|
|
543
|
+
'stopping': 'stopping',
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
status: statusMap[sprite.state] || sprite.state || 'unknown',
|
|
548
|
+
url: sprite.url,
|
|
549
|
+
createdAt: sprite.created_at || sprite.createdAt,
|
|
550
|
+
lastActive: sprite.last_active || sprite.lastActive,
|
|
551
|
+
state: sprite.state, // Original Sprites state
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Get sprite logs
|
|
557
|
+
*
|
|
558
|
+
* @param {string} spriteName - Sprite name
|
|
559
|
+
* @param {Object} [options] - Log retrieval options
|
|
560
|
+
* @param {number} [options.tail] - Number of lines to retrieve
|
|
561
|
+
* @param {boolean} [options.follow] - Stream logs (not implemented)
|
|
562
|
+
* @returns {Promise<Object>} Log data
|
|
563
|
+
* @throws {Error} If log retrieval fails
|
|
564
|
+
*/
|
|
565
|
+
async getLogs(spriteName, options = {}) {
|
|
566
|
+
this._validateMachineId(spriteName);
|
|
567
|
+
|
|
568
|
+
const params = new URLSearchParams();
|
|
569
|
+
if (options.tail) params.set('tail', options.tail);
|
|
570
|
+
|
|
571
|
+
const url = `${this.apiUrl}/sprites/${spriteName}/logs${params.toString() ? '?' + params : ''}`;
|
|
572
|
+
const response = await this._fetchWithTimeout(url, {
|
|
573
|
+
method: 'GET',
|
|
574
|
+
headers: this._headers(),
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
if (!response.ok) {
|
|
578
|
+
throw new Error(await this._formatApiError(response, url));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const data = await response.json();
|
|
582
|
+
return {
|
|
583
|
+
logs: data.logs || data || [],
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Wake up a hibernating sprite
|
|
589
|
+
*
|
|
590
|
+
* Sprites auto-hibernate after 30 seconds of inactivity.
|
|
591
|
+
* This method wakes them up.
|
|
592
|
+
*
|
|
593
|
+
* @param {string} spriteName - Sprite name
|
|
594
|
+
* @returns {Promise<Object>} Wake result
|
|
595
|
+
* @throws {Error} If wake fails
|
|
596
|
+
*/
|
|
597
|
+
async wakeSprite(spriteName) {
|
|
598
|
+
this._validateMachineId(spriteName);
|
|
599
|
+
|
|
600
|
+
const url = `${this.apiUrl}/sprites/${spriteName}/wake`;
|
|
601
|
+
const response = await this._fetchWithTimeout(url, {
|
|
602
|
+
method: 'POST',
|
|
603
|
+
headers: this._headers(),
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
if (!response.ok) {
|
|
607
|
+
throw new Error(await this._formatApiError(response, url));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return response.json();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Get provider capabilities
|
|
615
|
+
*
|
|
616
|
+
* @returns {Object} Provider capabilities
|
|
617
|
+
*/
|
|
618
|
+
getCapabilities() {
|
|
619
|
+
return {
|
|
620
|
+
supportsCheckpoints: true,
|
|
621
|
+
supportsSnapshots: true,
|
|
622
|
+
supportsHibernation: true,
|
|
623
|
+
supportsAutoStop: true,
|
|
624
|
+
autoHibernate: true,
|
|
625
|
+
bootTime: 10000, // ~10 seconds
|
|
626
|
+
checkpointTime: 300, // ~300ms
|
|
627
|
+
supportsResume: true,
|
|
628
|
+
provider: 'sprites',
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Check provider health
|
|
634
|
+
*
|
|
635
|
+
* @returns {Promise<Object>} Health check result
|
|
636
|
+
*/
|
|
637
|
+
async checkHealth() {
|
|
638
|
+
try {
|
|
639
|
+
// Try to list sprites - if this works, the API is healthy
|
|
640
|
+
const url = `${this.apiUrl}/sprites`;
|
|
641
|
+
const response = await this._fetchWithTimeout(url, {
|
|
642
|
+
method: 'GET',
|
|
643
|
+
headers: this._headers(),
|
|
644
|
+
}, 10000); // 10 second timeout for health check
|
|
645
|
+
|
|
646
|
+
if (!response.ok) {
|
|
647
|
+
return {
|
|
648
|
+
healthy: false,
|
|
649
|
+
provider: 'sprites',
|
|
650
|
+
error: `API returned status ${response.status}`,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return {
|
|
655
|
+
healthy: true,
|
|
656
|
+
provider: 'sprites',
|
|
657
|
+
details: {
|
|
658
|
+
apiUrl: this.apiUrl,
|
|
659
|
+
timestamp: new Date().toISOString(),
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
} catch (error) {
|
|
663
|
+
return {
|
|
664
|
+
healthy: false,
|
|
665
|
+
provider: 'sprites',
|
|
666
|
+
error: error.message,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Update sprite labels/tags
|
|
673
|
+
*
|
|
674
|
+
* @param {string} spriteName - Sprite name
|
|
675
|
+
* @param {Object} labels - Key-value pairs of labels
|
|
676
|
+
* @returns {Promise<Object>} Updated sprite info
|
|
677
|
+
*/
|
|
678
|
+
async updateLabels(spriteName, labels) {
|
|
679
|
+
this._validateMachineId(spriteName);
|
|
680
|
+
|
|
681
|
+
if (!labels || typeof labels !== 'object') {
|
|
682
|
+
throw new Error('Labels must be an object');
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const url = `${this.apiUrl}/sprites/${spriteName}`;
|
|
686
|
+
const response = await this._fetchWithTimeout(url, {
|
|
687
|
+
method: 'PATCH',
|
|
688
|
+
headers: this._headers(),
|
|
689
|
+
body: JSON.stringify({ labels }),
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
if (!response.ok) {
|
|
693
|
+
throw new Error(await this._formatApiError(response, url));
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return response.json();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Get sprite labels
|
|
701
|
+
*
|
|
702
|
+
* @param {string} spriteName - Sprite name
|
|
703
|
+
* @returns {Promise<Object>} Labels object
|
|
704
|
+
*/
|
|
705
|
+
async getLabels(spriteName) {
|
|
706
|
+
const status = await this.getMachineStatus(spriteName);
|
|
707
|
+
return status.labels || {};
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export default SpritesProvider;
|