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.
@@ -0,0 +1,611 @@
1
+ /**
2
+ * Fly.io Provider for Remote Development Environments
3
+ *
4
+ * Uses Fly.io Machines API for persistent remote development environments.
5
+ * Machines are persistent VMs that can be stopped/started without losing state.
6
+ *
7
+ * Ideal for:
8
+ * - Long-running tasks (overnight work, multi-day projects)
9
+ * - Cost-efficient: pay only when machine is running
10
+ * - Persistent storage between stops/starts
11
+ *
12
+ * @see https://fly.io/docs/machines/api/
13
+ */
14
+
15
+ import { BaseProvider } from './base-provider.js';
16
+
17
+ export class FlyProvider extends BaseProvider {
18
+ /**
19
+ * Initialize FlyProvider
20
+ * @param {Object} config - Provider configuration
21
+ * @param {VaultClient} config.vaultClient - Mech Vault client
22
+ * @param {LivePortClient} config.livePortClient - LivePort client
23
+ * @param {string} [config.flyApiToken] - Fly.io API token (or FLY_API_TOKEN env var)
24
+ * @param {string} [config.appName] - Fly app name (or FLY_APP_NAME env var, defaults to 'teleportation-remote')
25
+ * @param {string} [config.region] - Fly region code (defaults to 'iad' - Ashburn, VA)
26
+ */
27
+ constructor(config) {
28
+ super(config);
29
+ this.apiToken = config.flyApiToken || process.env.FLY_API_TOKEN;
30
+ this.apiUrl = 'https://api.machines.dev/v1';
31
+ this.appName = config.appName || process.env.FLY_APP_NAME || 'teleportation-remote';
32
+ this.region = config.region || 'iad'; // Default to Ashburn, VA
33
+ this.timeout = config.timeout || 30000; // 30 second default timeout
34
+
35
+ if (!this.apiToken) {
36
+ throw new Error('FLY_API_TOKEN is required for FlyProvider');
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Create headers for Fly.io API
42
+ * @private
43
+ */
44
+ _headers() {
45
+ const authToken = this.apiToken.startsWith('Bearer ') ? this.apiToken : `Bearer ${this.apiToken}`;
46
+ return {
47
+ 'Content-Type': 'application/json',
48
+ 'Authorization': authToken,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Escape shell argument by wrapping in single quotes and escaping any single quotes
54
+ * @private
55
+ * @param {string} arg - Argument to escape
56
+ * @returns {string} Shell-safe escaped argument
57
+ */
58
+ _escapeShellArg(arg) {
59
+ if (!arg) return "''";
60
+ // Replace any single quotes with '\'' (end quote, escaped quote, start quote)
61
+ return `'${arg.replace(/'/g, "'\\''")}'`;
62
+ }
63
+
64
+ /**
65
+ * Fetch with timeout support
66
+ * @private
67
+ * @param {string} url - URL to fetch
68
+ * @param {Object} options - Fetch options
69
+ * @returns {Promise<Response>} Fetch response
70
+ * @throws {Error} If request times out or fails
71
+ */
72
+ async _fetchWithTimeout(url, options = {}) {
73
+ const controller = new AbortController();
74
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
75
+
76
+ try {
77
+ const response = await fetch(url, {
78
+ ...options,
79
+ signal: controller.signal,
80
+ });
81
+ clearTimeout(timeoutId);
82
+ return response;
83
+ } catch (error) {
84
+ clearTimeout(timeoutId);
85
+ if (error.name === 'AbortError') {
86
+ throw new Error(`Request timed out after ${this.timeout}ms: ${url}`);
87
+ }
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Create a remote Fly.io machine
94
+ *
95
+ * Provisions a new Fly machine with:
96
+ * - Bun runtime (oven/bun:latest)
97
+ * - SSH access configured
98
+ * - LivePort tunnel setup
99
+ * - All secrets stored in Vault
100
+ * - Git repository cloned
101
+ * - Teleportation daemon started
102
+ *
103
+ * @param {Object} sessionConfig - Session configuration (see BaseProvider)
104
+ * @returns {Promise<Object>} Machine details with IDs for cleanup
105
+ * @throws {Error} If machine provisioning fails
106
+ */
107
+ async createMachine(sessionConfig) {
108
+ const sessionId = sessionConfig.sessionId;
109
+ const namespace = `teleportationremote${sessionId.replace(/-/g, '')}`;
110
+
111
+ // 1. Setup SSH access
112
+ console.log('[DEBUG] Step 1: Setting up SSH access...');
113
+ const ssh = await this._setupSSHAccess(sessionConfig.machineId || sessionId, namespace);
114
+ console.log('[DEBUG] SSH setup complete');
115
+
116
+ // 2. Setup LivePort tunnel
117
+ console.log('[DEBUG] Step 2: Setting up LivePort tunnel...');
118
+ const tunnel = await this._setupTunnel(sessionId, sessionId);
119
+ console.log('[DEBUG] Tunnel setup complete');
120
+
121
+ // 3. Store necessary secrets in Vault
122
+ console.log('[DEBUG] Step 3: Storing secrets in Vault...');
123
+
124
+ // Fetch user environment variables from relay API
125
+ let userEnvVars = {};
126
+ if (sessionConfig.relayApiUrl && sessionConfig.relayApiKey) {
127
+ try {
128
+ console.log('[DEBUG] Fetching user environment variables from relay API...');
129
+ const envVarsResponse = await fetch(`${sessionConfig.relayApiUrl}/api/env-vars`, {
130
+ headers: {
131
+ 'Authorization': `Bearer ${sessionConfig.relayApiKey}`,
132
+ },
133
+ });
134
+
135
+ if (!envVarsResponse.ok) {
136
+ console.warn('[WARNING] Failed to fetch user environment variables:', envVarsResponse.status);
137
+ console.warn('[WARNING] Remote session will use local environment variables only');
138
+ } else {
139
+ const envVarsData = await envVarsResponse.json();
140
+ userEnvVars = envVarsData.variables || {};
141
+ console.log('[DEBUG] Fetched user environment variables:', Object.keys(userEnvVars).join(', '));
142
+ }
143
+ } catch (error) {
144
+ console.warn('[WARNING] Error fetching user environment variables:', error.message);
145
+ console.warn('[WARNING] Remote session will use local environment variables only');
146
+ }
147
+ }
148
+
149
+ // Try to extract Claude API key from local installation if not provided
150
+ // User env vars take precedence, then sessionConfig, then local extraction, then env vars
151
+ let anthropicApiKey = userEnvVars.ANTHROPIC_API_KEY || sessionConfig.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
152
+ if (!anthropicApiKey) {
153
+ try {
154
+ const { extractClaudeApiKey } = await import('../auth/claude-key-extractor.js');
155
+ anthropicApiKey = await extractClaudeApiKey();
156
+ } catch (error) {
157
+ console.log('[DEBUG] Could not auto-extract Claude API key:', error.message);
158
+ }
159
+ }
160
+
161
+ const secrets = {
162
+ RELAY_API_URL: sessionConfig.relayApiUrl,
163
+ RELAY_API_KEY: sessionConfig.relayApiKey,
164
+ GITHUB_TOKEN: userEnvVars.GITHUB_TOKEN || sessionConfig.githubToken,
165
+ MECH_API_KEY: sessionConfig.mechApiKey,
166
+ LIVEPORT_BRIDGE_KEY: tunnel.bridgeKey,
167
+ // Machine coder API keys (user env vars take precedence)
168
+ ANTHROPIC_API_KEY: anthropicApiKey,
169
+ GEMINI_API_KEY: userEnvVars.GEMINI_API_KEY || sessionConfig.geminiApiKey || process.env.GEMINI_API_KEY,
170
+ // Merge all other user environment variables
171
+ ...userEnvVars,
172
+ // Session config can override user env vars if explicitly provided
173
+ ...sessionConfig.additionalSecrets,
174
+ };
175
+
176
+ const secretIds = await this._storeSecrets(namespace, secrets);
177
+ console.log('[DEBUG] Secrets stored');
178
+
179
+ // 4. Export env file for the machine
180
+ console.log('[DEBUG] Step 4: Exporting env file...');
181
+ const envFile = await this._exportEnvFile(namespace);
182
+ console.log('[DEBUG] Env file exported');
183
+
184
+ // 5. Create Fly.io machine
185
+ const machineConfig = {
186
+ name: `teleportation-${sessionId}`,
187
+ config: {
188
+ image: 'oven/bun:latest',
189
+ auto_destroy: true,
190
+ restart: {
191
+ policy: 'no',
192
+ },
193
+ env: {
194
+ SESSION_ID: sessionId,
195
+ VAULT_NAMESPACE: namespace,
196
+ MECH_VAULT_URL: this.vaultClient.apiUrl,
197
+ MECH_API_KEY: this.vaultClient.apiKey,
198
+ MECH_APP_ID: this.vaultClient.appId,
199
+ TUNNEL_URL: tunnel.tunnelUrl,
200
+ },
201
+ services: [
202
+ {
203
+ ports: [
204
+ {
205
+ port: 22,
206
+ handlers: ['tls', 'http'],
207
+ },
208
+ {
209
+ port: 3000,
210
+ handlers: ['tls', 'http'],
211
+ },
212
+ ],
213
+ protocol: 'tcp',
214
+ internal_port: 22,
215
+ },
216
+ ],
217
+ guest: {
218
+ cpu_kind: 'shared',
219
+ cpus: 1,
220
+ memory_mb: 512,
221
+ },
222
+ init: {
223
+ cmd: ['/bin/sh', '-c', this._generateInitScript(sessionConfig, tunnel, ssh)],
224
+ },
225
+ },
226
+ };
227
+
228
+ const response = await fetch(`${this.apiUrl}/apps/${this.appName}/machines`, {
229
+ method: 'POST',
230
+ headers: this._headers(),
231
+ body: JSON.stringify(machineConfig),
232
+ });
233
+
234
+ if (!response.ok) {
235
+ const error = await response.text();
236
+ throw new Error(`Failed to create Fly machine: ${error}`);
237
+ }
238
+
239
+ const machine = await response.json();
240
+
241
+ return {
242
+ machineId: machine.id,
243
+ sshHost: `${machine.id}.vm.${this.appName}.internal`,
244
+ sshPort: 22,
245
+ tunnelUrl: tunnel.tunnelUrl,
246
+ namespace,
247
+ secretIds,
248
+ sshKeyId: ssh.keyId,
249
+ bridgeKeyId: tunnel.keyId,
250
+ publicIp: machine.private_ip,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Generate initialization script for the Fly machine
256
+ *
257
+ * This script runs when the machine starts and sets up:
258
+ * - SSH server with authorized keys
259
+ * - Git repository clone
260
+ * - LivePort tunnel
261
+ * - Teleportation daemon
262
+ *
263
+ * @private
264
+ * @param {Object} sessionConfig - Session configuration
265
+ * @param {Object} tunnel - Tunnel configuration with tunnelCommand
266
+ * @param {Object} ssh - SSH key with publicKey
267
+ * @returns {string} Shell script for machine initialization
268
+ */
269
+ _generateInitScript(sessionConfig, tunnel, ssh) {
270
+ // Escape all user-provided values to prevent command injection
271
+ const safeRepoUrl = this._escapeShellArg(sessionConfig.repoUrl);
272
+ const safeBranch = this._escapeShellArg(sessionConfig.branch || 'main');
273
+ const safeSessionId = this._escapeShellArg(sessionConfig.sessionId);
274
+ const safeTask = this._escapeShellArg(sessionConfig.task || '');
275
+ const safePublicKey = this._escapeShellArg(ssh.publicKey);
276
+ const safeVaultUrl = this._escapeShellArg(this.vaultClient.apiUrl);
277
+ const safeVaultKey = this._escapeShellArg(this.vaultClient.apiKey);
278
+ const safeAppId = this._escapeShellArg(this.vaultClient.appId);
279
+
280
+ // Teleportation repo URL (configurable via env var or config)
281
+ const teleportationRepoUrl = process.env.TELEPORTATION_REPO_URL ||
282
+ sessionConfig.teleportationRepoUrl ||
283
+ 'https://github.com/dundas/teleportation-private.git';
284
+ const safeTeleportationRepoUrl = this._escapeShellArg(teleportationRepoUrl);
285
+
286
+ return `
287
+ set -e
288
+
289
+ # Install system dependencies
290
+ apt-get update && apt-get install -y git openssh-server curl jq wget
291
+
292
+ # Install Node.js (required for Claude Code CLI)
293
+ echo "=== Installing Node.js ==="
294
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
295
+ apt-get install -y nodejs
296
+ echo "✅ Node.js installed: $(node --version)"
297
+
298
+ # Install Claude Code CLI
299
+ echo "=== Installing Claude Code CLI ==="
300
+ npm install -g @anthropic-ai/claude-code || {
301
+ echo "WARNING: Claude Code CLI installation failed"
302
+ }
303
+ if command -v claude >/dev/null 2>&1; then
304
+ echo "✅ Claude Code CLI installed: $(claude --version)"
305
+ else
306
+ echo "⚠️ Claude Code CLI not available"
307
+ fi
308
+
309
+ # Install Gemini CLI
310
+ echo "=== Installing Gemini CLI ==="
311
+ wget -O /tmp/gemini-cli.tar.gz https://github.com/google/generative-ai-cli/releases/latest/download/gemini-cli-linux-amd64.tar.gz || {
312
+ echo "WARNING: Gemini CLI download failed"
313
+ }
314
+ if [ -f /tmp/gemini-cli.tar.gz ]; then
315
+ tar -xzf /tmp/gemini-cli.tar.gz -C /usr/local/bin/
316
+ chmod +x /usr/local/bin/gemini
317
+ rm /tmp/gemini-cli.tar.gz
318
+ if command -v gemini >/dev/null 2>&1; then
319
+ echo "✅ Gemini CLI installed: $(gemini --version)"
320
+ else
321
+ echo "⚠️ Gemini CLI not available"
322
+ fi
323
+ else
324
+ echo "⚠️ Gemini CLI installation skipped"
325
+ fi
326
+
327
+ # Setup SSH
328
+ mkdir -p ~/.ssh
329
+ echo ${safePublicKey} >> ~/.ssh/authorized_keys
330
+ chmod 600 ~/.ssh/authorized_keys
331
+ # SSH service not needed in container
332
+
333
+ # Pull secrets from Vault
334
+ export MECH_VAULT_URL=${safeVaultUrl}
335
+ export MECH_API_KEY=${safeVaultKey}
336
+ export MECH_APP_ID=${safeAppId}
337
+
338
+ # Export session env vars from Vault (with debugging)
339
+ ENV_FILE=/tmp/teleportation.env
340
+ echo "=== Exporting env from Vault ==="
341
+ echo "Namespace: $VAULT_NAMESPACE"
342
+ echo "Vault URL: $MECH_VAULT_URL"
343
+
344
+ # Construct payload
345
+ PAYLOAD=$(jq -n --arg namespace "$VAULT_NAMESPACE" --arg format 'env' '{namespace:$namespace,format:$format}')
346
+ echo "Payload: $PAYLOAD"
347
+
348
+ # Make request with verbose output
349
+ curl -v -X POST "$MECH_VAULT_URL/env-files/export" \
350
+ -H "Content-Type: application/json" \
351
+ -H "X-API-Key: $MECH_API_KEY" \
352
+ -H "X-App-ID: $MECH_APP_ID" \
353
+ -d "$PAYLOAD" \
354
+ -o "$ENV_FILE" 2>&1 || {
355
+ EXIT_CODE=$?
356
+ echo "ERROR: Vault env export failed with exit code $EXIT_CODE"
357
+ echo "Response body (if any):"
358
+ cat "$ENV_FILE" 2>/dev/null || echo "(empty)"
359
+ exit $EXIT_CODE
360
+ }
361
+
362
+ if [ ! -s "$ENV_FILE" ]; then
363
+ echo "WARNING: Env file is empty, creating minimal file"
364
+ echo "# No secrets found" > "$ENV_FILE"
365
+ fi
366
+
367
+ echo "✅ Env file exported successfully ($(wc -l < "$ENV_FILE") lines)"
368
+
369
+ # Load exported env vars (no xtrace; avoid printing secrets)
370
+ set +x
371
+ set -a
372
+ . "$ENV_FILE"
373
+ set +a
374
+
375
+ # Configure git authentication (for both teleportation and user repositories)
376
+ if [ -n "$GITHUB_TOKEN" ]; then
377
+ git config --global url."https://x-access-token:$GITHUB_TOKEN@github.com/".insteadOf "https://github.com/"
378
+ git config --global url."https://x-access-token:$GITHUB_TOKEN@github.com/".insteadOf "git@github.com:"
379
+ echo "✅ Git authentication configured"
380
+ fi
381
+
382
+ # Clone teleportation repository first (contains daemon)
383
+ echo "=== Cloning teleportation repository ==="
384
+ git clone ${safeTeleportationRepoUrl} /teleportation
385
+ cd /teleportation
386
+ bun install
387
+ echo "✅ Teleportation repo ready at /teleportation"
388
+
389
+ # Clone user repository
390
+ echo "=== Cloning user repository ==="
391
+ git clone ${safeRepoUrl} /workspace
392
+ cd /workspace
393
+
394
+ # Checkout branch
395
+ git checkout ${safeBranch}
396
+
397
+ # Install repo dependencies
398
+ bun install
399
+ echo "✅ User repo ready at /workspace"
400
+
401
+ # Start LivePort tunnel in background
402
+ ${tunnel.tunnelCommand} &
403
+
404
+ # Ensure session exists in relay so daemon can poll immediately
405
+ curl -fsSL -X POST "$RELAY_API_URL/api/sessions/register" \
406
+ -H "Content-Type: application/json" \
407
+ -H "Authorization: Bearer $RELAY_API_KEY" \
408
+ -d "$(jq -n --arg session_id \"$SESSION_ID\" '{session_id:$session_id,meta:{provider:"fly",remote:true}}')" >/dev/null || true
409
+
410
+ # Start teleportation daemon from user workspace
411
+ # The daemon will run in /workspace context so child processes have correct working directory
412
+ cd /workspace
413
+ echo "=== Starting daemon ==="
414
+ echo "Session ID: $SESSION_ID"
415
+ echo "Teleportation repo: /teleportation"
416
+ echo "Working directory: $(pwd)"
417
+
418
+ # Export task description for daemon
419
+ export TELEPORTATION_TASK="${safeTask}"
420
+
421
+ # Start daemon (it's installed in /teleportation)
422
+ bun /teleportation/teleportation-cli.cjs daemon start || {
423
+ echo "ERROR: Daemon failed to start (exit code $?)"
424
+ exit 1
425
+ }
426
+
427
+ # Keep container running
428
+ echo "=== Daemon started successfully, keeping container alive ==="
429
+ echo "Initialization completed at $(date)"
430
+ tail -f /dev/null
431
+ `.trim();
432
+ }
433
+
434
+ /**
435
+ * Destroy the Fly.io machine
436
+ *
437
+ * Permanently deletes the machine and all its data.
438
+ * This action cannot be undone.
439
+ *
440
+ * @param {string} machineId - Fly machine ID
441
+ * @returns {Promise<boolean>} True if destroyed successfully
442
+ * @throws {Error} If machine deletion fails
443
+ */
444
+ async destroyMachine(machineId) {
445
+ this._validateMachineId(machineId);
446
+ const response = await this._fetchWithTimeout(`${this.apiUrl}/apps/${this.appName}/machines/${machineId}`, {
447
+ method: 'DELETE',
448
+ headers: this._headers(),
449
+ });
450
+
451
+ if (!response.ok) {
452
+ throw new Error(`Failed to destroy Fly machine: ${response.statusText}`);
453
+ }
454
+
455
+ return true;
456
+ }
457
+
458
+ /**
459
+ * Get machine status
460
+ *
461
+ * Returns current state of the Fly machine.
462
+ *
463
+ * @param {string} machineId - Fly machine ID
464
+ * @returns {Promise<Object>} Machine status
465
+ * @returns {string} return.status - Machine state (started, stopped, etc.)
466
+ * @returns {string} return.ip - Private IP address
467
+ * @returns {string} return.uptime - Last update timestamp
468
+ * @returns {string} return.region - Fly region
469
+ * @throws {Error} If status fetch fails
470
+ */
471
+ async getMachineStatus(machineId) {
472
+ this._validateMachineId(machineId);
473
+ const response = await this._fetchWithTimeout(`${this.apiUrl}/apps/${this.appName}/machines/${machineId}`, {
474
+ method: 'GET',
475
+ headers: this._headers(),
476
+ });
477
+
478
+ if (!response.ok) {
479
+ throw new Error(`Failed to get machine status: ${response.statusText}`);
480
+ }
481
+
482
+ const machine = await response.json();
483
+
484
+ return {
485
+ status: machine.state,
486
+ ip: machine.private_ip,
487
+ uptime: machine.updated_at,
488
+ region: machine.region,
489
+ };
490
+ }
491
+
492
+ /**
493
+ * Execute command on remote machine via SSH
494
+ *
495
+ * Note: This is a placeholder. Real SSH execution would require
496
+ * establishing SSH connection using the private key from Vault.
497
+ *
498
+ * @param {string} machineId - Fly machine ID
499
+ * @param {string} command - Command to execute
500
+ * @returns {Promise<Object>} Command result (placeholder)
501
+ * @returns {string} return.stdout - Standard output
502
+ * @returns {string} return.stderr - Standard error
503
+ * @returns {number} return.exitCode - Exit code
504
+ */
505
+ async executeCommand(machineId, command) {
506
+ this._validateMachineId(machineId);
507
+
508
+ // TODO: Implement real SSH command execution
509
+ // This would require:
510
+ // 1. Fetch private key from Vault
511
+ // 2. Establish SSH connection to machine
512
+ // 3. Execute command
513
+ // 4. Return stdout/stderr/exitCode
514
+
515
+ return {
516
+ stdout: '',
517
+ stderr: '',
518
+ exitCode: 0,
519
+ };
520
+ }
521
+
522
+ /**
523
+ * Get machine logs
524
+ *
525
+ * Retrieves logs from the Fly machine's init process.
526
+ *
527
+ * @param {string} machineId - Fly machine ID
528
+ * @param {Object} [options] - Log retrieval options
529
+ * @param {number} [options.tail] - Number of recent lines to retrieve
530
+ * @param {boolean} [options.follow] - Stream logs (not implemented)
531
+ * @returns {Promise<Object>} Log data
532
+ * @returns {Array<string>} return.logs - Array of log lines
533
+ * @throws {Error} If log retrieval fails
534
+ */
535
+ async getLogs(machineId, options = {}) {
536
+ this._validateMachineId(machineId);
537
+
538
+ try {
539
+ const params = new URLSearchParams();
540
+ if (options.tail) {
541
+ params.set('limit', options.tail.toString());
542
+ }
543
+
544
+ const url = `${this.apiUrl}/apps/${this.appName}/machines/${machineId}/logs?${params}`;
545
+ const response = await this._fetchWithTimeout(url, {
546
+ method: 'GET',
547
+ headers: this._headers(),
548
+ });
549
+
550
+ if (!response.ok) {
551
+ throw new Error(`Failed to get logs: ${response.statusText}`);
552
+ }
553
+
554
+ const logs = await response.json();
555
+
556
+ return {
557
+ logs: Array.isArray(logs) ? logs : [],
558
+ };
559
+ } catch (error) {
560
+ // Fallback to empty logs if API doesn't support log retrieval
561
+ console.warn(`Failed to retrieve logs for ${machineId}:`, error.message);
562
+ return { logs: [] };
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Stop a machine (without destroying)
568
+ *
569
+ * Stops the machine to save costs. Machine state is preserved.
570
+ *
571
+ * @param {string} machineId - Fly machine ID
572
+ * @returns {Promise<boolean>} True if stopped successfully
573
+ * @throws {Error} If stop fails
574
+ */
575
+ async stopMachine(machineId) {
576
+ this._validateMachineId(machineId);
577
+ const response = await this._fetchWithTimeout(`${this.apiUrl}/apps/${this.appName}/machines/${machineId}/stop`, {
578
+ method: 'POST',
579
+ headers: this._headers(),
580
+ });
581
+
582
+ if (!response.ok) {
583
+ throw new Error(`Failed to stop machine: ${response.statusText}`);
584
+ }
585
+
586
+ return true;
587
+ }
588
+
589
+ /**
590
+ * Start a stopped machine
591
+ *
592
+ * Resumes a stopped machine. State is preserved from when it was stopped.
593
+ *
594
+ * @param {string} machineId - Fly machine ID
595
+ * @returns {Promise<boolean>} True if started successfully
596
+ * @throws {Error} If start fails
597
+ */
598
+ async startMachine(machineId) {
599
+ this._validateMachineId(machineId);
600
+ const response = await this._fetchWithTimeout(`${this.apiUrl}/apps/${this.appName}/machines/${machineId}/start`, {
601
+ method: 'POST',
602
+ headers: this._headers(),
603
+ });
604
+
605
+ if (!response.ok) {
606
+ throw new Error(`Failed to start machine: ${response.statusText}`);
607
+ }
608
+
609
+ return true;
610
+ }
611
+ }