@iosifnicolae22/remote-shell
Version:
Run any terminal application and control it remotely from your mobile device. Perfect for maxing out vibe coding sessions with Claude Code on the go!
3 lines (2 loc) ⢠12.3 kB
JavaScript
;Object.defineProperty(exports,"__esModule",{value:!0});const pty_manager_1=require("./pty-manager");const websocket_client_1=require("./websocket-client");const token_generator_1=require("./token-generator");const config_manager_1=require("./config-manager");const child_process_1=require("child_process");const path_1=require("path");const fs_1=require("fs");const os_1=require("os");const isDebugMode="1"===process.env.DEBUG||"true"===process.env.DEBUG;const logFile=(0,path_1.resolve)((0,os_1.homedir)(),".remote-shell/logs.log");function debugLog(e,...t){if(!isDebugMode)return;const n=`${(new Date).toISOString()} [CLI] ${e}${t.length>0?" "+JSON.stringify(t):""}\n`;try{(0,fs_1.appendFileSync)(logFile,n,"utf8")}catch(e){console.error(`Failed to write to debug log: ${e}`)}}class ClaudeMobileClient{ptyManager;wsClient;tokenGenerator;configManager;terminalBuffer="";maxBufferSize=1e5;appConfig;localTerminalSize;remoteTerminalSize=null;constructor(e){this.ptyManager=new pty_manager_1.PTYManager,this.configManager=new config_manager_1.ConfigManager;const t=this.configManager.getConfig();this.wsClient=new websocket_client_1.WebSocketClient(t.websocketHost),this.tokenGenerator=new token_generator_1.TokenGenerator,this.appConfig=e,this.localTerminalSize={cols:process.stdout.columns||80,rows:process.stdout.rows||24}}async start(){debugLog(`Starting Mobile Terminal Client for ${this.appConfig.name}`,{command:this.appConfig.command,args:this.appConfig.args,workingDir:this.appConfig.workingDir,env:Object.keys(this.appConfig.env||{}),debugMode:isDebugMode}),this.startApplication(),this.startBackgroundConnection()}statusBarVisible=!1;currentStatus="";showStatusBar(e){this.currentStatus=e,this.statusBarVisible?this.updateStatusBar():(this.statusBarVisible=!0,this.ptyManager.setStatusBarVisible(!0),this.renderStatusBar())}showErrorBar(e){debugLog("Error:",e),this.currentStatus=e,this.statusBarVisible||(this.statusBarVisible=!0,this.ptyManager.setStatusBarVisible(!0)),this.renderErrorBar()}hideStatusBar(){this.statusBarVisible&&(this.statusBarVisible=!1,this.ptyManager.setStatusBarVisible(!1),process.stdout.write("[2K\r"))}renderStatusBar(){const e=process.stdout.rows||24;process.stdout.write(`[${e};1H`);const t=process.stdout.columns||80;const n=Math.max(0,t-this.currentStatus.length-2);const o=`[47m[30m ${this.currentStatus}${" ".repeat(n)} [0m`;process.stdout.write(o)}renderErrorBar(){const e=process.stdout.rows||24;process.stdout.write(`[${e};1H`);const t=process.stdout.columns||80;const n=Math.max(0,t-this.currentStatus.length-2);const o=`[41m[37m ${this.currentStatus}${" ".repeat(n)} [0m`;process.stdout.write(o)}updateStatusBar(){this.statusBarVisible&&this.renderStatusBar()}getMinimumTerminalSize(){return this.remoteTerminalSize?{cols:Math.min(this.localTerminalSize.cols,this.remoteTerminalSize.cols),rows:Math.min(this.localTerminalSize.rows,this.remoteTerminalSize.rows)}:this.localTerminalSize}updatePtySize(){const e=this.getMinimumTerminalSize();debugLog("Updating PTY size to minimum:",{local:this.localTerminalSize,remote:this.remoteTerminalSize,minimum:e}),this.ptyManager.resize(e.cols,e.rows)}startApplication(){debugLog("Starting application process"),this.showStatusBar("š Starting application...");try{if(!(0,fs_1.existsSync)(this.appConfig.command)&&!this.isCommandInPath(this.appConfig.command))return this.showErrorBar(`ā Command not found: ${this.appConfig.command}`),setTimeout(()=>{this.showErrorBar("š” Make sure the application is installed and accessible")},3e3),void setTimeout(()=>process.exit(1),6e3);const e=this.resolveCommand(this.appConfig.command);debugLog("Command resolution completed:",{original:this.appConfig.command,resolved:e,args:this.appConfig.args}),debugLog("Creating PTY process with resolved command");this.ptyManager.create(e,this.appConfig.workingDir||process.cwd(),this.appConfig.args);this.setupEventHandlers(),this.ptyManager.onExit=()=>{debugLog("PTY process exited:",{command:this.appConfig.command}),process.stdin.setRawMode(!1),this.wsClient.disconnect(),process.exit(0)},this.ptyManager.onOutput=e=>{this.handlePTYOutput(e)},this.ptyManager.onBell=()=>{this.wsClient.sendBeep()},this.showStatusBar("š± Connecting to server..."),debugLog("PTY process started successfully:",{name:this.appConfig.name,command:e})}catch(e){debugLog("Critical error starting application:",e),this.showErrorBar(`ā Error starting ${this.appConfig.name}: ${e}`),setTimeout(()=>process.exit(1),3e3)}}startBackgroundConnection(){debugLog("Starting background connection attempts");const e=this.tokenGenerator.generateToken();debugLog("Generated connection token:",{tokenLength:e.token.length}),this.attemptConnection(e.token);let t=this.configManager.getConfig().hostname||"localhost:3001";t.match(/^https?:\/\//)||(t=t.match(/^wss?:\/\//)?t.replace(/^ws(s)?:\/\//,"http$1://"):`http://${t}`);const n=`${t}/connect/${e.token}`;debugLog("Generated mobile connection URL:",n),console.log("\nš± Mobile Terminal Connection URL:"),console.log(`${n}\n`);const o=setInterval(()=>{this.wsClient.isConnected()&&this.wsClient.hasClients()?clearInterval(o):debugLog(`š± Connect at: ${n}`)},1e4);try{const e="darwin"===process.platform?"open":"win32"===process.platform?"start":"xdg-open";(0,child_process_1.exec)(`${e} "${n}"`,e=>{e?(debugLog("Failed to auto-open browser:",e),console.log("ā ļø Could not auto-open browser. Please visit the URL above manually.")):debugLog("Successfully opened browser with mobile connection page")})}catch(e){debugLog("Exception when trying to open browser:",e),console.log("ā ļø Could not auto-open browser. Please visit the URL above manually.")}}attemptConnection(e){debugLog("Attempting WebSocket connection..."),this.showStatusBar("š” Connecting to server..."),this.wsClient.connect().then(()=>{debugLog("Successfully connected to WebSocket server"),this.wsClient.notifySessionCreated(e),this.showStatusBar("ā
Connected! Waiting for mobile client..."),debugLog("Notified server of new session creation")}).catch(t=>{debugLog("Failed to connect to WebSocket server:",t),this.showStatusBar("š Retrying connection..."),setTimeout(()=>{this.attemptConnection(e)},5e3)})}isCommandInPath(e){try{const{execSync:t}=require("child_process");if("win32"===process.platform){return t(`where ${e}`,{stdio:"ignore"}),!0}try{return t(`which ${e}`,{stdio:"ignore"}),!0}catch{try{const n=process.env.SHELL||"/bin/bash";return t(`${n} -i -c "type ${e}"`,{stdio:"ignore"}),!0}catch{return!1}}}catch{return!1}}resolveCommand(e){try{const{execSync:t}=require("child_process");if("win32"===process.platform){return t(`where ${e}`,{encoding:"utf8"}).trim().split("\n")[0]}try{const n=t(`${process.env.SHELL||"/bin/bash"} -i -c "type ${e}"`,{encoding:"utf8"});if(n.includes("is an alias for")){const e=n.split("is an alias for ")[1]?.trim();if(e)return e}}catch{}try{return t(`which ${e}`,{encoding:"utf8"}).trim()}catch{return e}}catch{return e}}handlePTYOutput(e){process.stdout.write(e),this.terminalBuffer+=e,this.terminalBuffer.length>this.maxBufferSize&&(this.terminalBuffer=this.terminalBuffer.slice(-this.maxBufferSize),"true"===process.env.DEBUG_VERBOSE&&debugLog("Terminal buffer truncated (routine maintenance)",{newLength:this.terminalBuffer.length,maxSize:this.maxBufferSize})),this.wsClient.sendOutput(e)}setupEventHandlers(){debugLog("Setting up event handlers for PTY and WebSocket communication"),process.stdin.setRawMode(!0),process.stdin.setEncoding("utf8"),process.stdin.on("data",e=>{this.ptyManager.write(e)}),process.stdout.on("resize",()=>{this.localTerminalSize={cols:process.stdout.columns||80,rows:process.stdout.rows||24},this.updatePtySize(),this.statusBarVisible&&this.updateStatusBar()}),this.wsClient.onInput=e=>{debugLog("Received input from WebSocket client, forwarding to PTY:",{dataLength:e.length,firstChar:e.charCodeAt(0)}),this.ptyManager.write(e)},this.wsClient.onResize=(e,t)=>{debugLog("Remote client resize event:",{cols:e,rows:t}),this.remoteTerminalSize={cols:e,rows:t},this.updatePtySize()},this.wsClient.onClientConnected=()=>{this.terminalBuffer?(debugLog("New client connected, sending terminal buffer:",{bufferLength:this.terminalBuffer.length,maxBufferSize:this.maxBufferSize}),this.wsClient.sendBuffer(this.terminalBuffer)):debugLog("New client connected, no terminal buffer to send"),this.hideStatusBar()},this.wsClient.onClientDisconnected=()=>{debugLog("Remote client disconnected"),this.remoteTerminalSize=null,this.updatePtySize(),this.showStatusBar("š± Waiting for mobile client...")},this.wsClient.onSessionConfirmed=e=>{debugLog("Session confirmed by server:",e),this.showStatusBar("ā
Session ready! Waiting for mobile client...")}}}function handleConfigCommands(){const e=process.argv.slice(2);if("set"===e[0]&&3===e.length){const t=new config_manager_1.ConfigManager;const[,n,o]=e;if("hostname"===n||"websocket"===n)return t.setConfig("websocket"===n?"websocketHost":"hostname",o),!0;console.error("ā Invalid config key. Use: hostname or websocket"),process.exit(1)}if("config"===e[0]){return(new config_manager_1.ConfigManager).showConfig(),!0}if("production"===e[0]){console.log("š Production mode (default)");const e=(new config_manager_1.ConfigManager).getConfig();return console.log("ā
Production configuration:"),console.log(` websocketHost: ${e.websocketHost}`),console.log(` hostname: ${e.hostname}`),console.log("\nš” To use development mode instead:"),console.log(" export REMOTE_SHELL_DEVELOPMENT=true"),console.log(" or"),console.log(" export NODE_ENV=development"),!0}if("development"===e[0]){console.log("š ļø Configuring for development mode..."),process.env.REMOTE_SHELL_DEVELOPMENT="true";const e=(new config_manager_1.ConfigManager).getConfig();return console.log("ā
Development configuration:"),console.log(` websocketHost: ${e.websocketHost}`),console.log(` hostname: ${e.hostname}`),console.log("\nš” This connects to localhost for development"),!0}return!1}function parseArgs(){const e=process.argv.slice(2);let t;(e.includes("--help")||e.includes("-h"))&&(console.log("\nš Mobile Terminal Client\n\nUsage: \n remote-shell [options] [command] [args...]\n remote-shell set <key> <value> # Set configuration\n remote-shell config # Show current configuration\n remote-shell production # Show production config (default)\n remote-shell development # Configure for development mode\n\nOptions:\n --help, -h Show this help message\n --working-dir, -w Set working directory\n --env KEY=VALUE Set environment variable\n\nConfiguration:\n remote-shell set hostname <host> # Set server hostname\n remote-shell set websocket <url> # Set websocket URL\n remote-shell config # Show current config\n remote-shell production # Show production config (default)\n remote-shell development # Show development config\n\nExamples:\n remote-shell # Run bash (default)\n remote-shell claude # Run Claude Code\n remote-shell vim myfile.txt # Run vim with file\n remote-shell --working-dir=/tmp bash # Run bash in /tmp\n remote-shell --env DEBUG=1 node app.js # Run with environment variable\n\nSupported Applications:\n - bash, zsh, fish (shells) - bash is default\n - Claude Code\n - vim, nano, emacs (editors)\n - node, python, ruby (interpreters)\n - Any other terminal application\n"),process.exit(0));const env={};const n=[];for(let o=0;o<e.length;o++){const s=e[o];if("--working-dir"===s||"-w"===s)t=e[++o];else if(s.startsWith("--env")){const t="--env"===s?e[++o]:s.split("=",2)[1];const[n,i]=t.split("=",2);n&&i&&(env[n]=i)}else if(!s.startsWith("-")){n.push(...e.slice(o));break}}if(0===n.length)return{name:"Bash Shell",command:"bash",args:[],workingDir:t,env:env};{const[e,...o]=n;return{name:e,command:e,args:o,workingDir:t,env:env}}}function setupCleanup(){process.on("SIGINT",()=>{process.stdin.setRawMode(!1),process.exit(0)}),process.on("SIGTERM",()=>{process.stdin.setRawMode(!1),process.exit(0)})}async function main(){try{if(handleConfigCommands())return;const e=parseArgs();setupCleanup();const t=new ClaudeMobileClient(e);await t.start()}catch(e){console.error("ā Failed to start mobile terminal client:",e),process.exit(1)}}main();