UNPKG

shell-mirror

Version:

Access your Mac shell from any device securely. Perfect for mobile coding with Claude Code CLI, Gemini CLI, and any shell tool.

644 lines (553 loc) 20.2 kB
const fs = require('fs').promises; const path = require('path'); const os = require('os'); const crypto = require('crypto'); const { spawn } = require('child_process'); const https = require('https'); const querystring = require('querystring'); const NetworkUtils = require('./network-utils'); class AutoStart { constructor() { this.configDir = path.join(os.homedir(), '.shell-mirror'); this.authFile = path.join(this.configDir, 'auth.json'); this.configFile = path.join(this.configDir, 'config.json'); this.envFile = path.join(process.cwd(), '.env'); // Shell Mirror OAuth App credentials (production) this.oauthConfig = { clientId: '804759223392-i5nv5csn1o6siqr760c99l2a9k4sammp.apps.googleusercontent.com', clientSecret: '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', redirectUri: 'http://localhost:8080/auth/google/callback' }; } async run() { console.log('🚀 Starting Shell Mirror...'); console.log(''); try { // Ensure config directory exists await fs.mkdir(this.configDir, { recursive: true }); // Check authentication status const authInfo = await this.checkAuth(); if (!authInfo) { console.log('🔐 Not logged in. Opening browser for Google authentication...'); await this.initiateLogin(); return; } // Start the server with WebSocket integration await this.startAuthenticatedServer(authInfo); } catch (error) { console.error('❌ Failed to start Shell Mirror:', error.message); process.exit(1); } } async checkAuth() { try { const authData = await fs.readFile(this.authFile, 'utf8'); const auth = JSON.parse(authData); // Check if token is still valid (basic check) if (auth.accessToken && auth.email && auth.expiresAt) { const now = Date.now(); if (now < auth.expiresAt) { return auth; } } return null; } catch (error) { return null; } } async initiateLogin() { console.log(''); console.log('🔐 Opening browser for Google authentication...'); console.log(''); try { // Start OAuth callback server console.log('🔄 Starting OAuth callback server...'); const server = await this.createOAuthCallbackServer(); // Open browser for OAuth const authUrl = this.buildAuthUrl(); console.log('🌐 Opening browser for login...'); await this.openBrowser(authUrl); console.log(''); console.log('👤 Please complete the Google login in your browser'); console.log(' (If browser didn\'t open, visit: ' + authUrl + ')'); console.log(''); } catch (error) { console.error('❌ Failed to start OAuth flow:', error.message); process.exit(1); } } buildAuthUrl() { const params = new URLSearchParams({ client_id: this.oauthConfig.clientId, redirect_uri: this.oauthConfig.redirectUri, response_type: 'code', scope: 'openid email profile', access_type: 'offline', prompt: 'consent' }); return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; } async createOAuthCallbackServer() { const express = require('express'); const app = express(); return new Promise((resolve, reject) => { let serverInstance; app.get('/auth/google/callback', async (req, res) => { try { const code = req.query.code; const error = req.query.error; if (error) { throw new Error(`OAuth error: ${error}`); } if (code) { console.log('📝 Received authorization code, exchanging for tokens...'); // Exchange code for tokens const tokens = await this.exchangeCodeForTokens(code); await this.saveAuth(tokens); res.send(` <html> <head> <title>Shell Mirror - Login Successful</title> <style> body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: #f5f5f5; } .container { background: white; padding: 40px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); max-width: 500px; margin: 0 auto; } h2 { color: #4CAF50; margin-bottom: 20px; } p { color: #666; margin-bottom: 15px; } .email { color: #333; font-weight: bold; } </style> </head> <body> <div class="container"> <h2>✅ Login Successful!</h2> <p>Welcome, <span class="email">${tokens.email}</span></p> <p>Shell Mirror is now starting with your account.</p> <p>You can close this window and return to your terminal.</p> <p><small>Or visit <strong>https://shellmirror.app</strong> on your phone to access your terminal.</small></p> </div> <script> setTimeout(() => { window.close(); }, 3000); </script> </body> </html> `); // Close the temporary server and restart with authenticated state setTimeout(() => { console.log(`✅ Login successful! Welcome ${tokens.email}`); serverInstance.close(() => { this.startAuthenticatedServer(tokens); }); }, 1000); } else { throw new Error('No authorization code received'); } } catch (error) { console.error('❌ OAuth callback error:', error.message); res.send(` <html> <head> <title>Shell Mirror - Login Failed</title> <style> body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: #f5f5f5; } .container { background: white; padding: 40px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); max-width: 500px; margin: 0 auto; } h2 { color: #f44336; margin-bottom: 20px; } p { color: #666; margin-bottom: 15px; } </style> </head> <body> <div class="container"> <h2>❌ Login Failed</h2> <p>Error: ${error.message}</p> <p>Please try running 'shell-mirror' again.</p> </div> </body> </html> `); } }); serverInstance = app.listen(8080, (err) => { if (err) { reject(err); } else { resolve(serverInstance); } }); }); } async createProductionConfig() { // Create production environment file with real OAuth credentials const envContent = `# Shell Mirror Configuration # Auto-generated on ${new Date().toISOString()} BASE_URL=http://localhost:8080 PORT=8080 HOST=0.0.0.0 GOOGLE_CLIENT_ID=${this.oauthConfig.clientId} GOOGLE_CLIENT_SECRET=${this.oauthConfig.clientSecret} SESSION_SECRET=${crypto.randomBytes(32).toString('hex')} NODE_ENV=development `; await fs.writeFile(this.envFile, envContent); } async startServerForLogin() { console.log('🔄 Starting Shell Mirror server...'); console.log(''); // Find the package root directory (where server.js is located) const packageRoot = path.resolve(__dirname, '..'); const serverPath = path.join(packageRoot, 'server.js'); // Start the server in background const serverProcess = spawn('node', [serverPath], { stdio: 'pipe', cwd: path.dirname(this.envFile), env: { ...process.env } }); // Wait a moment for server to start setTimeout(() => { this.displayLoginInstructions(); this.openBrowser('http://localhost:8080'); }, 2000); // Handle Ctrl+C gracefully process.on('SIGINT', () => { console.log(''); console.log('🛑 Stopping Shell Mirror...'); serverProcess.kill('SIGINT'); process.exit(0); }); // Monitor server serverProcess.on('close', (code) => { if (code !== 0) { console.error(`❌ Server exited with code ${code}`); process.exit(code); } }); } displayLoginInstructions() { const pkg = require('../package.json'); console.log(`✅ Shell Mirror v${pkg.version} - First-time setup required`); console.log('Press Ctrl+C to stop'); } async exchangeCodeForTokens(code) { return new Promise((resolve, reject) => { const postData = querystring.stringify({ code: code, client_id: this.oauthConfig.clientId, client_secret: this.oauthConfig.clientSecret, redirect_uri: this.oauthConfig.redirectUri, grant_type: 'authorization_code' }); const options = { hostname: 'oauth2.googleapis.com', port: 443, path: '/token', method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(postData) } }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', async () => { try { const tokens = JSON.parse(data); if (tokens.access_token) { // Get user info const userInfo = await this.getUserInfo(tokens.access_token); resolve({ accessToken: tokens.access_token, refreshToken: tokens.refresh_token, email: userInfo.email, name: userInfo.name, expiresAt: Date.now() + (tokens.expires_in * 1000) }); } else { reject(new Error('Failed to get access token: ' + data)); } } catch (error) { reject(error); } }); }); req.on('error', (error) => { reject(error); }); req.write(postData); req.end(); }); } async getUserInfo(accessToken) { return new Promise((resolve, reject) => { const options = { hostname: 'www.googleapis.com', port: 443, path: '/oauth2/v2/userinfo', method: 'GET', headers: { 'Authorization': `Bearer ${accessToken}` } }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const userInfo = JSON.parse(data); resolve(userInfo); } catch (error) { reject(error); } }); }); req.on('error', (error) => { reject(error); }); req.end(); }); } async saveAuth(authData) { await fs.writeFile(this.authFile, JSON.stringify(authData, null, 2)); console.log('🔐 Authentication saved'); } async startServer(authInfo) { // Create .env file with configuration await this.createEnvFile(authInfo); // Start the Express server console.log('🔄 Starting server...'); console.log(''); // Find the package root directory (where server.js is located) const packageRoot = path.resolve(__dirname, '..'); const serverPath = path.join(packageRoot, 'server.js'); // Start the main server process const serverProcess = spawn('node', [serverPath], { stdio: 'inherit', cwd: path.dirname(this.envFile), env: { ...process.env } }); // Display status information this.displayStatus(authInfo); // Handle Ctrl+C gracefully process.on('SIGINT', () => { console.log(''); console.log('🛑 Stopping Shell Mirror...'); serverProcess.kill('SIGINT'); process.exit(0); }); // Wait for server process serverProcess.on('close', (code) => { if (code !== 0) { console.error(`❌ Server exited with code ${code}`); process.exit(code); } }); } async createEnvFile(authInfo, wsConfig = null) { const port = wsConfig ? wsConfig.port : 8080; const host = wsConfig ? wsConfig.host : '0.0.0.0'; const envContent = `# Shell Mirror Configuration # Auto-generated on ${new Date().toISOString()} BASE_URL=http://localhost:${port} PORT=${port} HOST=${host} GOOGLE_CLIENT_ID=${this.oauthConfig.clientId} GOOGLE_CLIENT_SECRET=${this.oauthConfig.clientSecret} SESSION_SECRET=${crypto.randomBytes(32).toString('hex')} NODE_ENV=development # User authentication USER_EMAIL=${authInfo.email} USER_NAME=${authInfo.name} ACCESS_TOKEN=${authInfo.accessToken} # WebSocket server configuration ${wsConfig ? `WS_PORT=${wsConfig.port} WS_HOST=${wsConfig.host} WS_LOCAL_IP=${wsConfig.localIP} WS_URL=${wsConfig.wsUrl}` : ''} `; await fs.writeFile(this.envFile, envContent); } displayStatus(authInfo, wsConfig = null) { const pkg = require('../package.json'); console.log(''); console.log(`✅ Shell Mirror v${pkg.version} - Running (${authInfo.email})`); if (wsConfig) { console.log(`🌐 WebSocket Server: ${wsConfig.wsUrl}`); console.log(`📱 Access from browser: https://shellmirror.app`); } console.log('Press Ctrl+C to stop'); console.log(''); } async startAuthenticatedServer(authInfo) { console.log(''); console.log('🚀 Starting authenticated Shell Mirror with cloud WebSocket connection...'); try { // Register this agent with the cloud backend const agentId = await this.registerAsMacAgent(authInfo); // Start the terminal agent with cloud WebSocket connection await this.startTerminalAgent(authInfo, agentId); } catch (error) { console.error('❌ Failed to start Shell Mirror:', error.message); process.exit(1); } } async registerAsMacAgent(authInfo) { console.log('🔗 Registering as Mac agent with cloud WebSocket...'); console.log(` User: ${authInfo.email}`); console.log(` Machine: ${os.hostname()}`); // Generate agent ID outside try block so it's always available const agentId = `local-${os.hostname()}-${Date.now()}`; try { const registrationData = { agentId: agentId, ownerEmail: authInfo.email, ownerName: authInfo.name, ownerToken: authInfo.accessToken, machineName: os.hostname(), agentVersion: '1.5.23', capabilities: ['terminal', 'cloud-websocket'], connectionType: 'cloud-websocket' }; console.log(` Agent ID: ${agentId}`); console.log(` Registering with: https://shellmirror.app/php-backend/api/agent-register.php`); const postData = JSON.stringify(registrationData); const options = { hostname: 'shellmirror.app', port: 443, path: '/php-backend/api/agent-register.php', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) } }; console.log(' Sending registration request...'); const response = await this.makeHttpsRequest(options, postData); console.log(` Response status: ${response.statusCode}`); console.log(` Response body: ${response.body}`); if (response.statusCode === 200) { try { const result = JSON.parse(response.body); if (result.success) { console.log(`✅ Registered as Mac agent: ${agentId}`); console.log(`📱 Your Mac is now accessible from https://shellmirror.app`); // Store agent ID for later use await fs.writeFile(path.join(this.configDir, 'agent.json'), JSON.stringify({ agentId: agentId, registeredAt: new Date().toISOString(), ownerEmail: authInfo.email })); console.log(` Agent info saved to: ${path.join(this.configDir, 'agent.json')}`); } else { console.log(`⚠️ Registration failed: ${result.message || 'Unknown error'}`); } } catch (parseError) { console.log(`⚠️ Failed to parse registration response: ${parseError.message}`); console.log(` Raw response: ${response.body}`); } } else { console.log(`⚠️ Registration failed with HTTP ${response.statusCode}`); console.log(` Response: ${response.body}`); console.log(' Server will still run locally'); } } catch (error) { console.log('⚠️ Mac agent registration failed:', error.message); console.log(` Error details: ${error.stack}`); console.log(' Server will run locally only'); } return agentId; // Return the agent ID for use in startTerminalAgent } async startTerminalAgent(authInfo, agentId) { console.log(''); console.log('🔄 Starting terminal agent with cloud WebSocket connection...'); // Find the package root directory (where agent files are located) const packageRoot = path.resolve(__dirname, '..'); const agentPath = path.join(packageRoot, 'mac-agent', 'agent.js'); // Create environment for agent const agentEnv = { ...process.env, USER_EMAIL: authInfo.email, USER_NAME: authInfo.name, ACCESS_TOKEN: authInfo.accessToken, WEBSOCKET_URL: 'wss://shell-mirror-30aa5479ceaf.herokuapp.com', CONNECTION_TYPE: 'cloud-websocket', AGENT_ID: agentId }; console.log('🌐 Connecting to: wss://shell-mirror-30aa5479ceaf.herokuapp.com'); console.log('📱 Access from browser: https://shellmirror.app/app/terminal.html'); console.log(''); // Start the terminal agent process const agentProcess = spawn('node', [agentPath], { stdio: 'inherit', env: agentEnv }); // Display final status const pkg = require('../package.json'); console.log(`✅ Shell Mirror v${pkg.version} - Running (${authInfo.email})`); console.log('🌐 Connection: Cloud WebSocket (wss://shellmirror.app)'); console.log('Press Ctrl+C to stop'); console.log(''); // Handle Ctrl+C gracefully process.on('SIGINT', () => { console.log(''); console.log('🛑 Stopping Shell Mirror agent...'); agentProcess.kill('SIGINT'); process.exit(0); }); // Wait for agent process agentProcess.on('close', (code) => { if (code !== 0) { console.error(`❌ Agent exited with code ${code}`); process.exit(code); } }); } async makeHttpsRequest(options, postData = null) { return new Promise((resolve, reject) => { const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { resolve({ statusCode: res.statusCode, headers: res.headers, body: data }); }); }); req.on('error', (error) => { reject(error); }); if (postData) { req.write(postData); } req.end(); }); } async openBrowser(url) { const platform = os.platform(); let command; switch (platform) { case 'darwin': // macOS command = 'open'; break; case 'win32': // Windows command = 'start'; break; default: // Linux and others command = 'xdg-open'; break; } try { spawn(command, [url], { detached: true, stdio: 'ignore' }); } catch (error) { console.log('⚠️ Could not open browser automatically.'); console.log(' Please visit: ' + url); } } } module.exports = new AutoStart();