fix: handle authenticate-pam as optional dependency in npm package

- Modified postinstall script to extract authenticate-pam prebuilds when npm skips optional deps
- Updated authenticate-pam-loader to load from optional-modules directory
- Prepared beta.12 release with all fixes consolidated
- Moved Docker test script to scripts folder
- Updated documentation in npm.md
This commit is contained in:
Peter Steinberger 2025-07-17 08:57:08 +02:00
parent 693565d9ea
commit 84b7467e83
49 changed files with 5609 additions and 14 deletions

293
web/package/README.md Normal file
View file

@ -0,0 +1,293 @@
# VibeTunnel CLI
**Turn any browser into your terminal.** VibeTunnel proxies your terminals right into the browser, so you can vibe-code anywhere.
Full-featured terminal sharing server with web interface for macOS and Linux. Windows not yet supported.
## Why VibeTunnel?
Ever wanted to check on your AI agents while you're away? Need to monitor that long-running build from your phone? Want to share a terminal session with a colleague without complex SSH setups? VibeTunnel makes it happen with zero friction.
## Installation
### From npm (Recommended)
```bash
npm install -g vibetunnel
```
### From Source
```bash
git clone https://github.com/amantus-ai/vibetunnel.git
cd vibetunnel/web
pnpm install
pnpm run build
```
## Installation Differences
**npm package**:
- Pre-built binaries for common platforms (macOS x64/arm64, Linux x64/arm64)
- Automatic fallback to source compilation if pre-built binaries unavailable
- Global installation makes `vibetunnel` and `vt` commands available system-wide
- Includes production dependencies only
**Source installation**:
- Full development environment with hot reload (`pnpm run dev`)
- Access to all development scripts and tools
- Ability to modify and rebuild the application
- Includes test suites and development dependencies
## Requirements
- Node.js >= 20.0.0
- macOS or Linux (Windows not yet supported)
- Build tools for native modules (Xcode on macOS, build-essential on Linux)
## Usage
### Start the server
```bash
# Start with default settings (port 4020)
vibetunnel
# Start with custom port
vibetunnel --port 8080
# Start without authentication
vibetunnel --no-auth
# Bind to specific interface
vibetunnel --bind 127.0.0.1 --port 4020
# Enable SSH key authentication
vibetunnel --enable-ssh-keys
# SSH keys only (no password auth)
vibetunnel --disallow-user-password
```
Then open http://localhost:4020 in your browser to access the web interface.
### Command-line Options
```
vibetunnel [options]
Basic Options:
--help, -h Show help message
--version, -v Show version information
--port <number> Server port (default: 4020 or PORT env var)
--bind <address> Bind address (default: 0.0.0.0, all interfaces)
Authentication Options:
--no-auth Disable authentication (auto-login as current user)
--enable-ssh-keys Enable SSH key authentication UI and functionality
--disallow-user-password Disable password auth, SSH keys only (auto-enables --enable-ssh-keys)
--allow-local-bypass Allow localhost connections to bypass authentication
--local-auth-token <token> Token for localhost authentication bypass
Push Notification Options:
--push-enabled Enable push notifications (default: enabled)
--push-disabled Disable push notifications
--vapid-email <email> Contact email for VAPID configuration
--generate-vapid-keys Generate new VAPID keys if none exist
Network Discovery Options:
--no-mdns Disable mDNS/Bonjour advertisement (enabled by default)
HQ Mode Options:
--hq Run as HQ (headquarters) server
--no-hq-auth Disable HQ authentication
Remote Server Options:
--hq-url <url> HQ server URL to register with
--hq-username <user> Username for HQ authentication
--hq-password <pass> Password for HQ authentication
--name <name> Unique name for remote server
--allow-insecure-hq Allow HTTP URLs for HQ (not recommended)
Repository Options:
--repository-base-path <path> Base path for repository discovery
Debugging:
--debug Enable debug logging
```
### Use the vt command wrapper
The `vt` command allows you to run commands with TTY forwarding:
```bash
# Monitor AI agents with automatic activity tracking
vt claude
vt claude --dangerously-skip-permissions
# Run commands with output visible in VibeTunnel
vt npm test
vt python script.py
vt top
# Launch interactive shell
vt --shell
vt -i
# Update session title (inside a session)
vt title "My Project"
# Execute command directly without shell wrapper
vt --no-shell-wrap ls -la
vt -S ls -la
# Control terminal title behavior
vt --title-mode none # No title management
vt --title-mode filter # Block all title changes
vt --title-mode static # Show directory and command
vt --title-mode dynamic # Show directory, command, and activity
# Verbosity control
vt -q npm test # Quiet mode (errors only)
vt -v npm run dev # Verbose mode
vt -vv npm test # Extra verbose
vt -vvv npm build # Debug mode
```
### Forward commands to a session
```bash
# Basic usage
vibetunnel fwd <session-id> <command> [args...]
# Examples
vibetunnel fwd --session-id abc123 ls -la
vibetunnel fwd --session-id abc123 npm test
vibetunnel fwd --session-id abc123 python script.py
```
### Environment Variables
VibeTunnel respects the following environment variables:
```bash
PORT=8080 # Default port if --port not specified
VIBETUNNEL_USERNAME=myuser # Username (for env-based auth, not CLI)
VIBETUNNEL_PASSWORD=mypass # Password (for env-based auth, not CLI)
VIBETUNNEL_CONTROL_DIR=/path # Control directory for session data
VIBETUNNEL_SESSION_ID=abc123 # Current session ID (set automatically inside sessions)
VIBETUNNEL_LOG_LEVEL=debug # Log level: error, warn, info, verbose, debug
PUSH_CONTACT_EMAIL=admin@example.com # Contact email for VAPID configuration
```
## Features
- **Web-based terminal interface** - Access terminals from any browser
- **Multiple concurrent sessions** - Run multiple terminals simultaneously
- **Real-time synchronization** - See output in real-time
- **TTY forwarding** - Full terminal emulation support
- **Session management** - Create, list, and manage sessions
- **Cross-platform** - Works on macOS and Linux
- **No dependencies** - Just Node.js required
## Package Contents
This npm package includes:
- Full VibeTunnel server with web UI
- Command-line tools (vibetunnel, vt)
- Native PTY support for terminal emulation
- Web interface with xterm.js
- Session management and forwarding
## Platform Support
- macOS (Intel and Apple Silicon)
- Linux (x64 and ARM64)
- Windows: Not yet supported ([#252](https://github.com/amantus-ai/vibetunnel/issues/252))
## Troubleshooting
### Installation Issues
If you encounter issues during installation:
1. **Missing Build Tools**: Install build essentials
```bash
# Ubuntu/Debian
sudo apt-get install build-essential python3-dev
# macOS
xcode-select --install
```
2. **Permission Issues**: Use sudo for global installation
```bash
sudo npm install -g vibetunnel
```
3. **Node Version**: Ensure Node.js 20+ is installed
```bash
node --version
```
### Runtime Issues
- **Server Won't Start**: Check if port is already in use
- **Authentication Failed**: Verify system authentication setup
- **Terminal Not Responsive**: Check browser console for WebSocket errors
### SSH Key Authentication Issues
If you encounter errors when generating or importing SSH keys (e.g., "Cannot read properties of undefined"), this is due to browser security restrictions on the Web Crypto API.
#### The Issue
Modern browsers (Chrome 60+, Firefox 75+) block the Web Crypto API when accessing web applications over HTTP from non-localhost addresses. This affects:
- Local network IPs (192.168.x.x, 10.x.x.x, 172.16-31.x.x)
- Any non-localhost hostname over HTTP
#### Solutions
1. **Use localhost (Recommended)**
```bash
# Access VibeTunnel via localhost
http://localhost:4020
# If running on a remote server, use SSH tunneling:
ssh -L 4020:localhost:4020 user@your-server
# Then access http://localhost:4020 in your browser
```
2. **Enable HTTPS**
Set up a reverse proxy with HTTPS using nginx or Caddy (recommended for production).
3. **Chrome Flag Workaround** (Development only)
- Navigate to `chrome://flags/#unsafely-treat-insecure-origin-as-secure`
- Add your server URL (e.g., `http://192.168.1.100:4020`)
- Enable the flag and restart Chrome
- ⚠️ This reduces security - use only for development
#### Why This Happens
The Web Crypto API is restricted to secure contexts (HTTPS or localhost) to prevent man-in-the-middle attacks on cryptographic operations. This is a browser security feature, not a VibeTunnel limitation.
### Development Setup
For source installations:
```bash
# Install dependencies
pnpm install
# Run development server with hot reload
pnpm run dev
# Run code quality checks
pnpm run check
# Build for production
pnpm run build
```
## Documentation
See the main repository for complete documentation: https://github.com/amantus-ai/vibetunnel
## License
MIT

27
web/package/bin/vibetunnel Executable file
View file

@ -0,0 +1,27 @@
#!/usr/bin/env node
// Start the CLI - it handles all command routing including 'fwd'
const { spawn } = require('child_process');
const path = require('path');
const cliPath = path.join(__dirname, '..', 'lib', 'vibetunnel-cli');
const args = process.argv.slice(2);
const child = spawn('node', [cliPath, ...args], {
stdio: 'inherit',
env: process.env
});
child.on('exit', (code, signal) => {
if (signal) {
// Process was killed by signal, exit with 128 + signal number convention
// Common signals: SIGTERM=15, SIGINT=2, SIGKILL=9
const signalExitCode = signal === 'SIGTERM' ? 143 :
signal === 'SIGINT' ? 130 :
signal === 'SIGKILL' ? 137 : 128;
process.exit(signalExitCode);
} else {
// Normal exit, use the exit code (or 0 if null)
process.exit(code ?? 0);
}
});

343
web/package/bin/vt Executable file
View file

@ -0,0 +1,343 @@
#!/bin/bash
# Unified VibeTunnel CLI wrapper - compatible with both Mac app and npm installations
# Only check for Mac app on macOS
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS symlink resolution function using BSD readlink
resolve_symlink_macos() {
local target="$1"
local current="$target"
while [ -L "$current" ]; do
current="$(readlink "$current")"
# Handle relative symlinks
if [[ "$current" != /* ]]; then
current="$(dirname "$target")/$current"
fi
done
echo "$current"
}
# Get the real path of this script to avoid infinite recursion
SCRIPT_REAL_PATH="$(resolve_symlink_macos "${BASH_SOURCE[0]}")"
# Comprehensive Mac app search - try standard locations first, then development locations
APP_PATH=""
# First try standard locations with valid binary check
for TRY_PATH in "/Applications/VibeTunnel.app" "$HOME/Applications/VibeTunnel.app"; do
if [ -d "$TRY_PATH" ] && [ -f "$TRY_PATH/Contents/Resources/vibetunnel" ]; then
VT_SCRIPT="$TRY_PATH/Contents/Resources/vt"
if [ -f "$VT_SCRIPT" ] && [ -x "$VT_SCRIPT" ]; then
# Avoid infinite recursion by checking if this is the same script
VT_REAL_PATH="$(resolve_symlink_macos "$VT_SCRIPT")"
if [ "$SCRIPT_REAL_PATH" != "$VT_REAL_PATH" ]; then
exec "$VT_SCRIPT" "$@"
fi
fi
APP_PATH="$TRY_PATH"
break
fi
done
# If not found in standard locations, search for development builds
if [ -z "$APP_PATH" ]; then
# First try DerivedData (for development)
for CANDIDATE in $(find ~/Library/Developer/Xcode/DerivedData -name "VibeTunnel.app" -type d 2>/dev/null | grep -v "\.dSYM" | grep -v "Index\.noindex"); do
if [ -f "$CANDIDATE/Contents/Resources/vibetunnel" ]; then
VT_SCRIPT="$CANDIDATE/Contents/Resources/vt"
if [ -f "$VT_SCRIPT" ] && [ -x "$VT_SCRIPT" ]; then
VT_REAL_PATH="$(resolve_symlink_macos "$VT_SCRIPT")"
if [ "$SCRIPT_REAL_PATH" != "$VT_REAL_PATH" ]; then
exec "$VT_SCRIPT" "$@"
fi
fi
APP_PATH="$CANDIDATE"
break
fi
done
# If still not found, use mdfind as last resort
if [ -z "$APP_PATH" ]; then
for CANDIDATE in $(mdfind -name "VibeTunnel.app" 2>/dev/null | grep -v "\.dSYM"); do
if [ -f "$CANDIDATE/Contents/Resources/vibetunnel" ]; then
VT_SCRIPT="$CANDIDATE/Contents/Resources/vt"
if [ -f "$VT_SCRIPT" ] && [ -x "$VT_SCRIPT" ]; then
VT_REAL_PATH="$(resolve_symlink_macos "$VT_SCRIPT")"
if [ "$SCRIPT_REAL_PATH" != "$VT_REAL_PATH" ]; then
exec "$VT_SCRIPT" "$@"
fi
fi
APP_PATH="$CANDIDATE"
break
fi
done
fi
fi
# If we found a Mac app but couldn't use its vt script, use its binary directly
if [ -n "$APP_PATH" ]; then
VIBETUNNEL_BIN="$APP_PATH/Contents/Resources/vibetunnel"
if [ -f "$VIBETUNNEL_BIN" ]; then
# Found Mac app bundle - will use this binary
# Silent operation - no message printed
fi
fi
fi
# If we get here without a Mac app, use the npm-installed vibetunnel
if [ -z "$VIBETUNNEL_BIN" ]; then
# First, try to find vibetunnel in the same directory as this script
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "$SCRIPT_DIR/vibetunnel" ]; then
VIBETUNNEL_BIN="$SCRIPT_DIR/vibetunnel"
else
# Try to find vibetunnel in PATH
if command -v vibetunnel >/dev/null 2>&1; then
VIBETUNNEL_BIN="$(command -v vibetunnel)"
fi
fi
if [ -z "$VIBETUNNEL_BIN" ] || [ ! -f "$VIBETUNNEL_BIN" ]; then
echo "Error: vibetunnel binary not found. Please ensure vibetunnel is installed." >&2
echo "Install with: npm install -g vibetunnel" >&2
exit 1
fi
fi
# Check if we're already inside a VibeTunnel session
if [ -n "$VIBETUNNEL_SESSION_ID" ]; then
# Special case: handle 'vt title' command inside a session
if [[ "$1" == "title" ]]; then
if [[ $# -lt 2 ]]; then
echo "Error: 'vt title' requires a title argument" >&2
echo "Usage: vt title <new title>" >&2
exit 1
fi
shift # Remove 'title' from arguments
TITLE="$*" # Get all remaining arguments as the title
# Use the vibetunnel binary's new --update-title flag
exec "$VIBETUNNEL_BIN" fwd --update-title "$TITLE" --session-id "$VIBETUNNEL_SESSION_ID"
# If exec fails, exit with error
exit 1
fi
echo "Error: Already inside a VibeTunnel session (ID: $VIBETUNNEL_SESSION_ID). Recursive VibeTunnel sessions are not supported." >&2
echo "If you need to run commands, use them directly without the 'vt' prefix." >&2
exit 1
fi
# Function to show help
show_help() {
cat << 'EOF'
vt - VibeTunnel TTY Forward Wrapper
USAGE:
vt [command] [args...]
vt --shell [args...]
vt -i [args...]
vt --no-shell-wrap [command] [args...]
vt -S [command] [args...]
vt title <new title> # Inside a VibeTunnel session only
vt --help
QUICK VERBOSITY:
-q (quiet), -v (verbose), -vv (extra), -vvv (debug)
DESCRIPTION:
This wrapper script allows VibeTunnel to see the output of commands by
forwarding TTY data through the vibetunnel utility. When you run commands
through 'vt', VibeTunnel can monitor and display the command's output
in real-time.
By default, commands are executed through your shell to resolve aliases,
functions, and builtins. Use --no-shell-wrap to execute commands directly.
Inside a VibeTunnel session, use 'vt title' to update the session name.
EXAMPLES:
vt top # Watch top with VibeTunnel monitoring
vt python script.py # Run Python script with output forwarding
vt npm test # Run tests with VibeTunnel visibility
vt --shell # Launch current shell (equivalent to vt $SHELL)
vt -i # Launch current shell (short form)
vt -S ls -la # List files without shell alias resolution
vt title "My Project" # Update session title (inside session only)
vt -q npm test # Run with minimal output (errors only)
vt -vv npm run dev # Run with verbose output
OPTIONS:
--shell, -i Launch current shell (equivalent to vt $SHELL)
--no-shell-wrap, -S Execute command directly without shell wrapper
--title-mode <mode> Terminal title mode (none, filter, static, dynamic)
Default: none (dynamic for claude)
--quiet, -q Quiet mode - only show errors
--verbose, -v Verbose mode - show more information
-vv Extra verbose - show all except debug
-vvv Debug mode - show all messages
--help, -h Show this help message and exit
TITLE MODES:
none No title management - apps control their own titles
filter Block all title changes from applications
static Show working directory and command in title
dynamic Show directory, command, and live activity status (default for web UI)
VERBOSITY:
By default, only errors are shown. Use verbosity flags to control output:
-q/--quiet Suppress all output except critical errors
-v/--verbose Show errors, warnings, and informational messages
-vv Show everything except debug messages
-vvv Show all messages including debug
You can also set VIBETUNNEL_LOG_LEVEL environment variable:
export VIBETUNNEL_LOG_LEVEL=error # Default
export VIBETUNNEL_LOG_LEVEL=warn # Show errors and warnings
export VIBETUNNEL_LOG_LEVEL=info # Show errors, warnings, and info
export VIBETUNNEL_LOG_LEVEL=verbose # All except debug
export VIBETUNNEL_LOG_LEVEL=debug # Everything
NOTE:
This script automatically detects and uses the best available VibeTunnel installation:
- Mac app bundle (preferred on macOS)
- npm package installation (fallback)
EOF
# Show path and version info
echo
echo "VIBETUNNEL BINARY:"
echo " Path: $VIBETUNNEL_BIN"
if [ -f "$VIBETUNNEL_BIN" ]; then
# Try to get version from binary output first (works for both Mac app and npm)
VERSION_INFO=$("$VIBETUNNEL_BIN" --version 2>&1 | grep "^VibeTunnel Server" | head -n 1)
BUILD_INFO=$("$VIBETUNNEL_BIN" --version 2>&1 | grep "^Built:" | head -n 1)
PLATFORM_INFO=$("$VIBETUNNEL_BIN" --version 2>&1 | grep "^Platform:" | head -n 1)
if [ -n "$VERSION_INFO" ]; then
echo " Version: ${VERSION_INFO#VibeTunnel Server }"
else
# Fallback to package.json for npm installations
PACKAGE_JSON="$(dirname "$(dirname "$VIBETUNNEL_BIN")")/package.json"
if [ -f "$PACKAGE_JSON" ]; then
VERSION=$(grep '"version"' "$PACKAGE_JSON" | head -1 | sed 's/.*"version".*:.*"\(.*\)".*/\1/')
echo " Version: $VERSION"
fi
fi
if [ -n "$BUILD_INFO" ]; then
echo " ${BUILD_INFO}"
fi
if [ -n "$PLATFORM_INFO" ]; then
echo " ${PLATFORM_INFO}"
fi
# Determine installation type
if [[ "$VIBETUNNEL_BIN" == */Applications/VibeTunnel.app/* ]]; then
echo " Status: Mac app bundle"
elif [[ "$VIBETUNNEL_BIN" == */DerivedData/* ]]; then
echo " Status: Development build"
elif [[ "$VIBETUNNEL_BIN" == *npm* ]] || [[ "$VIBETUNNEL_BIN" == */bin/vibetunnel ]]; then
echo " Status: Installed via npm"
else
echo " Status: Unknown installation"
fi
else
echo " Status: Not found"
fi
}
# Function to resolve command through user's shell
resolve_command() {
local user_shell="${SHELL:-/bin/bash}"
local cmd="$1"
shift
local shell_name=$(basename "$user_shell")
# Always try through shell first to handle aliases, functions, and builtins
# The shell will fall back to PATH lookup if no alias/function exists
case "$shell_name" in
zsh)
# For zsh, we need interactive mode to get aliases
exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$user_shell" -i -c "$(printf '%q ' "$cmd" "$@")"
;;
bash)
# For bash, expand aliases in non-interactive mode
exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$user_shell" -c "shopt -s expand_aliases; source ~/.bashrc 2>/dev/null || source ~/.bash_profile 2>/dev/null || true; $(printf '%q ' "$cmd" "$@")"
;;
*)
# Generic shell handling
exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$user_shell" -c "$(printf '%q ' "$cmd" "$@")"
;;
esac
}
# Handle --help or -h option, or no arguments (show help)
if [[ $# -eq 0 || "$1" == "--help" || "$1" == "-h" ]]; then
show_help
exit 0
fi
# Handle 'vt title' command when not inside a session
if [[ "$1" == "title" ]]; then
echo "Error: 'vt title' can only be used inside a VibeTunnel session." >&2
echo "Start a session first with 'vt' or 'vt <command>'" >&2
exit 1
fi
# Handle verbosity flags
VERBOSITY_ARGS=""
if [[ "$1" == "--quiet" || "$1" == "-q" ]]; then
VERBOSITY_ARGS="--verbosity silent"
shift
elif [[ "$1" == "--verbose" || "$1" == "-v" ]]; then
VERBOSITY_ARGS="--verbosity info"
shift
elif [[ "$1" == "-vv" ]]; then
VERBOSITY_ARGS="--verbosity verbose"
shift
elif [[ "$1" == "-vvv" ]]; then
VERBOSITY_ARGS="--verbosity debug"
shift
fi
# Handle --shell or -i option (launch current shell)
if [[ "$1" == "--shell" || "$1" == "-i" ]]; then
shift
# Execute current shell through vibetunnel
exec "$0" ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} "${SHELL:-/bin/bash}" "$@"
fi
# Handle --no-shell-wrap or -S option
NO_SHELL_WRAP=false
if [[ "$1" == "--no-shell-wrap" || "$1" == "-S" ]]; then
NO_SHELL_WRAP=true
shift
fi
# Handle --title-mode option
TITLE_MODE_ARGS=""
if [[ "$1" == "--title-mode" && $# -gt 1 ]]; then
TITLE_MODE_ARGS="--title-mode $2"
shift 2
fi
# Check if we have arguments and if the first argument is not an option
if [ $# -gt 0 ] && [[ "$1" != -* ]]; then
if [[ "$NO_SHELL_WRAP" == "true" ]]; then
# Execute directly without shell wrapper
exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$@"
else
# Check if the first argument is a real binary
if which "$1" >/dev/null 2>&1; then
# It's a real binary, execute directly
exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$@"
else
# Not a real binary, try alias resolution
resolve_command "$@"
fi
fi
else
# Run with fwd command (original behavior for options)
exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$@"
fi

2
web/package/lib/cli.js Executable file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env node
require('./vibetunnel-cli');

125
web/package/lib/vibetunnel-cli Executable file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,47 @@
# Vendored PTY
This is a vendored fork of [node-pty](https://github.com/microsoft/node-pty) v1.1.0-beta34 with the threading and shared pipe architecture removed.
## Why?
The original node-pty uses a shared pipe/socket architecture through `ConoutSocketWorker` that causes issues when used heavily:
- All PTY instances write to the same shared pipe
- This can overwhelm other Electron processes (like VS Code) that are also listening on the pipe
- Heavy usage from VibeTunnel causes crashes in other applications
## What's Changed?
1. **Removed ConoutSocketWorker** - No more worker threads for socket management
2. **Removed shared pipe architecture** - Each PTY instance uses direct file descriptors
3. **Simplified Windows implementation** - Direct socket connections without intermediary workers
4. **Kept core functionality** - The essential PTY spawn/resize/kill operations remain unchanged
## Building
```bash
npm install
npm run build
```
The native modules will be compiled during installation.
## Usage
The API remains compatible with node-pty:
```javascript
const pty = require('node-pty');
const ptyProcess = pty.spawn('bash', [], {
name: 'xterm-color',
cols: 80,
rows: 30,
cwd: process.env.HOME,
env: process.env
});
ptyProcess.on('data', function(data) {
console.log(data);
});
ptyProcess.write('ls\r');
```

View file

@ -0,0 +1,63 @@
{
'targets': [{
'target_name': 'pty',
'include_dirs': [
'src/',
'<!@(node -p "require(\'node-addon-api\').include")'
],
'defines': [ 'NAPI_CPP_EXCEPTIONS' ],
'cflags!': [ '-fno-exceptions' ],
'cflags_cc!': [ '-fno-exceptions' ],
'xcode_settings': {
'GCC_ENABLE_CPP_EXCEPTIONS': 'YES',
'CLANG_CXX_LIBRARY': 'libc++',
'MACOSX_DEPLOYMENT_TARGET': '14.0',
},
'msvs_settings': {
'VCCLCompilerTool': { 'ExceptionHandling': 1 },
},
'conditions': [
['OS=="win"', {
'sources': [
'src/win/conpty.cc',
'src/win/path_util.cc'
],
'libraries': [
'-lkernel32.lib',
'-luser32.lib',
'-lshell32.lib',
'-ladvapi32.lib'
],
'defines': [
'_WIN32_WINNT=0x0600',
'NTDDI_VERSION=0x06000000'
]
}],
['OS!="win"', {
'sources': [
'src/unix/pty.cc'
],
'libraries': [
'-lutil'
],
'conditions': [
['OS=="mac"', {
'xcode_settings': {
'MACOSX_DEPLOYMENT_TARGET': '14.0'
}
}]
]
}]
]
}, {
'target_name': 'spawn-helper',
'type': 'executable',
'conditions': [
['OS!="win"', {
'sources': [
'src/unix/spawn-helper.cc'
],
}]
]
}]
}

View file

@ -0,0 +1,13 @@
/**
* Copyright (c) 2019, Microsoft Corporation (MIT License).
*/
import { IDisposable } from './types';
export interface IEvent<T> {
(listener: (e: T) => any): IDisposable;
}
export declare class EventEmitter2<T> {
private _listeners;
private _event?;
get event(): IEvent<T>;
fire(data: T): void;
}

View file

@ -0,0 +1,40 @@
"use strict";
/**
* Copyright (c) 2019, Microsoft Corporation (MIT License).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.EventEmitter2 = void 0;
class EventEmitter2 {
constructor() {
this._listeners = [];
}
get event() {
if (!this._event) {
this._event = (listener) => {
this._listeners.push(listener);
const disposable = {
dispose: () => {
for (let i = 0; i < this._listeners.length; i++) {
if (this._listeners[i] === listener) {
this._listeners.splice(i, 1);
return;
}
}
}
};
return disposable;
};
}
return this._event;
}
fire(data) {
const queue = [];
for (let i = 0; i < this._listeners.length; i++) {
queue.push(this._listeners[i]);
}
for (let i = 0; i < queue.length; i++) {
queue[i].call(undefined, data);
}
}
}
exports.EventEmitter2 = EventEmitter2;

15
web/package/node-pty/lib/index.d.ts vendored Normal file
View file

@ -0,0 +1,15 @@
/**
* Minimal PTY implementation without threading
* Vendored from node-pty, simplified to remove shared pipe architecture
*/
import { ITerminal, IPtyForkOptions, IWindowsPtyForkOptions } from './interfaces';
import { ArgvOrCommandLine } from './types';
/**
* Forks a process as a pseudoterminal.
*/
export declare function spawn(file?: string, args?: ArgvOrCommandLine, opt?: IPtyForkOptions | IWindowsPtyForkOptions): ITerminal;
export declare const fork: typeof spawn;
export declare const createTerminal: typeof spawn;
export * from './interfaces';
export * from './types';
export type IPty = ITerminal;

View file

@ -0,0 +1,41 @@
"use strict";
/**
* Minimal PTY implementation without threading
* Vendored from node-pty, simplified to remove shared pipe architecture
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createTerminal = exports.fork = void 0;
exports.spawn = spawn;
let terminalCtor;
if (process.platform === 'win32') {
terminalCtor = require('./windowsTerminal').WindowsTerminal;
}
else {
terminalCtor = require('./unixTerminal').UnixTerminal;
}
/**
* Forks a process as a pseudoterminal.
*/
function spawn(file, args, opt) {
return new terminalCtor(file, args, opt);
}
// Deprecated aliases
exports.fork = spawn;
exports.createTerminal = spawn;
// Re-export types
__exportStar(require("./interfaces"), exports);
__exportStar(require("./types"), exports);

120
web/package/node-pty/lib/interfaces.d.ts vendored Normal file
View file

@ -0,0 +1,120 @@
/**
* Copyright (c) 2016, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
export interface IProcessEnv {
[key: string]: string | undefined;
}
import type { IExitEvent } from './types';
import type { IEvent } from './eventEmitter2';
export interface ITerminal {
/**
* Gets the name of the process.
*/
process: string;
/**
* Gets the process ID.
*/
pid: number;
/**
* The data event.
*/
readonly onData: IEvent<string>;
/**
* The exit event.
*/
readonly onExit: IEvent<IExitEvent>;
/**
* Writes data to the socket.
* @param data The data to write.
*/
write(data: string): void;
/**
* Resize the pty.
* @param cols The number of columns.
* @param rows The number of rows.
*/
resize(cols: number, rows: number): void;
/**
* Clears the pty's internal representation of its buffer. This is a no-op
* unless on Windows/ConPTY.
*/
clear(): void;
/**
* Close, kill and destroy the socket.
*/
destroy(): void;
/**
* Kill the pty.
* @param signal The signal to send, by default this is SIGHUP. This is not
* supported on Windows.
*/
kill(signal?: string): void;
/**
* Set the pty socket encoding.
*/
setEncoding(encoding: string | null): void;
/**
* Resume the pty socket.
*/
resume(): void;
/**
* Pause the pty socket.
*/
pause(): void;
/**
* Alias for ITerminal.on(eventName, listener).
*/
addListener(eventName: string, listener: (...args: any[]) => any): void;
/**
* Adds the listener function to the end of the listeners array for the event
* named eventName.
* @param eventName The event name.
* @param listener The callback function
*/
on(eventName: string, listener: (...args: any[]) => any): void;
/**
* Returns a copy of the array of listeners for the event named eventName.
*/
listeners(eventName: string): Function[];
/**
* Removes the specified listener from the listener array for the event named
* eventName.
*/
removeListener(eventName: string, listener: (...args: any[]) => any): void;
/**
* Removes all listeners, or those of the specified eventName.
*/
removeAllListeners(eventName: string): void;
/**
* Adds a one time listener function for the event named eventName. The next
* time eventName is triggered, this listener is removed and then invoked.
*/
once(eventName: string, listener: (...args: any[]) => any): void;
}
interface IBasePtyForkOptions {
name?: string;
cols?: number;
rows?: number;
cwd?: string;
env?: IProcessEnv;
encoding?: string | null;
handleFlowControl?: boolean;
flowControlPause?: string;
flowControlResume?: string;
}
export interface IPtyForkOptions extends IBasePtyForkOptions {
uid?: number;
gid?: number;
}
export interface IWindowsPtyForkOptions extends IBasePtyForkOptions {
useConpty?: boolean;
useConptyDll?: boolean;
conptyInheritCursor?: boolean;
}
export interface IPtyOpenOptions {
cols?: number;
rows?: number;
encoding?: string | null;
}
export {};

View file

@ -0,0 +1,6 @@
"use strict";
/**
* Copyright (c) 2016, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
Object.defineProperty(exports, "__esModule", { value: true });

66
web/package/node-pty/lib/terminal.d.ts vendored Normal file
View file

@ -0,0 +1,66 @@
/**
* Copyright (c) 2012-2015, Christopher Jeffrey (MIT License)
* Copyright (c) 2016, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
import { Socket } from 'net';
import { EventEmitter } from 'events';
import { ITerminal, IPtyForkOptions, IProcessEnv } from './interfaces';
import { IEvent } from './eventEmitter2';
import { IExitEvent } from './types';
export declare const DEFAULT_COLS: number;
export declare const DEFAULT_ROWS: number;
export declare abstract class Terminal implements ITerminal {
protected _socket: Socket;
protected _pid: number;
protected _fd: number;
protected _pty: any;
protected _file: string;
protected _name: string;
protected _cols: number;
protected _rows: number;
protected _readable: boolean;
protected _writable: boolean;
protected _internalee: EventEmitter;
private _flowControlPause;
private _flowControlResume;
handleFlowControl: boolean;
private _onData;
get onData(): IEvent<string>;
private _onExit;
get onExit(): IEvent<IExitEvent>;
get pid(): number;
get cols(): number;
get rows(): number;
constructor(opt?: IPtyForkOptions);
protected abstract _write(data: string): void;
write(data: string): void;
protected _forwardEvents(): void;
protected _checkType<T>(name: string, value: T | undefined, type: string, allowArray?: boolean): void;
/** See net.Socket.end */
end(data: string): void;
/** See stream.Readable.pipe */
pipe(dest: any, options: any): any;
/** See net.Socket.pause */
pause(): Socket;
/** See net.Socket.resume */
resume(): Socket;
/** See net.Socket.setEncoding */
setEncoding(encoding: string | null): void;
addListener(eventName: string, listener: (...args: any[]) => any): void;
on(eventName: string, listener: (...args: any[]) => any): void;
emit(eventName: string, ...args: any[]): any;
listeners(eventName: string): Function[];
removeListener(eventName: string, listener: (...args: any[]) => any): void;
removeAllListeners(eventName: string): void;
once(eventName: string, listener: (...args: any[]) => any): void;
abstract resize(cols: number, rows: number): void;
abstract clear(): void;
abstract destroy(): void;
abstract kill(signal?: string): void;
abstract get process(): string;
abstract get master(): Socket | undefined;
abstract get slave(): Socket | undefined;
protected _close(): void;
protected _parseEnv(env: IProcessEnv): string[];
}

View file

@ -0,0 +1,162 @@
"use strict";
/**
* Copyright (c) 2012-2015, Christopher Jeffrey (MIT License)
* Copyright (c) 2016, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.Terminal = exports.DEFAULT_ROWS = exports.DEFAULT_COLS = void 0;
const events_1 = require("events");
const eventEmitter2_1 = require("./eventEmitter2");
exports.DEFAULT_COLS = 80;
exports.DEFAULT_ROWS = 24;
/**
* Default messages to indicate PAUSE/RESUME for automatic flow control.
* To avoid conflicts with rebound XON/XOFF control codes (such as on-my-zsh),
* the sequences can be customized in `IPtyForkOptions`.
*/
const FLOW_CONTROL_PAUSE = '\x13'; // defaults to XOFF
const FLOW_CONTROL_RESUME = '\x11'; // defaults to XON
class Terminal {
get onData() { return this._onData.event; }
get onExit() { return this._onExit.event; }
get pid() { return this._pid; }
get cols() { return this._cols; }
get rows() { return this._rows; }
constructor(opt) {
this._pid = 0;
this._fd = 0;
this._cols = 0;
this._rows = 0;
this._readable = false;
this._writable = false;
this._onData = new eventEmitter2_1.EventEmitter2();
this._onExit = new eventEmitter2_1.EventEmitter2();
// for 'close'
this._internalee = new events_1.EventEmitter();
// setup flow control handling
this.handleFlowControl = !!(opt?.handleFlowControl);
this._flowControlPause = opt?.flowControlPause || FLOW_CONTROL_PAUSE;
this._flowControlResume = opt?.flowControlResume || FLOW_CONTROL_RESUME;
if (!opt) {
return;
}
// Do basic type checks here in case node-pty is being used within JavaScript. If the wrong
// types go through to the C++ side it can lead to hard to diagnose exceptions.
this._checkType('name', opt.name ? opt.name : undefined, 'string');
this._checkType('cols', opt.cols ? opt.cols : undefined, 'number');
this._checkType('rows', opt.rows ? opt.rows : undefined, 'number');
this._checkType('cwd', opt.cwd ? opt.cwd : undefined, 'string');
this._checkType('env', opt.env ? opt.env : undefined, 'object');
this._checkType('uid', opt.uid ? opt.uid : undefined, 'number');
this._checkType('gid', opt.gid ? opt.gid : undefined, 'number');
this._checkType('encoding', opt.encoding ? opt.encoding : undefined, 'string');
}
write(data) {
if (this.handleFlowControl) {
// PAUSE/RESUME messages are not forwarded to the pty
if (data === this._flowControlPause) {
this.pause();
return;
}
if (data === this._flowControlResume) {
this.resume();
return;
}
}
// everything else goes to the real pty
this._write(data);
}
_forwardEvents() {
this.on('data', e => this._onData.fire(e));
this.on('exit', (exitCode, signal) => this._onExit.fire({ exitCode, signal }));
}
_checkType(name, value, type, allowArray = false) {
if (value === undefined) {
return;
}
if (allowArray) {
if (Array.isArray(value)) {
value.forEach((v, i) => {
if (typeof v !== type) {
throw new Error(`${name}[${i}] must be a ${type} (not a ${typeof v})`);
}
});
return;
}
}
if (typeof value !== type) {
throw new Error(`${name} must be a ${type} (not a ${typeof value})`);
}
}
/** See net.Socket.end */
end(data) {
this._socket.end(data);
}
/** See stream.Readable.pipe */
pipe(dest, options) {
return this._socket.pipe(dest, options);
}
/** See net.Socket.pause */
pause() {
return this._socket.pause();
}
/** See net.Socket.resume */
resume() {
return this._socket.resume();
}
/** See net.Socket.setEncoding */
setEncoding(encoding) {
if (this._socket._decoder) {
delete this._socket._decoder;
}
if (encoding) {
this._socket.setEncoding(encoding);
}
}
addListener(eventName, listener) { this.on(eventName, listener); }
on(eventName, listener) {
if (eventName === 'close') {
this._internalee.on('close', listener);
return;
}
this._socket.on(eventName, listener);
}
emit(eventName, ...args) {
if (eventName === 'close') {
return this._internalee.emit.apply(this._internalee, arguments);
}
return this._socket.emit.apply(this._socket, arguments);
}
listeners(eventName) {
return this._socket.listeners(eventName);
}
removeListener(eventName, listener) {
this._socket.removeListener(eventName, listener);
}
removeAllListeners(eventName) {
this._socket.removeAllListeners(eventName);
}
once(eventName, listener) {
this._socket.once(eventName, listener);
}
_close() {
this._socket.readable = false;
this.write = () => { };
this.end = () => { };
this._writable = false;
this._readable = false;
}
_parseEnv(env) {
const keys = Object.keys(env || {});
const pairs = [];
for (let i = 0; i < keys.length; i++) {
if (keys[i] === undefined) {
continue;
}
pairs.push(keys[i] + '=' + env[keys[i]]);
}
return pairs;
}
}
exports.Terminal = Terminal;

12
web/package/node-pty/lib/types.d.ts vendored Normal file
View file

@ -0,0 +1,12 @@
/**
* Copyright (c) 2017, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
export type ArgvOrCommandLine = string[] | string;
export interface IExitEvent {
exitCode: number;
signal: number | undefined;
}
export interface IDisposable {
dispose(): void;
}

View file

@ -0,0 +1,6 @@
"use strict";
/**
* Copyright (c) 2017, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
Object.defineProperty(exports, "__esModule", { value: true });

View file

@ -0,0 +1,43 @@
/**
* Copyright (c) 2012-2015, Christopher Jeffrey (MIT License)
* Copyright (c) 2016, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
import * as net from 'net';
import { Terminal } from './terminal';
import { IPtyForkOptions, IPtyOpenOptions } from './interfaces';
import { ArgvOrCommandLine } from './types';
export declare class UnixTerminal extends Terminal {
protected _fd: number;
protected _pty: string;
protected _file: string;
protected _name: string;
protected _readable: boolean;
protected _writable: boolean;
private _boundClose;
private _emittedClose;
private _master;
private _slave;
get master(): net.Socket | undefined;
get slave(): net.Socket | undefined;
constructor(file?: string, args?: ArgvOrCommandLine, opt?: IPtyForkOptions);
protected _write(data: string): void;
get fd(): number;
get ptsName(): string;
/**
* openpty
*/
static open(opt: IPtyOpenOptions): UnixTerminal;
destroy(): void;
kill(signal?: string): void;
/**
* Gets the name of the process.
*/
get process(): string;
/**
* TTY
*/
resize(cols: number, rows: number): void;
clear(): void;
private _sanitizeEnv;
}

View file

@ -0,0 +1,300 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.UnixTerminal = void 0;
const path = __importStar(require("path"));
const tty = __importStar(require("tty"));
const terminal_1 = require("./terminal");
const utils_1 = require("./utils");
let pty;
let helperPath;
// Check if running in SEA (Single Executable Application) context
if (process.env.VIBETUNNEL_SEA) {
// In SEA mode, load native module using process.dlopen
const fs = require('fs');
const execDir = path.dirname(process.execPath);
const ptyPath = path.join(execDir, 'pty.node');
if (fs.existsSync(ptyPath)) {
const module = { exports: {} };
process.dlopen(module, ptyPath);
pty = module.exports;
}
else {
throw new Error(`Could not find pty.node next to executable at: ${ptyPath}`);
}
// Set spawn-helper path for macOS only (Linux doesn't use it)
if (process.platform === 'darwin') {
helperPath = path.join(execDir, 'spawn-helper');
if (!fs.existsSync(helperPath)) {
console.warn(`spawn-helper not found at ${helperPath}, PTY operations may fail`);
}
}
}
else {
// Standard Node.js loading
try {
pty = require('../build/Release/pty.node');
helperPath = '../build/Release/spawn-helper';
}
catch (outerError) {
try {
pty = require('../build/Debug/pty.node');
helperPath = '../build/Debug/spawn-helper';
}
catch (innerError) {
console.error('innerError', innerError);
throw outerError;
}
}
helperPath = path.resolve(__dirname, helperPath);
helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
}
const DEFAULT_FILE = 'sh';
const DEFAULT_NAME = 'xterm';
const DESTROY_SOCKET_TIMEOUT_MS = 200;
class UnixTerminal extends terminal_1.Terminal {
get master() { return this._master; }
get slave() { return this._slave; }
constructor(file, args, opt) {
super(opt);
this._boundClose = false;
this._emittedClose = false;
if (typeof args === 'string') {
throw new Error('args as a string is not supported on unix.');
}
// Initialize arguments
args = args || [];
file = file || DEFAULT_FILE;
opt = opt || {};
opt.env = opt.env || process.env;
this._cols = opt.cols || terminal_1.DEFAULT_COLS;
this._rows = opt.rows || terminal_1.DEFAULT_ROWS;
const uid = opt.uid ?? -1;
const gid = opt.gid ?? -1;
const env = (0, utils_1.assign)({}, opt.env);
if (opt.env === process.env) {
this._sanitizeEnv(env);
}
const cwd = opt.cwd || process.cwd();
env.PWD = cwd;
const name = opt.name || env.TERM || DEFAULT_NAME;
env.TERM = name;
const parsedEnv = this._parseEnv(env);
const encoding = (opt.encoding === undefined ? 'utf8' : opt.encoding);
const onexit = (code, signal) => {
// XXX Sometimes a data event is emitted after exit. Wait til socket is
// destroyed.
if (!this._emittedClose) {
if (this._boundClose) {
return;
}
this._boundClose = true;
// From macOS High Sierra 10.13.2 sometimes the socket never gets
// closed. A timeout is applied here to avoid the terminal never being
// destroyed when this occurs.
let timeout = setTimeout(() => {
timeout = null;
// Destroying the socket now will cause the close event to fire
this._socket.destroy();
}, DESTROY_SOCKET_TIMEOUT_MS);
this.once('close', () => {
if (timeout !== null) {
clearTimeout(timeout);
}
this.emit('exit', code, signal);
});
return;
}
this.emit('exit', code, signal);
};
// fork
const term = pty.fork(file, args, parsedEnv, cwd, this._cols, this._rows, uid, gid, (encoding === 'utf8'), helperPath, onexit);
this._socket = new tty.ReadStream(term.fd);
if (encoding !== null) {
this._socket.setEncoding(encoding);
}
// setup
this._socket.on('error', (err) => {
// NOTE: fs.ReadStream gets EAGAIN twice at first:
if (err.code) {
if (~err.code.indexOf('EAGAIN')) {
return;
}
}
// close
this._close();
// EIO on exit from fs.ReadStream:
if (!this._emittedClose) {
this._emittedClose = true;
this.emit('close');
}
// EIO, happens when someone closes our child process: the only process in
// the terminal.
// node < 0.6.14: errno 5
// node >= 0.6.14: read EIO
if (err.code) {
if (~err.code.indexOf('errno 5') || ~err.code.indexOf('EIO')) {
return;
}
}
// throw anything else
if (this.listeners('error').length < 2) {
throw err;
}
});
this._pid = term.pid;
this._fd = term.fd;
this._pty = term.pty;
this._file = file;
this._name = name;
this._readable = true;
this._writable = true;
this._socket.on('close', () => {
if (this._emittedClose) {
return;
}
this._emittedClose = true;
this._close();
this.emit('close');
});
this._forwardEvents();
}
_write(data) {
this._socket.write(data);
}
/* Accessors */
get fd() { return this._fd; }
get ptsName() { return this._pty; }
/**
* openpty
*/
static open(opt) {
const self = Object.create(UnixTerminal.prototype);
opt = opt || {};
if (arguments.length > 1) {
opt = {
cols: arguments[1],
rows: arguments[2]
};
}
const cols = opt.cols || terminal_1.DEFAULT_COLS;
const rows = opt.rows || terminal_1.DEFAULT_ROWS;
const encoding = (opt.encoding === undefined ? 'utf8' : opt.encoding);
// open
const term = pty.open(cols, rows);
self._master = new tty.ReadStream(term.master);
if (encoding !== null) {
self._master.setEncoding(encoding);
}
self._master.resume();
self._slave = new tty.ReadStream(term.slave);
if (encoding !== null) {
self._slave.setEncoding(encoding);
}
self._slave.resume();
self._socket = self._master;
self._pid = -1;
self._fd = term.master;
self._pty = term.pty;
self._file = process.argv[0] || 'node';
self._name = process.env.TERM || '';
self._readable = true;
self._writable = true;
self._socket.on('error', err => {
self._close();
if (self.listeners('error').length < 2) {
throw err;
}
});
self._socket.on('close', () => {
self._close();
});
return self;
}
destroy() {
this._close();
// Need to close the read stream so node stops reading a dead file
// descriptor. Then we can safely SIGHUP the shell.
this._socket.once('close', () => {
this.kill('SIGHUP');
});
this._socket.destroy();
}
kill(signal) {
try {
process.kill(this.pid, signal || 'SIGHUP');
}
catch (e) { /* swallow */ }
}
/**
* Gets the name of the process.
*/
get process() {
if (process.platform === 'darwin') {
const title = pty.process(this._fd);
return (title !== 'kernel_task') ? title : this._file;
}
return pty.process(this._fd, this._pty) || this._file;
}
/**
* TTY
*/
resize(cols, rows) {
if (cols <= 0 || rows <= 0 || isNaN(cols) || isNaN(rows) || cols === Infinity || rows === Infinity) {
throw new Error('resizing must be done using positive cols and rows');
}
pty.resize(this._fd, cols, rows);
this._cols = cols;
this._rows = rows;
}
clear() {
}
_sanitizeEnv(env) {
// Make sure we didn't start our server from inside tmux.
delete env['TMUX'];
delete env['TMUX_PANE'];
// Make sure we didn't start our server from inside screen.
// http://web.mit.edu/gnu/doc/html/screen_20.html
delete env['STY'];
delete env['WINDOW'];
// Delete some variables that might confuse our terminal.
delete env['WINDOWID'];
delete env['TERMCAP'];
delete env['COLUMNS'];
delete env['LINES'];
}
}
exports.UnixTerminal = UnixTerminal;

5
web/package/node-pty/lib/utils.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/**
* Copyright (c) 2017, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
export declare function assign(target: any, ...sources: any[]): any;

View file

@ -0,0 +1,11 @@
"use strict";
/**
* Copyright (c) 2017, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.assign = assign;
function assign(target, ...sources) {
sources.forEach(source => Object.keys(source).forEach(key => target[key] = source[key]));
return target;
}

View file

@ -0,0 +1,34 @@
/**
* Simplified Windows terminal implementation without threading
* Removed ConoutSocketWorker and shared pipe architecture
*/
import { Socket } from 'net';
import { Terminal } from './terminal';
import { IPtyOpenOptions, IWindowsPtyForkOptions } from './interfaces';
import { ArgvOrCommandLine } from './types';
export declare class WindowsTerminal extends Terminal {
private _isReady;
protected _pid: number;
private _innerPid;
private _ptyNative;
protected _pty: number;
private _inSocket;
private _outSocket;
private _exitCode;
private _useConptyDll;
get master(): Socket | undefined;
get slave(): Socket | undefined;
constructor(file?: string, args?: ArgvOrCommandLine, opt?: IWindowsPtyForkOptions);
private _setupDirectSockets;
protected _write(data: string): void;
resize(cols: number, rows: number): void;
clear(): void;
kill(signal?: string): void;
protected _close(): void;
private _generatePipeName;
private _argsToCommandLine;
static open(options?: IPtyOpenOptions): void;
get process(): string;
get pid(): number;
destroy(): void;
}

View file

@ -0,0 +1,200 @@
"use strict";
/**
* Simplified Windows terminal implementation without threading
* Removed ConoutSocketWorker and shared pipe architecture
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.WindowsTerminal = void 0;
const fs = __importStar(require("fs"));
const net_1 = require("net");
const terminal_1 = require("./terminal");
const utils_1 = require("./utils");
const DEFAULT_FILE = 'cmd.exe';
const DEFAULT_NAME = 'Windows Shell';
let conptyNative;
class WindowsTerminal extends terminal_1.Terminal {
get master() { return this._outSocket; }
get slave() { return this._inSocket; }
constructor(file, args, opt) {
super(opt);
this._isReady = false;
this._pid = 0;
this._innerPid = 0;
this._useConptyDll = false;
// Load native module
if (!conptyNative) {
try {
conptyNative = require('../build/Release/conpty.node');
}
catch (outerError) {
try {
conptyNative = require('../build/Debug/conpty.node');
}
catch (innerError) {
throw outerError;
}
}
}
this._ptyNative = conptyNative;
// Initialize arguments
args = args || [];
file = file || DEFAULT_FILE;
opt = opt || {};
opt.env = opt.env || process.env;
const env = (0, utils_1.assign)({}, opt.env);
this._cols = opt.cols || terminal_1.DEFAULT_COLS;
this._rows = opt.rows || terminal_1.DEFAULT_ROWS;
const cwd = opt.cwd || process.cwd();
const parsedEnv = this._parseEnv(env);
// Compose command line
const commandLine = this._argsToCommandLine(file, args);
// Start ConPTY process
const pipeName = this._generatePipeName();
const term = this._ptyNative.startProcess(file, this._cols, this._rows, false, pipeName, false, this._useConptyDll);
this._pty = term.pty;
this._fd = term.fd;
// Create direct socket connections without worker threads
this._setupDirectSockets(term);
// Connect the process
const connect = this._ptyNative.connect(this._pty, commandLine, cwd, parsedEnv, this._useConptyDll, (exitCode) => {
this._exitCode = exitCode;
this.emit('exit', exitCode);
this._close();
});
this._innerPid = connect.pid;
this._pid = connect.pid;
this._file = file;
this._name = opt.name || env.TERM || DEFAULT_NAME;
this._readable = true;
this._writable = true;
this._forwardEvents();
}
_setupDirectSockets(term) {
// Setup output socket - read directly from conout
const outFd = fs.openSync(term.conout, 'r');
this._outSocket = new net_1.Socket({ fd: outFd, readable: true, writable: false });
this._outSocket.setEncoding('utf8');
this._socket = this._outSocket;
// Setup input socket - write directly to conin
const inFd = fs.openSync(term.conin, 'w');
this._inSocket = new net_1.Socket({ fd: inFd, readable: false, writable: true });
this._inSocket.setEncoding('utf8');
// Forward events directly
this._outSocket.on('data', (data) => {
if (!this._isReady) {
this._isReady = true;
}
this.emit('data', data);
});
this._outSocket.on('error', (err) => {
if (err.code && (err.code.includes('EPIPE') || err.code.includes('EIO'))) {
// Expected errors when process exits
return;
}
this.emit('error', err);
});
this._outSocket.on('close', () => {
if (this._exitCode === undefined) {
this.emit('exit', 0);
}
this._close();
});
}
_write(data) {
if (this._inSocket && this._inSocket.writable) {
this._inSocket.write(data);
}
}
resize(cols, rows) {
if (cols <= 0 || rows <= 0 || isNaN(cols) || isNaN(rows) || cols === Infinity || rows === Infinity) {
throw new Error('resizing must be done using positive cols and rows');
}
if (this._exitCode !== undefined) {
throw new Error('Cannot resize a pty that has already exited');
}
this._cols = cols;
this._rows = rows;
this._ptyNative.resize(this._pty, cols, rows, this._useConptyDll);
}
clear() {
this._ptyNative.clear(this._pty, this._useConptyDll);
}
kill(signal) {
this._close();
try {
process.kill(this._pid);
}
catch (e) {
// Ignore if process cannot be found
}
this._ptyNative.kill(this._pty, this._useConptyDll);
}
_close() {
if (this._inSocket) {
this._inSocket.destroy();
}
if (this._outSocket) {
this._outSocket.destroy();
}
}
_generatePipeName() {
return `\\\\.\\pipe\\conpty-${Date.now()}-${Math.random()}`;
}
_argsToCommandLine(file, args) {
if (typeof args === 'string') {
return `${file} ${args}`;
}
const argv = [file];
if (args) {
argv.push(...args);
}
return argv.map(arg => {
if (arg.includes(' ') || arg.includes('\t')) {
return `"${arg.replace(/"/g, '\\"')}"`;
}
return arg;
}).join(' ');
}
static open(options) {
throw new Error('open() not supported on windows, use spawn() instead.');
}
get process() { return this._name; }
get pid() { return this._pid; }
destroy() {
this.kill();
}
}
exports.WindowsTerminal = WindowsTerminal;

View file

@ -0,0 +1,24 @@
{
"name": "node-pty",
"version": "1.0.0",
"description": "Minimal PTY implementation without threading - vendored from node-pty",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"scripts": {
"build": "tsc && node-gyp rebuild",
"clean": "rimraf lib build",
"install": "node-gyp rebuild"
},
"dependencies": {
"node-addon-api": "^7.1.0"
},
"devDependencies": {
"@types/node": "^24.0.3",
"node-gyp": "^11.0.0",
"prebuild": "^13.0.1",
"rimraf": "^5.0.5",
"typescript": "^5.8.3"
},
"gypfile": true,
"license": "MIT"
}

View file

@ -0,0 +1,48 @@
/**
* Copyright (c) 2019, Microsoft Corporation (MIT License).
*/
import { IDisposable } from './types';
interface IListener<T> {
(e: T): void;
}
export interface IEvent<T> {
(listener: (e: T) => any): IDisposable;
}
export class EventEmitter2<T> {
private _listeners: IListener<T>[] = [];
private _event?: IEvent<T>;
public get event(): IEvent<T> {
if (!this._event) {
this._event = (listener: (e: T) => any) => {
this._listeners.push(listener);
const disposable = {
dispose: () => {
for (let i = 0; i < this._listeners.length; i++) {
if (this._listeners[i] === listener) {
this._listeners.splice(i, 1);
return;
}
}
}
};
return disposable;
};
}
return this._event;
}
public fire(data: T): void {
const queue: IListener<T>[] = [];
for (let i = 0; i < this._listeners.length; i++) {
queue.push(this._listeners[i]);
}
for (let i = 0; i < queue.length; i++) {
queue[i].call(undefined, data);
}
}
}

View file

@ -0,0 +1,32 @@
/**
* Minimal PTY implementation without threading
* Vendored from node-pty, simplified to remove shared pipe architecture
*/
import { ITerminal, IPtyForkOptions, IWindowsPtyForkOptions } from './interfaces';
import { ArgvOrCommandLine } from './types';
let terminalCtor: any;
if (process.platform === 'win32') {
terminalCtor = require('./windowsTerminal').WindowsTerminal;
} else {
terminalCtor = require('./unixTerminal').UnixTerminal;
}
/**
* Forks a process as a pseudoterminal.
*/
export function spawn(file?: string, args?: ArgvOrCommandLine, opt?: IPtyForkOptions | IWindowsPtyForkOptions): ITerminal {
return new terminalCtor(file, args, opt);
}
// Deprecated aliases
export const fork = spawn;
export const createTerminal = spawn;
// Re-export types
export * from './interfaces';
export * from './types';
// Alias for compatibility
export type IPty = ITerminal;

View file

@ -0,0 +1,143 @@
/**
* Copyright (c) 2016, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
export interface IProcessEnv {
[key: string]: string | undefined;
}
import type { IExitEvent } from './types';
import type { IEvent } from './eventEmitter2';
export interface ITerminal {
/**
* Gets the name of the process.
*/
process: string;
/**
* Gets the process ID.
*/
pid: number;
/**
* The data event.
*/
readonly onData: IEvent<string>;
/**
* The exit event.
*/
readonly onExit: IEvent<IExitEvent>;
/**
* Writes data to the socket.
* @param data The data to write.
*/
write(data: string): void;
/**
* Resize the pty.
* @param cols The number of columns.
* @param rows The number of rows.
*/
resize(cols: number, rows: number): void;
/**
* Clears the pty's internal representation of its buffer. This is a no-op
* unless on Windows/ConPTY.
*/
clear(): void;
/**
* Close, kill and destroy the socket.
*/
destroy(): void;
/**
* Kill the pty.
* @param signal The signal to send, by default this is SIGHUP. This is not
* supported on Windows.
*/
kill(signal?: string): void;
/**
* Set the pty socket encoding.
*/
setEncoding(encoding: string | null): void;
/**
* Resume the pty socket.
*/
resume(): void;
/**
* Pause the pty socket.
*/
pause(): void;
/**
* Alias for ITerminal.on(eventName, listener).
*/
addListener(eventName: string, listener: (...args: any[]) => any): void;
/**
* Adds the listener function to the end of the listeners array for the event
* named eventName.
* @param eventName The event name.
* @param listener The callback function
*/
on(eventName: string, listener: (...args: any[]) => any): void;
/**
* Returns a copy of the array of listeners for the event named eventName.
*/
listeners(eventName: string): Function[];
/**
* Removes the specified listener from the listener array for the event named
* eventName.
*/
removeListener(eventName: string, listener: (...args: any[]) => any): void;
/**
* Removes all listeners, or those of the specified eventName.
*/
removeAllListeners(eventName: string): void;
/**
* Adds a one time listener function for the event named eventName. The next
* time eventName is triggered, this listener is removed and then invoked.
*/
once(eventName: string, listener: (...args: any[]) => any): void;
}
interface IBasePtyForkOptions {
name?: string;
cols?: number;
rows?: number;
cwd?: string;
env?: IProcessEnv;
encoding?: string | null;
handleFlowControl?: boolean;
flowControlPause?: string;
flowControlResume?: string;
}
export interface IPtyForkOptions extends IBasePtyForkOptions {
uid?: number;
gid?: number;
}
export interface IWindowsPtyForkOptions extends IBasePtyForkOptions {
useConpty?: boolean;
useConptyDll?: boolean;
conptyInheritCursor?: boolean;
}
export interface IPtyOpenOptions {
cols?: number;
rows?: number;
encoding?: string | null;
}

54
web/package/node-pty/src/native.d.ts vendored Normal file
View file

@ -0,0 +1,54 @@
/**
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
interface IConptyNative {
startProcess(file: string, cols: number, rows: number, debug: boolean, pipeName: string, conptyInheritCursor: boolean, useConptyDll: boolean): IConptyProcess;
connect(ptyId: number, commandLine: string, cwd: string, env: string[], useConptyDll: boolean, onExitCallback: (exitCode: number) => void): { pid: number };
resize(ptyId: number, cols: number, rows: number, useConptyDll: boolean): void;
clear(ptyId: number, useConptyDll: boolean): void;
kill(ptyId: number, useConptyDll: boolean): void;
}
interface IWinptyNative {
startProcess(file: string, commandLine: string, env: string[], cwd: string, cols: number, rows: number, debug: boolean): IWinptyProcess;
resize(pid: number, cols: number, rows: number): void;
kill(pid: number, innerPid: number): void;
getProcessList(pid: number): number[];
getExitCode(innerPid: number): number;
}
interface IUnixNative {
fork(file: string, args: string[], parsedEnv: string[], cwd: string, cols: number, rows: number, uid: number, gid: number, useUtf8: boolean, helperPath: string, onExitCallback: (code: number, signal: number) => void): IUnixProcess;
open(cols: number, rows: number): IUnixOpenProcess;
process(fd: number, pty?: string): string;
resize(fd: number, cols: number, rows: number): void;
}
interface IConptyProcess {
pty: number;
fd: number;
conin: string;
conout: string;
}
interface IWinptyProcess {
pty: number;
fd: number;
conin: string;
conout: string;
pid: number;
innerPid: number;
}
interface IUnixProcess {
fd: number;
pid: number;
pty: string;
}
interface IUnixOpenProcess {
master: number;
slave: number;
pty: string;
}

View file

@ -0,0 +1,211 @@
/**
* Copyright (c) 2012-2015, Christopher Jeffrey (MIT License)
* Copyright (c) 2016, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
import { Socket } from 'net';
import { EventEmitter } from 'events';
import { ITerminal, IPtyForkOptions, IProcessEnv } from './interfaces';
import { EventEmitter2, IEvent } from './eventEmitter2';
import { IExitEvent } from './types';
export const DEFAULT_COLS: number = 80;
export const DEFAULT_ROWS: number = 24;
/**
* Default messages to indicate PAUSE/RESUME for automatic flow control.
* To avoid conflicts with rebound XON/XOFF control codes (such as on-my-zsh),
* the sequences can be customized in `IPtyForkOptions`.
*/
const FLOW_CONTROL_PAUSE = '\x13'; // defaults to XOFF
const FLOW_CONTROL_RESUME = '\x11'; // defaults to XON
export abstract class Terminal implements ITerminal {
protected _socket!: Socket; // HACK: This is unsafe
protected _pid: number = 0;
protected _fd: number = 0;
protected _pty: any;
protected _file!: string; // HACK: This is unsafe
protected _name!: string; // HACK: This is unsafe
protected _cols: number = 0;
protected _rows: number = 0;
protected _readable: boolean = false;
protected _writable: boolean = false;
protected _internalee: EventEmitter;
private _flowControlPause: string;
private _flowControlResume: string;
public handleFlowControl: boolean;
private _onData = new EventEmitter2<string>();
public get onData(): IEvent<string> { return this._onData.event; }
private _onExit = new EventEmitter2<IExitEvent>();
public get onExit(): IEvent<IExitEvent> { return this._onExit.event; }
public get pid(): number { return this._pid; }
public get cols(): number { return this._cols; }
public get rows(): number { return this._rows; }
constructor(opt?: IPtyForkOptions) {
// for 'close'
this._internalee = new EventEmitter();
// setup flow control handling
this.handleFlowControl = !!(opt?.handleFlowControl);
this._flowControlPause = opt?.flowControlPause || FLOW_CONTROL_PAUSE;
this._flowControlResume = opt?.flowControlResume || FLOW_CONTROL_RESUME;
if (!opt) {
return;
}
// Do basic type checks here in case node-pty is being used within JavaScript. If the wrong
// types go through to the C++ side it can lead to hard to diagnose exceptions.
this._checkType('name', opt.name ? opt.name : undefined, 'string');
this._checkType('cols', opt.cols ? opt.cols : undefined, 'number');
this._checkType('rows', opt.rows ? opt.rows : undefined, 'number');
this._checkType('cwd', opt.cwd ? opt.cwd : undefined, 'string');
this._checkType('env', opt.env ? opt.env : undefined, 'object');
this._checkType('uid', opt.uid ? opt.uid : undefined, 'number');
this._checkType('gid', opt.gid ? opt.gid : undefined, 'number');
this._checkType('encoding', opt.encoding ? opt.encoding : undefined, 'string');
}
protected abstract _write(data: string): void;
public write(data: string): void {
if (this.handleFlowControl) {
// PAUSE/RESUME messages are not forwarded to the pty
if (data === this._flowControlPause) {
this.pause();
return;
}
if (data === this._flowControlResume) {
this.resume();
return;
}
}
// everything else goes to the real pty
this._write(data);
}
protected _forwardEvents(): void {
this.on('data', e => this._onData.fire(e));
this.on('exit', (exitCode, signal) => this._onExit.fire({ exitCode, signal }));
}
protected _checkType<T>(name: string, value: T | undefined, type: string, allowArray: boolean = false): void {
if (value === undefined) {
return;
}
if (allowArray) {
if (Array.isArray(value)) {
value.forEach((v, i) => {
if (typeof v !== type) {
throw new Error(`${name}[${i}] must be a ${type} (not a ${typeof v})`);
}
});
return;
}
}
if (typeof value !== type) {
throw new Error(`${name} must be a ${type} (not a ${typeof value})`);
}
}
/** See net.Socket.end */
public end(data: string): void {
this._socket.end(data);
}
/** See stream.Readable.pipe */
public pipe(dest: any, options: any): any {
return this._socket.pipe(dest, options);
}
/** See net.Socket.pause */
public pause(): Socket {
return this._socket.pause();
}
/** See net.Socket.resume */
public resume(): Socket {
return this._socket.resume();
}
/** See net.Socket.setEncoding */
public setEncoding(encoding: string | null): void {
if ((this._socket as any)._decoder) {
delete (this._socket as any)._decoder;
}
if (encoding) {
this._socket.setEncoding(encoding as BufferEncoding);
}
}
public addListener(eventName: string, listener: (...args: any[]) => any): void { this.on(eventName, listener); }
public on(eventName: string, listener: (...args: any[]) => any): void {
if (eventName === 'close') {
this._internalee.on('close', listener);
return;
}
this._socket.on(eventName, listener);
}
public emit(eventName: string, ...args: any[]): any {
if (eventName === 'close') {
return this._internalee.emit.apply(this._internalee, arguments as any);
}
return this._socket.emit.apply(this._socket, arguments as any);
}
public listeners(eventName: string): Function[] {
return this._socket.listeners(eventName);
}
public removeListener(eventName: string, listener: (...args: any[]) => any): void {
this._socket.removeListener(eventName, listener);
}
public removeAllListeners(eventName: string): void {
this._socket.removeAllListeners(eventName);
}
public once(eventName: string, listener: (...args: any[]) => any): void {
this._socket.once(eventName, listener);
}
public abstract resize(cols: number, rows: number): void;
public abstract clear(): void;
public abstract destroy(): void;
public abstract kill(signal?: string): void;
public abstract get process(): string;
public abstract get master(): Socket| undefined;
public abstract get slave(): Socket | undefined;
protected _close(): void {
this._socket.readable = false;
this.write = () => {};
this.end = () => {};
this._writable = false;
this._readable = false;
}
protected _parseEnv(env: IProcessEnv): string[] {
const keys = Object.keys(env || {});
const pairs = [];
for (let i = 0; i < keys.length; i++) {
if (keys[i] === undefined) {
continue;
}
pairs.push(keys[i] + '=' + env[keys[i]]);
}
return pairs;
}
}

View file

@ -0,0 +1,15 @@
/**
* Copyright (c) 2017, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
export type ArgvOrCommandLine = string[] | string;
export interface IExitEvent {
exitCode: number;
signal: number | undefined;
}
export interface IDisposable {
dispose(): void;
}

View file

@ -0,0 +1,816 @@
/**
* Copyright (c) 2012-2015, Christopher Jeffrey (MIT License)
* Copyright (c) 2017, Daniel Imms (MIT License)
*
* pty.cc:
* This file is responsible for starting processes
* with pseudo-terminal file descriptors.
*
* See:
* man pty
* man tty_ioctl
* man termios
* man forkpty
*/
/**
* Includes
*/
#define NODE_ADDON_API_DISABLE_DEPRECATED
#include <napi.h>
#include <assert.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <thread>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/ioctl.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <signal.h>
/* forkpty */
/* http://www.gnu.org/software/gnulib/manual/html_node/forkpty.html */
#if defined(__linux__)
#include <pty.h>
#elif defined(__APPLE__)
#include <util.h>
#elif defined(__FreeBSD__)
#include <libutil.h>
#include <termios.h>
#endif
/* Some platforms name VWERASE and VDISCARD differently */
#if !defined(VWERASE) && defined(VWERSE)
#define VWERASE VWERSE
#endif
#if !defined(VDISCARD) && defined(VDISCRD)
#define VDISCARD VDISCRD
#endif
/* for pty_getproc */
#if defined(__linux__)
#include <stdio.h>
#include <stdint.h>
#elif defined(__APPLE__)
#include <libproc.h>
#include <os/availability.h>
#include <paths.h>
#include <spawn.h>
#include <sys/event.h>
#include <sys/sysctl.h>
#include <termios.h>
#endif
/* NSIG - macro for highest signal + 1, should be defined */
#ifndef NSIG
#define NSIG 32
#endif
/* macOS 10.14 back does not define this constant */
#ifndef POSIX_SPAWN_SETSID
#define POSIX_SPAWN_SETSID 1024
#endif
/* environ for execvpe */
/* node/src/node_child_process.cc */
#if !defined(__APPLE__)
extern char **environ;
#endif
#if defined(__APPLE__)
extern "C" {
// Changes the current thread's directory to a path or directory file
// descriptor. libpthread only exposes a syscall wrapper starting in
// macOS 10.12, but the system call dates back to macOS 10.5. On older OSes,
// the syscall is issued directly.
int pthread_chdir_np(const char* dir) API_AVAILABLE(macosx(10.12));
int pthread_fchdir_np(int fd) API_AVAILABLE(macosx(10.12));
}
#define HANDLE_EINTR(x) ({ \
int eintr_wrapper_counter = 0; \
decltype(x) eintr_wrapper_result; \
do { \
eintr_wrapper_result = (x); \
} while (eintr_wrapper_result == -1 && errno == EINTR && \
eintr_wrapper_counter++ < 100); \
eintr_wrapper_result; \
})
#endif
struct ExitEvent {
int exit_code = 0, signal_code = 0;
};
void SetupExitCallback(Napi::Env env, Napi::Function cb, pid_t pid) {
std::thread *th = new std::thread;
// Don't use Napi::AsyncWorker which is limited by UV_THREADPOOL_SIZE.
auto tsfn = Napi::ThreadSafeFunction::New(
env,
cb, // JavaScript function called asynchronously
"SetupExitCallback_resource", // Name
0, // Unlimited queue
1, // Only one thread will use this initially
[th](Napi::Env) { // Finalizer used to clean threads up
th->join();
delete th;
});
*th = std::thread([tsfn = std::move(tsfn), pid] {
auto callback = [](Napi::Env env, Napi::Function cb, ExitEvent *exit_event) {
cb.Call({Napi::Number::New(env, exit_event->exit_code),
Napi::Number::New(env, exit_event->signal_code)});
delete exit_event;
};
int ret;
int stat_loc;
#if defined(__APPLE__)
// Based on
// https://source.chromium.org/chromium/chromium/src/+/main:base/process/kill_mac.cc;l=35-69?
int kq = HANDLE_EINTR(kqueue());
struct kevent change = {0};
EV_SET(&change, pid, EVFILT_PROC, EV_ADD, NOTE_EXIT, 0, NULL);
ret = HANDLE_EINTR(kevent(kq, &change, 1, NULL, 0, NULL));
if (ret == -1) {
if (errno == ESRCH) {
// At this point, one of the following has occurred:
// 1. The process has died but has not yet been reaped.
// 2. The process has died and has already been reaped.
// 3. The process is in the process of dying. It's no longer
// kqueueable, but it may not be waitable yet either. Mark calls
// this case the "zombie death race".
ret = HANDLE_EINTR(waitpid(pid, &stat_loc, WNOHANG));
if (ret == 0) {
ret = kill(pid, SIGKILL);
if (ret != -1) {
HANDLE_EINTR(waitpid(pid, &stat_loc, 0));
}
}
}
} else {
struct kevent event = {0};
ret = HANDLE_EINTR(kevent(kq, NULL, 0, &event, 1, NULL));
if (ret == 1) {
if ((event.fflags & NOTE_EXIT) &&
(event.ident == static_cast<uintptr_t>(pid))) {
// The process is dead or dying. This won't block for long, if at
// all.
HANDLE_EINTR(waitpid(pid, &stat_loc, 0));
}
}
}
#else
while (true) {
errno = 0;
if ((ret = waitpid(pid, &stat_loc, 0)) != pid) {
if (ret == -1 && errno == EINTR) {
continue;
}
if (ret == -1 && errno == ECHILD) {
// XXX node v0.8.x seems to have this problem.
// waitpid is already handled elsewhere.
;
} else {
assert(false);
}
}
break;
}
#endif
ExitEvent *exit_event = new ExitEvent;
if (WIFEXITED(stat_loc)) {
exit_event->exit_code = WEXITSTATUS(stat_loc); // errno?
}
if (WIFSIGNALED(stat_loc)) {
exit_event->signal_code = WTERMSIG(stat_loc);
}
auto status = tsfn.BlockingCall(exit_event, callback); // In main thread
switch (status) {
case napi_closing:
break;
case napi_queue_full:
Napi::Error::Fatal("SetupExitCallback", "Queue was full");
case napi_ok:
if (tsfn.Release() != napi_ok) {
Napi::Error::Fatal("SetupExitCallback", "ThreadSafeFunction.Release() failed");
}
break;
default:
Napi::Error::Fatal("SetupExitCallback", "ThreadSafeFunction.BlockingCall() failed");
}
});
}
/**
* Methods
*/
Napi::Value PtyFork(const Napi::CallbackInfo& info);
Napi::Value PtyOpen(const Napi::CallbackInfo& info);
Napi::Value PtyResize(const Napi::CallbackInfo& info);
Napi::Value PtyGetProc(const Napi::CallbackInfo& info);
/**
* Functions
*/
static int
pty_nonblock(int);
#if defined(__APPLE__)
static char *
pty_getproc(int);
#else
static char *
pty_getproc(int, char *);
#endif
#if defined(__APPLE__) || defined(__OpenBSD__)
static void
pty_posix_spawn(char** argv, char** env,
const struct termios *termp,
const struct winsize *winp,
int* master,
pid_t* pid,
int* err);
#endif
struct DelBuf {
int len;
DelBuf(int len) : len(len) {}
void operator()(char **p) {
if (p == nullptr)
return;
for (int i = 0; i < len; i++)
free(p[i]);
delete[] p;
}
};
Napi::Value PtyFork(const Napi::CallbackInfo& info) {
Napi::Env napiEnv(info.Env());
Napi::HandleScope scope(napiEnv);
if (info.Length() != 11 ||
!info[0].IsString() ||
!info[1].IsArray() ||
!info[2].IsArray() ||
!info[3].IsString() ||
!info[4].IsNumber() ||
!info[5].IsNumber() ||
!info[6].IsNumber() ||
!info[7].IsNumber() ||
!info[8].IsBoolean() ||
!info[9].IsString() ||
!info[10].IsFunction()) {
throw Napi::Error::New(napiEnv, "Usage: pty.fork(file, args, env, cwd, cols, rows, uid, gid, utf8, helperPath, onexit)");
}
// file
std::string file = info[0].As<Napi::String>();
// args
Napi::Array argv_ = info[1].As<Napi::Array>();
// env
Napi::Array env_ = info[2].As<Napi::Array>();
int envc = env_.Length();
std::unique_ptr<char *, DelBuf> env_unique_ptr(new char *[envc + 1], DelBuf(envc + 1));
char **env = env_unique_ptr.get();
env[envc] = NULL;
for (int i = 0; i < envc; i++) {
std::string pair = env_.Get(i).As<Napi::String>();
env[i] = strdup(pair.c_str());
}
// cwd
std::string cwd_ = info[3].As<Napi::String>();
// size
struct winsize winp;
winp.ws_col = info[4].As<Napi::Number>().Int32Value();
winp.ws_row = info[5].As<Napi::Number>().Int32Value();
winp.ws_xpixel = 0;
winp.ws_ypixel = 0;
#if !defined(__APPLE__)
// uid / gid
int uid = info[6].As<Napi::Number>().Int32Value();
int gid = info[7].As<Napi::Number>().Int32Value();
#endif
// termios
struct termios t = termios();
struct termios *term = &t;
term->c_iflag = ICRNL | IXON | IXANY | IMAXBEL | BRKINT;
if (info[8].As<Napi::Boolean>().Value()) {
#if defined(IUTF8)
term->c_iflag |= IUTF8;
#endif
}
term->c_oflag = OPOST | ONLCR;
term->c_cflag = CREAD | CS8 | HUPCL;
term->c_lflag = ICANON | ISIG | IEXTEN | ECHO | ECHOE | ECHOK | ECHOKE | ECHOCTL;
term->c_cc[VEOF] = 4;
term->c_cc[VEOL] = -1;
term->c_cc[VEOL2] = -1;
term->c_cc[VERASE] = 0x7f;
term->c_cc[VWERASE] = 23;
term->c_cc[VKILL] = 21;
term->c_cc[VREPRINT] = 18;
term->c_cc[VINTR] = 3;
term->c_cc[VQUIT] = 0x1c;
term->c_cc[VSUSP] = 26;
term->c_cc[VSTART] = 17;
term->c_cc[VSTOP] = 19;
term->c_cc[VLNEXT] = 22;
term->c_cc[VDISCARD] = 15;
term->c_cc[VMIN] = 1;
term->c_cc[VTIME] = 0;
#if (__APPLE__)
term->c_cc[VDSUSP] = 25;
term->c_cc[VSTATUS] = 20;
#endif
cfsetispeed(term, B38400);
cfsetospeed(term, B38400);
// helperPath
std::string helper_path = info[9].As<Napi::String>();
pid_t pid;
int master;
#if defined(__APPLE__)
int argc = argv_.Length();
int argl = argc + 4;
std::unique_ptr<char *, DelBuf> argv_unique_ptr(new char *[argl], DelBuf(argl));
char **argv = argv_unique_ptr.get();
argv[0] = strdup(helper_path.c_str());
argv[1] = strdup(cwd_.c_str());
argv[2] = strdup(file.c_str());
argv[argl - 1] = NULL;
for (int i = 0; i < argc; i++) {
std::string arg = argv_.Get(i).As<Napi::String>();
argv[i + 3] = strdup(arg.c_str());
}
int err = -1;
pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err);
if (err != 0) {
throw Napi::Error::New(napiEnv, "posix_spawnp failed.");
}
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
}
#else
int argc = argv_.Length();
int argl = argc + 2;
std::unique_ptr<char *, DelBuf> argv_unique_ptr(new char *[argl], DelBuf(argl));
char** argv = argv_unique_ptr.get();
argv[0] = strdup(file.c_str());
argv[argl - 1] = NULL;
for (int i = 0; i < argc; i++) {
std::string arg = argv_.Get(i).As<Napi::String>();
argv[i + 1] = strdup(arg.c_str());
}
sigset_t newmask, oldmask;
struct sigaction sig_action;
// temporarily block all signals
// this is needed due to a race condition in openpty
// and to avoid running signal handlers in the child
// before exec* happened
sigfillset(&newmask);
pthread_sigmask(SIG_SETMASK, &newmask, &oldmask);
pid = forkpty(&master, nullptr, static_cast<termios*>(term), static_cast<winsize*>(&winp));
if (!pid) {
// remove all signal handler from child
sig_action.sa_handler = SIG_DFL;
sig_action.sa_flags = 0;
sigemptyset(&sig_action.sa_mask);
for (int i = 0 ; i < NSIG ; i++) { // NSIG is a macro for all signals + 1
sigaction(i, &sig_action, NULL);
}
}
// reenable signals
pthread_sigmask(SIG_SETMASK, &oldmask, NULL);
switch (pid) {
case -1:
throw Napi::Error::New(napiEnv, "forkpty(3) failed.");
case 0:
if (strlen(cwd_.c_str())) {
if (chdir(cwd_.c_str()) == -1) {
perror("chdir(2) failed.");
_exit(1);
}
}
if (uid != -1 && gid != -1) {
if (setgid(gid) == -1) {
perror("setgid(2) failed.");
_exit(1);
}
if (setuid(uid) == -1) {
perror("setuid(2) failed.");
_exit(1);
}
}
{
char **old = environ;
environ = env;
execvp(argv[0], argv);
environ = old;
perror("execvp(3) failed.");
_exit(1);
}
default:
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
}
}
#endif
Napi::Object obj = Napi::Object::New(napiEnv);
obj.Set("fd", Napi::Number::New(napiEnv, master));
obj.Set("pid", Napi::Number::New(napiEnv, pid));
#if defined(__APPLE__)
// Use TIOCPTYGNAME instead of ptsname() to avoid threading problems.
char slave_pty_name[128];
if (ioctl(master, TIOCPTYGNAME, slave_pty_name) != -1) {
obj.Set("pty", Napi::String::New(napiEnv, slave_pty_name));
} else {
obj.Set("pty", Napi::String::New(napiEnv, ""));
}
#else
obj.Set("pty", Napi::String::New(napiEnv, ptsname(master)));
#endif
// Set up process exit callback.
Napi::Function cb = info[10].As<Napi::Function>();
SetupExitCallback(napiEnv, cb, pid);
return obj;
}
Napi::Value PtyOpen(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
if (info.Length() != 2 ||
!info[0].IsNumber() ||
!info[1].IsNumber()) {
throw Napi::Error::New(env, "Usage: pty.open(cols, rows)");
}
// size
struct winsize winp;
winp.ws_col = info[0].As<Napi::Number>().Int32Value();
winp.ws_row = info[1].As<Napi::Number>().Int32Value();
winp.ws_xpixel = 0;
winp.ws_ypixel = 0;
// pty
int master, slave;
int ret = openpty(&master, &slave, nullptr, NULL, static_cast<winsize*>(&winp));
if (ret == -1) {
throw Napi::Error::New(env, "openpty(3) failed.");
}
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(env, "Could not set master fd to nonblocking.");
}
if (pty_nonblock(slave) == -1) {
throw Napi::Error::New(env, "Could not set slave fd to nonblocking.");
}
Napi::Object obj = Napi::Object::New(env);
obj.Set("master", Napi::Number::New(env, master));
obj.Set("slave", Napi::Number::New(env, slave));
#if defined(__APPLE__)
// Use TIOCPTYGNAME instead of ptsname() to avoid threading problems.
char slave_pty_name[128];
if (ioctl(master, TIOCPTYGNAME, slave_pty_name) != -1) {
obj.Set("pty", Napi::String::New(env, slave_pty_name));
} else {
obj.Set("pty", Napi::String::New(env, ""));
}
#else
obj.Set("pty", Napi::String::New(env, ptsname(master)));
#endif
return obj;
}
Napi::Value PtyResize(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
if (info.Length() != 3 ||
!info[0].IsNumber() ||
!info[1].IsNumber() ||
!info[2].IsNumber()) {
throw Napi::Error::New(env, "Usage: pty.resize(fd, cols, rows)");
}
int fd = info[0].As<Napi::Number>().Int32Value();
struct winsize winp;
winp.ws_col = info[1].As<Napi::Number>().Int32Value();
winp.ws_row = info[2].As<Napi::Number>().Int32Value();
winp.ws_xpixel = 0;
winp.ws_ypixel = 0;
if (ioctl(fd, TIOCSWINSZ, &winp) == -1) {
switch (errno) {
case EBADF:
throw Napi::Error::New(env, "ioctl(2) failed, EBADF");
case EFAULT:
throw Napi::Error::New(env, "ioctl(2) failed, EFAULT");
case EINVAL:
throw Napi::Error::New(env, "ioctl(2) failed, EINVAL");
case ENOTTY:
throw Napi::Error::New(env, "ioctl(2) failed, ENOTTY");
}
throw Napi::Error::New(env, "ioctl(2) failed");
}
return env.Undefined();
}
/**
* Foreground Process Name
*/
Napi::Value PtyGetProc(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
#if defined(__APPLE__)
if (info.Length() != 1 ||
!info[0].IsNumber()) {
throw Napi::Error::New(env, "Usage: pty.process(pid)");
}
int fd = info[0].As<Napi::Number>().Int32Value();
char *name = pty_getproc(fd);
#else
if (info.Length() != 2 ||
!info[0].IsNumber() ||
!info[1].IsString()) {
throw Napi::Error::New(env, "Usage: pty.process(fd, tty)");
}
int fd = info[0].As<Napi::Number>().Int32Value();
std::string tty_ = info[1].As<Napi::String>();
char *tty = strdup(tty_.c_str());
char *name = pty_getproc(fd, tty);
free(tty);
#endif
if (name == NULL) {
return env.Undefined();
}
Napi::String name_ = Napi::String::New(env, name);
free(name);
return name_;
}
/**
* Nonblocking FD
*/
static int
pty_nonblock(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) return -1;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
/**
* pty_getproc
* Taken from tmux.
*/
// Taken from: tmux (http://tmux.sourceforge.net/)
// Copyright (c) 2009 Nicholas Marriott <nicm@users.sourceforge.net>
// Copyright (c) 2009 Joshua Elsasser <josh@elsasser.org>
// Copyright (c) 2009 Todd Carson <toc@daybefore.net>
//
// Permission to use, copy, modify, and distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF MIND, USE, DATA OR PROFITS, WHETHER
// IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING
// OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
#if defined(__linux__)
static char *
pty_getproc(int fd, char *tty) {
FILE *f;
char *path, *buf;
size_t len;
int ch;
pid_t pgrp;
int r;
if ((pgrp = tcgetpgrp(fd)) == -1) {
return NULL;
}
r = asprintf(&path, "/proc/%lld/cmdline", (long long)pgrp);
if (r == -1 || path == NULL) return NULL;
if ((f = fopen(path, "r")) == NULL) {
free(path);
return NULL;
}
free(path);
len = 0;
buf = NULL;
while ((ch = fgetc(f)) != EOF) {
if (ch == '\0') break;
buf = (char *)realloc(buf, len + 2);
if (buf == NULL) return NULL;
buf[len++] = ch;
}
if (buf != NULL) {
buf[len] = '\0';
}
fclose(f);
return buf;
}
#elif defined(__APPLE__)
static char *
pty_getproc(int fd) {
int mib[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, 0 };
size_t size;
struct kinfo_proc kp;
if ((mib[3] = tcgetpgrp(fd)) == -1) {
return NULL;
}
size = sizeof kp;
if (sysctl(mib, 4, &kp, &size, NULL, 0) == -1) {
return NULL;
}
if (size != (sizeof kp) || *kp.kp_proc.p_comm == '\0') {
return NULL;
}
return strdup(kp.kp_proc.p_comm);
}
#else
static char *
pty_getproc(int fd, char *tty) {
return NULL;
}
#endif
#if defined(__APPLE__)
static void
pty_posix_spawn(char** argv, char** env,
const struct termios *termp,
const struct winsize *winp,
int* master,
pid_t* pid,
int* err) {
int low_fds[3];
size_t count = 0;
for (; count < 3; count++) {
low_fds[count] = posix_openpt(O_RDWR);
if (low_fds[count] >= STDERR_FILENO)
break;
}
int flags = POSIX_SPAWN_CLOEXEC_DEFAULT |
POSIX_SPAWN_SETSIGDEF |
POSIX_SPAWN_SETSIGMASK |
POSIX_SPAWN_SETSID;
*master = posix_openpt(O_RDWR);
if (*master == -1) {
return;
}
int res = grantpt(*master) || unlockpt(*master);
if (res == -1) {
return;
}
// Use TIOCPTYGNAME instead of ptsname() to avoid threading problems.
int slave;
char slave_pty_name[128];
res = ioctl(*master, TIOCPTYGNAME, slave_pty_name);
if (res == -1) {
return;
}
slave = open(slave_pty_name, O_RDWR | O_NOCTTY);
if (slave == -1) {
return;
}
if (termp) {
res = tcsetattr(slave, TCSANOW, termp);
if (res == -1) {
return;
};
}
if (winp) {
res = ioctl(slave, TIOCSWINSZ, winp);
if (res == -1) {
return;
}
}
posix_spawn_file_actions_t acts;
posix_spawn_file_actions_init(&acts);
posix_spawn_file_actions_adddup2(&acts, slave, STDIN_FILENO);
posix_spawn_file_actions_adddup2(&acts, slave, STDOUT_FILENO);
posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO);
posix_spawn_file_actions_addclose(&acts, slave);
posix_spawn_file_actions_addclose(&acts, *master);
posix_spawnattr_t attrs;
posix_spawnattr_init(&attrs);
*err = posix_spawnattr_setflags(&attrs, flags);
if (*err != 0) {
goto done;
}
sigset_t signal_set;
/* Reset all signal the child to their default behavior */
sigfillset(&signal_set);
*err = posix_spawnattr_setsigdefault(&attrs, &signal_set);
if (*err != 0) {
goto done;
}
/* Reset the signal mask for all signals */
sigemptyset(&signal_set);
*err = posix_spawnattr_setsigmask(&attrs, &signal_set);
if (*err != 0) {
goto done;
}
do
*err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env);
while (*err == EINTR);
done:
posix_spawn_file_actions_destroy(&acts);
posix_spawnattr_destroy(&attrs);
for (; count > 0; count--) {
close(low_fds[count]);
}
}
#endif
/**
* Init
*/
Napi::Object init(Napi::Env env, Napi::Object exports) {
exports.Set("fork", Napi::Function::New(env, PtyFork));
exports.Set("open", Napi::Function::New(env, PtyOpen));
exports.Set("resize", Napi::Function::New(env, PtyResize));
exports.Set("process", Napi::Function::New(env, PtyGetProc));
return exports;
}
NODE_API_MODULE(NODE_GYP_MODULE_NAME, init)

View file

@ -0,0 +1,332 @@
/**
* Copyright (c) 2012-2015, Christopher Jeffrey (MIT License)
* Copyright (c) 2016, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
import * as net from 'net';
import * as path from 'path';
import * as tty from 'tty';
import { Terminal, DEFAULT_COLS, DEFAULT_ROWS } from './terminal';
import { IProcessEnv, IPtyForkOptions, IPtyOpenOptions } from './interfaces';
import { ArgvOrCommandLine } from './types';
import { assign } from './utils';
let pty: IUnixNative;
let helperPath: string;
// Check if running in SEA (Single Executable Application) context
if (process.env.VIBETUNNEL_SEA) {
// In SEA mode, load native module using process.dlopen
const fs = require('fs');
const execDir = path.dirname(process.execPath);
const ptyPath = path.join(execDir, 'pty.node');
if (fs.existsSync(ptyPath)) {
const module = { exports: {} };
process.dlopen(module, ptyPath);
pty = module.exports as IUnixNative;
} else {
throw new Error(`Could not find pty.node next to executable at: ${ptyPath}`);
}
// Set spawn-helper path for macOS only (Linux doesn't use it)
if (process.platform === 'darwin') {
helperPath = path.join(execDir, 'spawn-helper');
if (!fs.existsSync(helperPath)) {
console.warn(`spawn-helper not found at ${helperPath}, PTY operations may fail`);
}
}
} else {
// Standard Node.js loading
try {
pty = require('../build/Release/pty.node');
helperPath = '../build/Release/spawn-helper';
} catch (outerError) {
try {
pty = require('../build/Debug/pty.node');
helperPath = '../build/Debug/spawn-helper';
} catch (innerError) {
console.error('innerError', innerError);
throw outerError;
}
}
helperPath = path.resolve(__dirname, helperPath);
helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
}
const DEFAULT_FILE = 'sh';
const DEFAULT_NAME = 'xterm';
const DESTROY_SOCKET_TIMEOUT_MS = 200;
export class UnixTerminal extends Terminal {
protected _fd: number;
protected _pty: string;
protected _file: string;
protected _name: string;
protected _readable: boolean;
protected _writable: boolean;
private _boundClose: boolean = false;
private _emittedClose: boolean = false;
private _master: net.Socket | undefined;
private _slave: net.Socket | undefined;
public get master(): net.Socket | undefined { return this._master; }
public get slave(): net.Socket | undefined { return this._slave; }
constructor(file?: string, args?: ArgvOrCommandLine, opt?: IPtyForkOptions) {
super(opt);
if (typeof args === 'string') {
throw new Error('args as a string is not supported on unix.');
}
// Initialize arguments
args = args || [];
file = file || DEFAULT_FILE;
opt = opt || {};
opt.env = opt.env || process.env;
this._cols = opt.cols || DEFAULT_COLS;
this._rows = opt.rows || DEFAULT_ROWS;
const uid = opt.uid ?? -1;
const gid = opt.gid ?? -1;
const env: IProcessEnv = assign({}, opt.env);
if (opt.env === process.env) {
this._sanitizeEnv(env);
}
const cwd = opt.cwd || process.cwd();
env.PWD = cwd;
const name = opt.name || env.TERM || DEFAULT_NAME;
env.TERM = name;
const parsedEnv = this._parseEnv(env);
const encoding = (opt.encoding === undefined ? 'utf8' : opt.encoding);
const onexit = (code: number, signal: number): void => {
// XXX Sometimes a data event is emitted after exit. Wait til socket is
// destroyed.
if (!this._emittedClose) {
if (this._boundClose) {
return;
}
this._boundClose = true;
// From macOS High Sierra 10.13.2 sometimes the socket never gets
// closed. A timeout is applied here to avoid the terminal never being
// destroyed when this occurs.
let timeout: NodeJS.Timeout | null = setTimeout(() => {
timeout = null;
// Destroying the socket now will cause the close event to fire
this._socket.destroy();
}, DESTROY_SOCKET_TIMEOUT_MS);
this.once('close', () => {
if (timeout !== null) {
clearTimeout(timeout);
}
this.emit('exit', code, signal);
});
return;
}
this.emit('exit', code, signal);
};
// fork
const term = pty.fork(file, args, parsedEnv, cwd, this._cols, this._rows, uid, gid, (encoding === 'utf8'), helperPath, onexit);
this._socket = new tty.ReadStream(term.fd);
if (encoding !== null) {
this._socket.setEncoding(encoding as BufferEncoding);
}
// setup
this._socket.on('error', (err: any) => {
// NOTE: fs.ReadStream gets EAGAIN twice at first:
if (err.code) {
if (~err.code.indexOf('EAGAIN')) {
return;
}
}
// close
this._close();
// EIO on exit from fs.ReadStream:
if (!this._emittedClose) {
this._emittedClose = true;
this.emit('close');
}
// EIO, happens when someone closes our child process: the only process in
// the terminal.
// node < 0.6.14: errno 5
// node >= 0.6.14: read EIO
if (err.code) {
if (~err.code.indexOf('errno 5') || ~err.code.indexOf('EIO')) {
return;
}
}
// throw anything else
if (this.listeners('error').length < 2) {
throw err;
}
});
this._pid = term.pid;
this._fd = term.fd;
this._pty = term.pty;
this._file = file;
this._name = name;
this._readable = true;
this._writable = true;
this._socket.on('close', () => {
if (this._emittedClose) {
return;
}
this._emittedClose = true;
this._close();
this.emit('close');
});
this._forwardEvents();
}
protected _write(data: string): void {
this._socket.write(data);
}
/* Accessors */
get fd(): number { return this._fd; }
get ptsName(): string { return this._pty; }
/**
* openpty
*/
public static open(opt: IPtyOpenOptions): UnixTerminal {
const self: UnixTerminal = Object.create(UnixTerminal.prototype);
opt = opt || {};
if (arguments.length > 1) {
opt = {
cols: arguments[1],
rows: arguments[2]
};
}
const cols = opt.cols || DEFAULT_COLS;
const rows = opt.rows || DEFAULT_ROWS;
const encoding = (opt.encoding === undefined ? 'utf8' : opt.encoding);
// open
const term: IUnixOpenProcess = pty.open(cols, rows);
self._master = new tty.ReadStream(term.master);
if (encoding !== null) {
self._master.setEncoding(encoding as BufferEncoding);
}
self._master.resume();
self._slave = new tty.ReadStream(term.slave);
if (encoding !== null) {
self._slave.setEncoding(encoding as BufferEncoding);
}
self._slave.resume();
self._socket = self._master;
self._pid = -1;
self._fd = term.master;
self._pty = term.pty;
self._file = process.argv[0] || 'node';
self._name = process.env.TERM || '';
self._readable = true;
self._writable = true;
self._socket.on('error', err => {
self._close();
if (self.listeners('error').length < 2) {
throw err;
}
});
self._socket.on('close', () => {
self._close();
});
return self;
}
public destroy(): void {
this._close();
// Need to close the read stream so node stops reading a dead file
// descriptor. Then we can safely SIGHUP the shell.
this._socket.once('close', () => {
this.kill('SIGHUP');
});
this._socket.destroy();
}
public kill(signal?: string): void {
try {
process.kill(this.pid, signal || 'SIGHUP');
} catch (e) { /* swallow */ }
}
/**
* Gets the name of the process.
*/
public get process(): string {
if (process.platform === 'darwin') {
const title = pty.process(this._fd);
return (title !== 'kernel_task' ) ? title : this._file;
}
return pty.process(this._fd, this._pty) || this._file;
}
/**
* TTY
*/
public resize(cols: number, rows: number): void {
if (cols <= 0 || rows <= 0 || isNaN(cols) || isNaN(rows) || cols === Infinity || rows === Infinity) {
throw new Error('resizing must be done using positive cols and rows');
}
pty.resize(this._fd, cols, rows);
this._cols = cols;
this._rows = rows;
}
public clear(): void {
}
private _sanitizeEnv(env: IProcessEnv): void {
// Make sure we didn't start our server from inside tmux.
delete env['TMUX'];
delete env['TMUX_PANE'];
// Make sure we didn't start our server from inside screen.
// http://web.mit.edu/gnu/doc/html/screen_20.html
delete env['STY'];
delete env['WINDOW'];
// Delete some variables that might confuse our terminal.
delete env['WINDOWID'];
delete env['TERMCAP'];
delete env['COLUMNS'];
delete env['LINES'];
}
}

View file

@ -0,0 +1,9 @@
/**
* Copyright (c) 2017, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
export function assign(target: any, ...sources: any[]): any {
sources.forEach(source => Object.keys(source).forEach(key => target[key] = source[key]));
return target;
}

View file

@ -0,0 +1,583 @@
/**
* Copyright (c) 2013-2015, Christopher Jeffrey, Peter Sunde (MIT License)
* Copyright (c) 2016, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*
* pty.cc:
* This file is responsible for starting processes
* with pseudo-terminal file descriptors.
*/
#define _WIN32_WINNT 0x600
#define NODE_ADDON_API_DISABLE_DEPRECATED
#include <node_api.h>
#include <assert.h>
#include <Shlwapi.h> // PathCombine, PathIsRelative
#include <sstream>
#include <iostream>
#include <string>
#include <thread>
#include <vector>
#include <Windows.h>
#include <strsafe.h>
#include "path_util.h"
#include "conpty.h"
// Taken from the RS5 Windows SDK, but redefined here in case we're targeting <= 17134
#ifndef PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE
#define PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE \
ProcThreadAttributeValue(22, FALSE, TRUE, FALSE)
typedef VOID* HPCON;
typedef HRESULT (__stdcall *PFNCREATEPSEUDOCONSOLE)(COORD c, HANDLE hIn, HANDLE hOut, DWORD dwFlags, HPCON* phpcon);
typedef HRESULT (__stdcall *PFNRESIZEPSEUDOCONSOLE)(HPCON hpc, COORD newSize);
typedef HRESULT (__stdcall *PFNCLEARPSEUDOCONSOLE)(HPCON hpc);
typedef void (__stdcall *PFNCLOSEPSEUDOCONSOLE)(HPCON hpc);
typedef void (__stdcall *PFNRELEASEPSEUDOCONSOLE)(HPCON hpc);
#endif
struct pty_baton {
int id;
HANDLE hIn;
HANDLE hOut;
HPCON hpc;
HANDLE hShell;
pty_baton(int _id, HANDLE _hIn, HANDLE _hOut, HPCON _hpc) : id(_id), hIn(_hIn), hOut(_hOut), hpc(_hpc) {};
};
static std::vector<std::unique_ptr<pty_baton>> ptyHandles;
static volatile LONG ptyCounter;
static pty_baton* get_pty_baton(int id) {
auto it = std::find_if(ptyHandles.begin(), ptyHandles.end(), [id](const auto& ptyHandle) {
return ptyHandle->id == id;
});
if (it != ptyHandles.end()) {
return it->get();
}
return nullptr;
}
static bool remove_pty_baton(int id) {
auto it = std::remove_if(ptyHandles.begin(), ptyHandles.end(), [id](const auto& ptyHandle) {
return ptyHandle->id == id;
});
if (it != ptyHandles.end()) {
ptyHandles.erase(it);
return true;
}
return false;
}
struct ExitEvent {
int exit_code = 0;
};
void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) {
std::thread *th = new std::thread;
// Don't use Napi::AsyncWorker which is limited by UV_THREADPOOL_SIZE.
auto tsfn = Napi::ThreadSafeFunction::New(
env,
cb, // JavaScript function called asynchronously
"SetupExitCallback_resource", // Name
0, // Unlimited queue
1, // Only one thread will use this initially
[th](Napi::Env) { // Finalizer used to clean threads up
th->join();
delete th;
});
*th = std::thread([tsfn = std::move(tsfn), baton] {
auto callback = [](Napi::Env env, Napi::Function cb, ExitEvent *exit_event) {
cb.Call({Napi::Number::New(env, exit_event->exit_code)});
delete exit_event;
};
ExitEvent *exit_event = new ExitEvent;
// Wait for process to complete.
WaitForSingleObject(baton->hShell, INFINITE);
// Get process exit code.
GetExitCodeProcess(baton->hShell, (LPDWORD)(&exit_event->exit_code));
// Clean up handles
CloseHandle(baton->hShell);
assert(remove_pty_baton(baton->id));
auto status = tsfn.BlockingCall(exit_event, callback); // In main thread
switch (status) {
case napi_closing:
break;
case napi_queue_full:
Napi::Error::Fatal("SetupExitCallback", "Queue was full");
case napi_ok:
if (tsfn.Release() != napi_ok) {
Napi::Error::Fatal("SetupExitCallback", "ThreadSafeFunction.Release() failed");
}
break;
default:
Napi::Error::Fatal("SetupExitCallback", "ThreadSafeFunction.BlockingCall() failed");
}
});
}
Napi::Error errorWithCode(const Napi::CallbackInfo& info, const char* text) {
std::stringstream errorText;
errorText << text;
errorText << ", error code: " << GetLastError();
return Napi::Error::New(info.Env(), errorText.str());
}
// Returns a new server named pipe. It has not yet been connected.
bool createDataServerPipe(bool write,
std::wstring kind,
HANDLE* hServer,
std::wstring &name,
const std::wstring &pipeName)
{
*hServer = INVALID_HANDLE_VALUE;
name = L"\\\\.\\pipe\\" + pipeName + L"-" + kind;
const DWORD winOpenMode = PIPE_ACCESS_INBOUND | PIPE_ACCESS_OUTBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE/* | FILE_FLAG_OVERLAPPED */;
SECURITY_ATTRIBUTES sa = {};
sa.nLength = sizeof(sa);
*hServer = CreateNamedPipeW(
name.c_str(),
/*dwOpenMode=*/winOpenMode,
/*dwPipeMode=*/PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
/*nMaxInstances=*/1,
/*nOutBufferSize=*/128 * 1024,
/*nInBufferSize=*/128 * 1024,
/*nDefaultTimeOut=*/30000,
&sa);
return *hServer != INVALID_HANDLE_VALUE;
}
HANDLE LoadConptyDll(const Napi::CallbackInfo& info,
const bool useConptyDll)
{
if (!useConptyDll) {
return LoadLibraryExW(L"kernel32.dll", 0, 0);
}
wchar_t currentDir[MAX_PATH];
HMODULE hModule = GetModuleHandleA("conpty.node");
if (hModule == NULL) {
throw errorWithCode(info, "Failed to get conpty.node module handle");
}
DWORD result = GetModuleFileNameW(hModule, currentDir, MAX_PATH);
if (result == 0) {
throw errorWithCode(info, "Failed to get conpty.node module file name");
}
PathRemoveFileSpecW(currentDir);
wchar_t conptyDllPath[MAX_PATH];
PathCombineW(conptyDllPath, currentDir, L"conpty\\conpty.dll");
if (!path_util::file_exists(conptyDllPath)) {
std::wstring errorMessage = L"Cannot find conpty.dll at " + std::wstring(conptyDllPath);
std::string errorMessageStr = path_util::wstring_to_string(errorMessage);
throw errorWithCode(info, errorMessageStr.c_str());
}
return LoadLibraryW(conptyDllPath);
}
HRESULT CreateNamedPipesAndPseudoConsole(const Napi::CallbackInfo& info,
COORD size,
DWORD dwFlags,
HANDLE *phInput,
HANDLE *phOutput,
HPCON* phPC,
std::wstring& inName,
std::wstring& outName,
const std::wstring& pipeName,
const bool useConptyDll)
{
HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
DWORD error = GetLastError();
bool fLoadedDll = hLibrary != nullptr;
if (fLoadedDll)
{
PFNCREATEPSEUDOCONSOLE const pfnCreate = (PFNCREATEPSEUDOCONSOLE)GetProcAddress(
(HMODULE)hLibrary,
useConptyDll ? "ConptyCreatePseudoConsole" : "CreatePseudoConsole");
if (pfnCreate)
{
if (phPC == NULL || phInput == NULL || phOutput == NULL)
{
return E_INVALIDARG;
}
bool success = createDataServerPipe(true, L"in", phInput, inName, pipeName);
if (!success)
{
return HRESULT_FROM_WIN32(GetLastError());
}
success = createDataServerPipe(false, L"out", phOutput, outName, pipeName);
if (!success)
{
return HRESULT_FROM_WIN32(GetLastError());
}
return pfnCreate(size, *phInput, *phOutput, dwFlags, phPC);
}
else
{
// Failed to find CreatePseudoConsole in kernel32. This is likely because
// the user is not running a build of Windows that supports that API.
// We should fall back to winpty in this case.
return HRESULT_FROM_WIN32(GetLastError());
}
} else {
throw errorWithCode(info, "Failed to load conpty.dll");
}
// Failed to find kernel32. This is realy unlikely - honestly no idea how
// this is even possible to hit. But if it does happen, fall back to winpty.
return HRESULT_FROM_WIN32(GetLastError());
}
static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
Napi::Object marshal;
std::wstring inName, outName;
BOOL fSuccess = FALSE;
std::unique_ptr<wchar_t[]> mutableCommandline;
PROCESS_INFORMATION _piClient{};
if (info.Length() != 7 ||
!info[0].IsString() ||
!info[1].IsNumber() ||
!info[2].IsNumber() ||
!info[3].IsBoolean() ||
!info[4].IsString() ||
!info[5].IsBoolean() ||
!info[6].IsBoolean()) {
throw Napi::Error::New(env, "Usage: pty.startProcess(file, cols, rows, debug, pipeName, inheritCursor, useConptyDll)");
}
const std::wstring filename(path_util::to_wstring(info[0].As<Napi::String>()));
const SHORT cols = static_cast<SHORT>(info[1].As<Napi::Number>().Uint32Value());
const SHORT rows = static_cast<SHORT>(info[2].As<Napi::Number>().Uint32Value());
const bool debug = info[3].As<Napi::Boolean>().Value();
const std::wstring pipeName(path_util::to_wstring(info[4].As<Napi::String>()));
const bool inheritCursor = info[5].As<Napi::Boolean>().Value();
const bool useConptyDll = info[6].As<Napi::Boolean>().Value();
// use environment 'Path' variable to determine location of
// the relative path that we have recieved (e.g cmd.exe)
std::wstring shellpath;
if (::PathIsRelativeW(filename.c_str())) {
shellpath = path_util::get_shell_path(filename.c_str());
} else {
shellpath = filename;
}
if (shellpath.empty() || !path_util::file_exists(shellpath)) {
std::string why;
why += "File not found: ";
why += path_util::wstring_to_string(shellpath);
throw Napi::Error::New(env, why);
}
HANDLE hIn, hOut;
HPCON hpc;
HRESULT hr = CreateNamedPipesAndPseudoConsole(info, {cols, rows}, inheritCursor ? 1/*PSEUDOCONSOLE_INHERIT_CURSOR*/ : 0, &hIn, &hOut, &hpc, inName, outName, pipeName, useConptyDll);
// Restore default handling of ctrl+c
SetConsoleCtrlHandler(NULL, FALSE);
// Set return values
marshal = Napi::Object::New(env);
if (SUCCEEDED(hr)) {
// We were able to instantiate a conpty
const int ptyId = InterlockedIncrement(&ptyCounter);
marshal.Set("pty", Napi::Number::New(env, ptyId));
ptyHandles.emplace_back(
std::make_unique<pty_baton>(ptyId, hIn, hOut, hpc));
} else {
throw Napi::Error::New(env, "Cannot launch conpty");
}
std::string inNameStr = path_util::wstring_to_string(inName);
if (inNameStr.empty()) {
throw Napi::Error::New(env, "Failed to initialize conpty conin");
}
std::string outNameStr = path_util::wstring_to_string(outName);
if (outNameStr.empty()) {
throw Napi::Error::New(env, "Failed to initialize conpty conout");
}
marshal.Set("fd", Napi::Number::New(env, -1));
marshal.Set("conin", Napi::String::New(env, inNameStr));
marshal.Set("conout", Napi::String::New(env, outNameStr));
return marshal;
}
static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
// If we're working with conpty's we need to call ConnectNamedPipe here AFTER
// the Socket has attempted to connect to the other end, then actually
// spawn the process here.
std::stringstream errorText;
BOOL fSuccess = FALSE;
if (info.Length() != 6 ||
!info[0].IsNumber() ||
!info[1].IsString() ||
!info[2].IsString() ||
!info[3].IsArray() ||
!info[4].IsBoolean() ||
!info[5].IsFunction()) {
throw Napi::Error::New(env, "Usage: pty.connect(id, cmdline, cwd, env, useConptyDll, exitCallback)");
}
const int id = info[0].As<Napi::Number>().Int32Value();
const std::wstring cmdline(path_util::to_wstring(info[1].As<Napi::String>()));
const std::wstring cwd(path_util::to_wstring(info[2].As<Napi::String>()));
const Napi::Array envValues = info[3].As<Napi::Array>();
const bool useConptyDll = info[4].As<Napi::Boolean>().Value();
Napi::Function exitCallback = info[5].As<Napi::Function>();
// Fetch pty handle from ID and start process
pty_baton* handle = get_pty_baton(id);
if (!handle) {
throw Napi::Error::New(env, "Invalid pty handle");
}
// Prepare command line
std::unique_ptr<wchar_t[]> mutableCommandline = std::make_unique<wchar_t[]>(cmdline.length() + 1);
HRESULT hr = StringCchCopyW(mutableCommandline.get(), cmdline.length() + 1, cmdline.c_str());
// Prepare cwd
std::unique_ptr<wchar_t[]> mutableCwd = std::make_unique<wchar_t[]>(cwd.length() + 1);
hr = StringCchCopyW(mutableCwd.get(), cwd.length() + 1, cwd.c_str());
// Prepare environment
std::wstring envStr;
if (!envValues.IsEmpty()) {
std::wstring envBlock;
for(uint32_t i = 0; i < envValues.Length(); i++) {
envBlock += path_util::to_wstring(envValues.Get(i).As<Napi::String>());
envBlock += L'\0';
}
envBlock += L'\0';
envStr = std::move(envBlock);
}
std::vector<wchar_t> envV(envStr.cbegin(), envStr.cend());
LPWSTR envArg = envV.empty() ? nullptr : envV.data();
ConnectNamedPipe(handle->hIn, nullptr);
ConnectNamedPipe(handle->hOut, nullptr);
// Attach the pseudoconsole to the client application we're creating
STARTUPINFOEXW siEx{0};
siEx.StartupInfo.cb = sizeof(STARTUPINFOEXW);
siEx.StartupInfo.dwFlags |= STARTF_USESTDHANDLES;
siEx.StartupInfo.hStdError = nullptr;
siEx.StartupInfo.hStdInput = nullptr;
siEx.StartupInfo.hStdOutput = nullptr;
SIZE_T size = 0;
InitializeProcThreadAttributeList(NULL, 1, 0, &size);
BYTE *attrList = new BYTE[size];
siEx.lpAttributeList = reinterpret_cast<PPROC_THREAD_ATTRIBUTE_LIST>(attrList);
fSuccess = InitializeProcThreadAttributeList(siEx.lpAttributeList, 1, 0, &size);
if (!fSuccess) {
throw errorWithCode(info, "InitializeProcThreadAttributeList failed");
}
fSuccess = UpdateProcThreadAttribute(siEx.lpAttributeList,
0,
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
handle->hpc,
sizeof(HPCON),
NULL,
NULL);
if (!fSuccess) {
throw errorWithCode(info, "UpdateProcThreadAttribute failed");
}
PROCESS_INFORMATION piClient{};
fSuccess = !!CreateProcessW(
nullptr,
mutableCommandline.get(),
nullptr, // lpProcessAttributes
nullptr, // lpThreadAttributes
false, // bInheritHandles VERY IMPORTANT that this is false
EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, // dwCreationFlags
envArg, // lpEnvironment
mutableCwd.get(), // lpCurrentDirectory
&siEx.StartupInfo, // lpStartupInfo
&piClient // lpProcessInformation
);
if (!fSuccess) {
throw errorWithCode(info, "Cannot create process");
}
HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
bool fLoadedDll = hLibrary != nullptr;
if (useConptyDll && fLoadedDll)
{
PFNRELEASEPSEUDOCONSOLE const pfnReleasePseudoConsole = (PFNRELEASEPSEUDOCONSOLE)GetProcAddress(
(HMODULE)hLibrary, "ConptyReleasePseudoConsole");
if (pfnReleasePseudoConsole)
{
pfnReleasePseudoConsole(handle->hpc);
}
}
// Update handle
handle->hShell = piClient.hProcess;
// Close the thread handle to avoid resource leak
CloseHandle(piClient.hThread);
// Close the input read and output write handle of the pseudoconsole
CloseHandle(handle->hIn);
CloseHandle(handle->hOut);
SetupExitCallback(env, exitCallback, handle);
// Return
auto marshal = Napi::Object::New(env);
marshal.Set("pid", Napi::Number::New(env, piClient.dwProcessId));
return marshal;
}
static Napi::Value PtyResize(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
if (info.Length() != 4 ||
!info[0].IsNumber() ||
!info[1].IsNumber() ||
!info[2].IsNumber() ||
!info[3].IsBoolean()) {
throw Napi::Error::New(env, "Usage: pty.resize(id, cols, rows, useConptyDll)");
}
int id = info[0].As<Napi::Number>().Int32Value();
SHORT cols = static_cast<SHORT>(info[1].As<Napi::Number>().Uint32Value());
SHORT rows = static_cast<SHORT>(info[2].As<Napi::Number>().Uint32Value());
const bool useConptyDll = info[3].As<Napi::Boolean>().Value();
const pty_baton* handle = get_pty_baton(id);
if (handle != nullptr) {
HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
bool fLoadedDll = hLibrary != nullptr;
if (fLoadedDll)
{
PFNRESIZEPSEUDOCONSOLE const pfnResizePseudoConsole = (PFNRESIZEPSEUDOCONSOLE)GetProcAddress(
(HMODULE)hLibrary,
useConptyDll ? "ConptyResizePseudoConsole" : "ResizePseudoConsole");
if (pfnResizePseudoConsole)
{
COORD size = {cols, rows};
pfnResizePseudoConsole(handle->hpc, size);
}
}
}
return env.Undefined();
}
static Napi::Value PtyClear(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
if (info.Length() != 2 ||
!info[0].IsNumber() ||
!info[1].IsBoolean()) {
throw Napi::Error::New(env, "Usage: pty.clear(id, useConptyDll)");
}
int id = info[0].As<Napi::Number>().Int32Value();
const bool useConptyDll = info[1].As<Napi::Boolean>().Value();
// This API is only supported for conpty.dll as it was introduced in a later version of Windows.
// We could hook it up to point at >= a version of Windows only, but the future is conpty.dll
// anyway.
if (!useConptyDll) {
return env.Undefined();
}
const pty_baton* handle = get_pty_baton(id);
if (handle != nullptr) {
HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
bool fLoadedDll = hLibrary != nullptr;
if (fLoadedDll)
{
PFNCLEARPSEUDOCONSOLE const pfnClearPseudoConsole = (PFNCLEARPSEUDOCONSOLE)GetProcAddress((HMODULE)hLibrary, "ConptyClearPseudoConsole");
if (pfnClearPseudoConsole)
{
pfnClearPseudoConsole(handle->hpc);
}
}
}
return env.Undefined();
}
static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
if (info.Length() != 2 ||
!info[0].IsNumber() ||
!info[1].IsBoolean()) {
throw Napi::Error::New(env, "Usage: pty.kill(id, useConptyDll)");
}
int id = info[0].As<Napi::Number>().Int32Value();
const bool useConptyDll = info[1].As<Napi::Boolean>().Value();
const pty_baton* handle = get_pty_baton(id);
if (handle != nullptr) {
HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
bool fLoadedDll = hLibrary != nullptr;
if (fLoadedDll)
{
PFNCLOSEPSEUDOCONSOLE const pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress(
(HMODULE)hLibrary,
useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole");
if (pfnClosePseudoConsole)
{
pfnClosePseudoConsole(handle->hpc);
}
}
if (useConptyDll) {
TerminateProcess(handle->hShell, 1);
}
}
return env.Undefined();
}
/**
* Init
*/
Napi::Object init(Napi::Env env, Napi::Object exports) {
exports.Set("startProcess", Napi::Function::New(env, PtyStartProcess));
exports.Set("connect", Napi::Function::New(env, PtyConnect));
exports.Set("resize", Napi::Function::New(env, PtyResize));
exports.Set("clear", Napi::Function::New(env, PtyClear));
exports.Set("kill", Napi::Function::New(env, PtyKill));
return exports;
};
NODE_API_MODULE(NODE_GYP_MODULE_NAME, init);

View file

@ -0,0 +1,41 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
// This header prototypes the Pseudoconsole symbols from conpty.lib with their original names.
// This is required because we cannot import __imp_CreatePseudoConsole from a static library
// as it doesn't produce an import lib.
// We can't use an /ALTERNATENAME trick because it seems that that name is only resolved when the
// linker cannot otherwise find the symbol.
#pragma once
#include <consoleapi.h>
#ifndef CONPTY_IMPEXP
#define CONPTY_IMPEXP __declspec(dllimport)
#endif
#ifndef CONPTY_EXPORT
#ifdef __cplusplus
#define CONPTY_EXPORT extern "C" CONPTY_IMPEXP
#else
#define CONPTY_EXPORT extern CONPTY_IMPEXP
#endif
#endif
#define PSEUDOCONSOLE_RESIZE_QUIRK (2u)
#define PSEUDOCONSOLE_PASSTHROUGH_MODE (8u)
CONPTY_EXPORT HRESULT WINAPI ConptyCreatePseudoConsole(COORD size, HANDLE hInput, HANDLE hOutput, DWORD dwFlags, HPCON* phPC);
CONPTY_EXPORT HRESULT WINAPI ConptyCreatePseudoConsoleAsUser(HANDLE hToken, COORD size, HANDLE hInput, HANDLE hOutput, DWORD dwFlags, HPCON* phPC);
CONPTY_EXPORT HRESULT WINAPI ConptyResizePseudoConsole(HPCON hPC, COORD size);
CONPTY_EXPORT HRESULT WINAPI ConptyClearPseudoConsole(HPCON hPC);
CONPTY_EXPORT HRESULT WINAPI ConptyShowHidePseudoConsole(HPCON hPC, bool show);
CONPTY_EXPORT HRESULT WINAPI ConptyReparentPseudoConsole(HPCON hPC, HWND newParent);
CONPTY_EXPORT HRESULT WINAPI ConptyReleasePseudoConsole(HPCON hPC);
CONPTY_EXPORT VOID WINAPI ConptyClosePseudoConsole(HPCON hPC);
CONPTY_EXPORT VOID WINAPI ConptyClosePseudoConsoleTimeout(HPCON hPC, DWORD dwMilliseconds);
CONPTY_EXPORT HRESULT WINAPI ConptyPackPseudoConsole(HANDLE hServerProcess, HANDLE hRef, HANDLE hSignal, HPCON* phPC);

View file

@ -0,0 +1,44 @@
/**
* Copyright (c) 2019, Microsoft Corporation (MIT License).
*/
#define NODE_ADDON_API_DISABLE_DEPRECATED
#include <napi.h>
#include <windows.h>
static Napi::Value ApiConsoleProcessList(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
if (info.Length() != 1 ||
!info[0].IsNumber()) {
throw Napi::Error::New(env, "Usage: getConsoleProcessList(shellPid)");
}
const DWORD pid = info[0].As<Napi::Number>().Uint32Value();
if (!FreeConsole()) {
throw Napi::Error::New(env, "FreeConsole failed");
}
if (!AttachConsole(pid)) {
throw Napi::Error::New(env, "AttachConsole failed");
}
auto processList = std::vector<DWORD>(64);
auto processCount = GetConsoleProcessList(&processList[0], static_cast<DWORD>(processList.size()));
if (processList.size() < processCount) {
processList.resize(processCount);
processCount = GetConsoleProcessList(&processList[0], static_cast<DWORD>(processList.size()));
}
FreeConsole();
Napi::Array result = Napi::Array::New(env);
for (DWORD i = 0; i < processCount; i++) {
result.Set(i, Napi::Number::New(env, processList[i]));
}
return result;
}
Napi::Object init(Napi::Env env, Napi::Object exports) {
exports.Set("getConsoleProcessList", Napi::Function::New(env, ApiConsoleProcessList));
return exports;
};
NODE_API_MODULE(NODE_GYP_MODULE_NAME, init);

View file

@ -0,0 +1,95 @@
/**
* Copyright (c) 2013-2015, Christopher Jeffrey, Peter Sunde (MIT License)
* Copyright (c) 2016, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
#include <stdexcept>
#include <Shlwapi.h> // PathCombine
#include <Windows.h>
#include "path_util.h"
namespace path_util {
std::wstring to_wstring(const Napi::String& str) {
const std::u16string & u16 = str.Utf16Value();
return std::wstring(u16.begin(), u16.end());
}
std::string wstring_to_string(const std::wstring &wide_string) {
if (wide_string.empty()) {
return "";
}
const auto size_needed = WideCharToMultiByte(CP_UTF8, 0, &wide_string.at(0), (int)wide_string.size(), nullptr, 0, nullptr, nullptr);
if (size_needed <= 0) {
return "";
}
std::string result(size_needed, 0);
WideCharToMultiByte(CP_UTF8, 0, &wide_string.at(0), (int)wide_string.size(), &result.at(0), size_needed, nullptr, nullptr);
return result;
}
std::string from_wstring(const wchar_t* wstr) {
int bufferSize = WideCharToMultiByte(CP_UTF8, 0, wstr, -1, NULL, 0, NULL, NULL);
if (bufferSize <= 0) {
return "";
}
std::string result(bufferSize - 1, 0); // -1 to exclude null terminator
int status = WideCharToMultiByte(CP_UTF8, 0, wstr, -1, &result[0], bufferSize, NULL, NULL);
if (status == 0) {
return "";
}
return result;
}
bool file_exists(std::wstring filename) {
DWORD attr = ::GetFileAttributesW(filename.c_str());
if (attr == INVALID_FILE_ATTRIBUTES || (attr & FILE_ATTRIBUTE_DIRECTORY)) {
return false;
}
return true;
}
// cmd.exe -> C:\Windows\system32\cmd.exe
std::wstring get_shell_path(std::wstring filename) {
std::wstring shellpath;
if (file_exists(filename)) {
return shellpath;
}
wchar_t* buffer_ = new wchar_t[MAX_ENV];
int read = ::GetEnvironmentVariableW(L"Path", buffer_, MAX_ENV);
if (read) {
std::wstring delimiter = L";";
size_t pos = 0;
std::vector<std::wstring> paths;
std::wstring buffer(buffer_);
while ((pos = buffer.find(delimiter)) != std::wstring::npos) {
paths.push_back(buffer.substr(0, pos));
buffer.erase(0, pos + delimiter.length());
}
const wchar_t *filename_ = filename.c_str();
for (size_t i = 0; i < paths.size(); ++i) {
std::wstring path = paths[i];
wchar_t searchPath[MAX_PATH];
::PathCombineW(searchPath, const_cast<wchar_t*>(path.c_str()), filename_);
if (searchPath == NULL) {
continue;
}
if (file_exists(searchPath)) {
shellpath = searchPath;
break;
}
}
}
delete[] buffer_;
return shellpath;
}
} // namespace path_util

View file

@ -0,0 +1,26 @@
/**
* Copyright (c) 2013-2015, Christopher Jeffrey, Peter Sunde (MIT License)
* Copyright (c) 2016, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*/
#ifndef NODE_PTY_PATH_UTIL_H_
#define NODE_PTY_PATH_UTIL_H_
#define NODE_ADDON_API_DISABLE_DEPRECATED
#include <napi.h>
#include <string>
#define MAX_ENV 65536
namespace path_util {
std::wstring to_wstring(const Napi::String& str);
std::string wstring_to_string(const std::wstring &wide_string);
std::string from_wstring(const wchar_t* wstr);
bool file_exists(std::wstring filename);
std::wstring get_shell_path(std::wstring filename);
} // namespace path_util
#endif // NODE_PTY_PATH_UTIL_H_

View file

@ -0,0 +1,333 @@
/**
* Copyright (c) 2013-2015, Christopher Jeffrey, Peter Sunde (MIT License)
* Copyright (c) 2016, Daniel Imms (MIT License).
* Copyright (c) 2018, Microsoft Corporation (MIT License).
*
* pty.cc:
* This file is responsible for starting processes
* with pseudo-terminal file descriptors.
*/
#define NODE_ADDON_API_DISABLE_DEPRECATED
#include <napi.h>
#include <iostream>
#include <assert.h>
#include <map>
#include <Shlwapi.h> // PathCombine, PathIsRelative
#include <sstream>
#include <stdlib.h>
#include <string.h>
#include <string>
#include <vector>
#include <winpty.h>
#include "path_util.h"
/**
* Misc
*/
#define WINPTY_DBG_VARIABLE TEXT("WINPTYDBG")
/**
* winpty
*/
static std::vector<winpty_t *> ptyHandles;
static volatile LONG ptyCounter;
/**
* Helpers
*/
/** Keeps track of the handles created by PtyStartProcess */
static std::map<DWORD, HANDLE> createdHandles;
static winpty_t *get_pipe_handle(DWORD pid) {
for (size_t i = 0; i < ptyHandles.size(); ++i) {
winpty_t *ptyHandle = ptyHandles[i];
HANDLE current = winpty_agent_process(ptyHandle);
if (GetProcessId(current) == pid) {
return ptyHandle;
}
}
return nullptr;
}
static bool remove_pipe_handle(DWORD pid) {
for (size_t i = 0; i < ptyHandles.size(); ++i) {
winpty_t *ptyHandle = ptyHandles[i];
HANDLE current = winpty_agent_process(ptyHandle);
if (GetProcessId(current) == pid) {
winpty_free(ptyHandle);
ptyHandles.erase(ptyHandles.begin() + i);
ptyHandle = nullptr;
return true;
}
}
return false;
}
Napi::Error error_with_winpty_msg(const char *generalMsg, winpty_error_ptr_t error_ptr, Napi::Env env) {
std::string why;
why += generalMsg;
why += ": ";
why += path_util::wstring_to_string(winpty_error_msg(error_ptr));
winpty_error_free(error_ptr);
return Napi::Error::New(env, why);
}
static Napi::Value PtyGetExitCode(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
if (info.Length() != 1 ||
!info[0].IsNumber()) {
throw Napi::Error::New(env, "Usage: pty.getExitCode(pid)");
}
DWORD pid = info[0].As<Napi::Number>().Uint32Value();
HANDLE handle = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, pid);
if (handle == NULL) {
return Napi::Number::New(env, -1);
}
DWORD exitCode = 0;
BOOL success = GetExitCodeProcess(handle, &exitCode);
if (success == FALSE) {
exitCode = -1;
}
CloseHandle(handle);
return Napi::Number::New(env, exitCode);
}
static Napi::Value PtyGetProcessList(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
if (info.Length() != 1 ||
!info[0].IsNumber()) {
throw Napi::Error::New(env, "Usage: pty.getProcessList(pid)");
}
DWORD pid = info[0].As<Napi::Number>().Uint32Value();
winpty_t *pc = get_pipe_handle(pid);
if (pc == nullptr) {
return Napi::Number::New(env, 0);
}
int processList[64];
const int processCount = 64;
int actualCount = winpty_get_console_process_list(pc, processList, processCount, nullptr);
if (actualCount <= 0) {
return Napi::Number::New(env, 0);
}
Napi::Array result = Napi::Array::New(env, actualCount);
for (int i = 0; i < actualCount; i++) {
result.Set(i, Napi::Number::New(env, processList[i]));
}
return result;
}
static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
if (info.Length() != 7 ||
!info[0].IsString() ||
!info[1].IsString() ||
!info[2].IsArray() ||
!info[3].IsString() ||
!info[4].IsNumber() ||
!info[5].IsNumber() ||
!info[6].IsBoolean()) {
throw Napi::Error::New(env, "Usage: pty.startProcess(file, cmdline, env, cwd, cols, rows, debug)");
}
std::wstring filename(path_util::to_wstring(info[0].As<Napi::String>()));
std::wstring cmdline(path_util::to_wstring(info[1].As<Napi::String>()));
std::wstring cwd(path_util::to_wstring(info[3].As<Napi::String>()));
// create environment block
std::wstring envStr;
const Napi::Array envValues = info[2].As<Napi::Array>();
if (!envValues.IsEmpty()) {
std::wstring envBlock;
for(uint32_t i = 0; i < envValues.Length(); i++) {
envBlock += path_util::to_wstring(envValues.Get(i).As<Napi::String>());
envBlock += L'\0';
}
envStr = std::move(envBlock);
}
// use environment 'Path' variable to determine location of
// the relative path that we have recieved (e.g cmd.exe)
std::wstring shellpath;
if (::PathIsRelativeW(filename.c_str())) {
shellpath = path_util::get_shell_path(filename);
} else {
shellpath = filename;
}
if (shellpath.empty() || !path_util::file_exists(shellpath)) {
std::string why;
why += "File not found: ";
why += path_util::wstring_to_string(shellpath);
throw Napi::Error::New(env, why);
}
int cols = info[4].As<Napi::Number>().Int32Value();
int rows = info[5].As<Napi::Number>().Int32Value();
bool debug = info[6].As<Napi::Boolean>().Value();
// Enable/disable debugging
SetEnvironmentVariable(WINPTY_DBG_VARIABLE, debug ? "1" : NULL); // NULL = deletes variable
// Create winpty config
winpty_error_ptr_t error_ptr = nullptr;
winpty_config_t* winpty_config = winpty_config_new(0, &error_ptr);
if (winpty_config == nullptr) {
throw error_with_winpty_msg("Error creating WinPTY config", error_ptr, env);
}
winpty_error_free(error_ptr);
// Set pty size on config
winpty_config_set_initial_size(winpty_config, cols, rows);
// Start the pty agent
winpty_t *pc = winpty_open(winpty_config, &error_ptr);
winpty_config_free(winpty_config);
if (pc == nullptr) {
throw error_with_winpty_msg("Error launching WinPTY agent", error_ptr, env);
}
winpty_error_free(error_ptr);
// Create winpty spawn config
winpty_spawn_config_t* config = winpty_spawn_config_new(WINPTY_SPAWN_FLAG_AUTO_SHUTDOWN, shellpath.c_str(), cmdline.c_str(), cwd.c_str(), envStr.c_str(), &error_ptr);
if (config == nullptr) {
winpty_free(pc);
throw error_with_winpty_msg("Error creating WinPTY spawn config", error_ptr, env);
}
winpty_error_free(error_ptr);
// Spawn the new process
HANDLE handle = nullptr;
BOOL spawnSuccess = winpty_spawn(pc, config, &handle, nullptr, nullptr, &error_ptr);
winpty_spawn_config_free(config);
if (!spawnSuccess) {
if (handle) {
CloseHandle(handle);
}
winpty_free(pc);
throw error_with_winpty_msg("Unable to start terminal process", error_ptr, env);
}
winpty_error_free(error_ptr);
LPCWSTR coninPipeName = winpty_conin_name(pc);
std::string coninPipeNameStr = path_util::from_wstring(coninPipeName);
if (coninPipeNameStr.empty()) {
CloseHandle(handle);
winpty_free(pc);
throw Napi::Error::New(env, "Failed to initialize winpty conin");
}
LPCWSTR conoutPipeName = winpty_conout_name(pc);
std::string conoutPipeNameStr = path_util::from_wstring(conoutPipeName);
if (conoutPipeNameStr.empty()) {
CloseHandle(handle);
winpty_free(pc);
throw Napi::Error::New(env, "Failed to initialize winpty conout");
}
DWORD innerPid = GetProcessId(handle);
if (createdHandles[innerPid]) {
CloseHandle(handle);
winpty_free(pc);
std::stringstream why;
why << "There is already a process with innerPid " << innerPid;
throw Napi::Error::New(env, why.str());
}
createdHandles[innerPid] = handle;
// Save pty struct for later use
ptyHandles.push_back(pc);
DWORD pid = GetProcessId(winpty_agent_process(pc));
Napi::Object marshal = Napi::Object::New(env);
marshal.Set("innerPid", Napi::Number::New(env, (int)innerPid));
marshal.Set("pid", Napi::Number::New(env, (int)pid));
marshal.Set("pty", Napi::Number::New(env, InterlockedIncrement(&ptyCounter)));
marshal.Set("fd", Napi::Number::New(env, -1));
marshal.Set("conin", Napi::String::New(env, coninPipeNameStr));
marshal.Set("conout", Napi::String::New(env, conoutPipeNameStr));
return marshal;
}
static Napi::Value PtyResize(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
if (info.Length() != 3 ||
!info[0].IsNumber() ||
!info[1].IsNumber() ||
!info[2].IsNumber()) {
throw Napi::Error::New(env, "Usage: pty.resize(pid, cols, rows)");
}
DWORD pid = info[0].As<Napi::Number>().Uint32Value();
int cols = info[1].As<Napi::Number>().Int32Value();
int rows = info[2].As<Napi::Number>().Int32Value();
winpty_t *pc = get_pipe_handle(pid);
if (pc == nullptr) {
throw Napi::Error::New(env, "The pty doesn't appear to exist");
}
BOOL success = winpty_set_size(pc, cols, rows, nullptr);
if (!success) {
throw Napi::Error::New(env, "The pty could not be resized");
}
return env.Undefined();
}
static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
if (info.Length() != 2 ||
!info[0].IsNumber() ||
!info[1].IsNumber()) {
throw Napi::Error::New(env, "Usage: pty.kill(pid, innerPid)");
}
DWORD pid = info[0].As<Napi::Number>().Uint32Value();
DWORD innerPid = info[1].As<Napi::Number>().Uint32Value();
winpty_t *pc = get_pipe_handle(pid);
if (pc == nullptr) {
throw Napi::Error::New(env, "Pty seems to have been killed already");
}
assert(remove_pipe_handle(pid));
HANDLE innerPidHandle = createdHandles[innerPid];
createdHandles.erase(innerPid);
CloseHandle(innerPidHandle);
return env.Undefined();
}
/**
* Init
*/
Napi::Object init(Napi::Env env, Napi::Object exports) {
exports.Set("startProcess", Napi::Function::New(env, PtyStartProcess));
exports.Set("resize", Napi::Function::New(env, PtyResize));
exports.Set("kill", Napi::Function::New(env, PtyKill));
exports.Set("getExitCode", Napi::Function::New(env, PtyGetExitCode));
exports.Set("getProcessList", Napi::Function::New(env, PtyGetProcessList));
return exports;
};
NODE_API_MODULE(NODE_GYP_MODULE_NAME, init);

View file

@ -0,0 +1,214 @@
/**
* Simplified Windows terminal implementation without threading
* Removed ConoutSocketWorker and shared pipe architecture
*/
import * as fs from 'fs';
import { Socket } from 'net';
import { Terminal, DEFAULT_COLS, DEFAULT_ROWS } from './terminal';
import { IPtyOpenOptions, IWindowsPtyForkOptions } from './interfaces';
import { ArgvOrCommandLine } from './types';
import { assign } from './utils';
const DEFAULT_FILE = 'cmd.exe';
const DEFAULT_NAME = 'Windows Shell';
// Native module interfaces
interface IConptyProcess {
pty: number;
fd: number;
conin: string;
conout: string;
}
interface IConptyNative {
startProcess(file: string, cols: number, rows: number, debug: boolean, pipeName: string, inheritCursor: boolean, useConptyDll: boolean): IConptyProcess;
connect(pty: number, commandLine: string, cwd: string, env: string[], useConptyDll: boolean, onExit: (exitCode: number) => void): { pid: number };
resize(pty: number, cols: number, rows: number, useConptyDll: boolean): void;
clear(pty: number, useConptyDll: boolean): void;
kill(pty: number, useConptyDll: boolean): void;
}
let conptyNative: IConptyNative;
export class WindowsTerminal extends Terminal {
private _isReady: boolean = false;
protected _pid: number = 0;
private _innerPid: number = 0;
private _ptyNative: IConptyNative;
protected _pty: number;
private _inSocket!: Socket;
private _outSocket!: Socket;
private _exitCode: number | undefined;
private _useConptyDll: boolean = false;
public get master(): Socket | undefined { return this._outSocket; }
public get slave(): Socket | undefined { return this._inSocket; }
constructor(file?: string, args?: ArgvOrCommandLine, opt?: IWindowsPtyForkOptions) {
super(opt);
// Load native module
if (!conptyNative) {
try {
conptyNative = require('../build/Release/conpty.node');
} catch (outerError) {
try {
conptyNative = require('../build/Debug/conpty.node');
} catch (innerError) {
throw outerError;
}
}
}
this._ptyNative = conptyNative;
// Initialize arguments
args = args || [];
file = file || DEFAULT_FILE;
opt = opt || {};
opt.env = opt.env || process.env;
const env = assign({}, opt.env);
this._cols = opt.cols || DEFAULT_COLS;
this._rows = opt.rows || DEFAULT_ROWS;
const cwd = opt.cwd || process.cwd();
const parsedEnv = this._parseEnv(env);
// Compose command line
const commandLine = this._argsToCommandLine(file, args);
// Start ConPTY process
const pipeName = this._generatePipeName();
const term = this._ptyNative.startProcess(file, this._cols, this._rows, false, pipeName, false, this._useConptyDll);
this._pty = term.pty;
this._fd = term.fd;
// Create direct socket connections without worker threads
this._setupDirectSockets(term);
// Connect the process
const connect = this._ptyNative.connect(this._pty, commandLine, cwd, parsedEnv, this._useConptyDll, (exitCode) => {
this._exitCode = exitCode;
this.emit('exit', exitCode);
this._close();
});
this._innerPid = connect.pid;
this._pid = connect.pid;
this._file = file;
this._name = opt.name || env.TERM || DEFAULT_NAME;
this._readable = true;
this._writable = true;
this._forwardEvents();
}
private _setupDirectSockets(term: IConptyProcess): void {
// Setup output socket - read directly from conout
const outFd = fs.openSync(term.conout, 'r');
this._outSocket = new Socket({ fd: outFd, readable: true, writable: false });
this._outSocket.setEncoding('utf8');
this._socket = this._outSocket;
// Setup input socket - write directly to conin
const inFd = fs.openSync(term.conin, 'w');
this._inSocket = new Socket({ fd: inFd, readable: false, writable: true });
this._inSocket.setEncoding('utf8');
// Forward events directly
this._outSocket.on('data', (data) => {
if (!this._isReady) {
this._isReady = true;
}
this.emit('data', data);
});
this._outSocket.on('error', (err) => {
if ((err as any).code && ((err as any).code.includes('EPIPE') || (err as any).code.includes('EIO'))) {
// Expected errors when process exits
return;
}
this.emit('error', err);
});
this._outSocket.on('close', () => {
if (this._exitCode === undefined) {
this.emit('exit', 0);
}
this._close();
});
}
protected _write(data: string): void {
if (this._inSocket && this._inSocket.writable) {
this._inSocket.write(data);
}
}
public resize(cols: number, rows: number): void {
if (cols <= 0 || rows <= 0 || isNaN(cols) || isNaN(rows) || cols === Infinity || rows === Infinity) {
throw new Error('resizing must be done using positive cols and rows');
}
if (this._exitCode !== undefined) {
throw new Error('Cannot resize a pty that has already exited');
}
this._cols = cols;
this._rows = rows;
this._ptyNative.resize(this._pty, cols, rows, this._useConptyDll);
}
public clear(): void {
this._ptyNative.clear(this._pty, this._useConptyDll);
}
public kill(signal?: string): void {
this._close();
try {
process.kill(this._pid);
} catch (e) {
// Ignore if process cannot be found
}
this._ptyNative.kill(this._pty, this._useConptyDll);
}
protected _close(): void {
if (this._inSocket) {
this._inSocket.destroy();
}
if (this._outSocket) {
this._outSocket.destroy();
}
}
private _generatePipeName(): string {
return `\\\\.\\pipe\\conpty-${Date.now()}-${Math.random()}`;
}
private _argsToCommandLine(file: string, args: ArgvOrCommandLine): string {
if (typeof args === 'string') {
return `${file} ${args}`;
}
const argv = [file];
if (args) {
argv.push(...args);
}
return argv.map(arg => {
if (arg.includes(' ') || arg.includes('\t')) {
return `"${arg.replace(/"/g, '\\"')}"`;
}
return arg;
}).join(' ');
}
public static open(options?: IPtyOpenOptions): void {
throw new Error('open() not supported on windows, use spawn() instead.');
}
public get process(): string { return this._name; }
public get pid(): number { return this._pid; }
public destroy(): void {
this.kill();
}
}

78
web/package/package.json Normal file
View file

@ -0,0 +1,78 @@
{
"name": "vibetunnel",
"version": "1.0.0-beta.12",
"description": "Terminal sharing server with web interface - supports macOS, Linux, and headless environments",
"main": "lib/cli.js",
"bin": {
"vibetunnel": "./bin/vibetunnel",
"vt": "./bin/vt"
},
"files": [
"lib/",
"public/",
"bin/",
"scripts/",
"node-pty/",
"node_modules/authenticate-pam/",
"prebuilds/",
"README.md"
],
"os": [
"darwin",
"linux"
],
"engines": {
"node": ">=20.0.0"
},
"repository": {
"type": "git",
"url": "https://github.com/amantus-ai/vibetunnel.git",
"directory": "web"
},
"homepage": "https://vibetunnel.sh",
"bugs": {
"url": "https://github.com/amantus-ai/vibetunnel/issues"
},
"scripts": {
"postinstall": "node scripts/postinstall.js"
},
"dependencies": {
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.3.3",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.0",
"@xterm/headless": "^5.5.0",
"bonjour-service": "^1.3.0",
"chalk": "^5.4.1",
"compression": "^1.8.0",
"express": "^5.1.0",
"helmet": "^8.1.0",
"http-proxy-middleware": "^3.0.5",
"jsonwebtoken": "^9.0.2",
"lit": "^3.3.1",
"mime-types": "^3.0.1",
"monaco-editor": "^0.52.2",
"multer": "^2.0.1",
"postject": "1.0.0-alpha.6",
"signal-exit": "^4.1.0",
"web-push": "^3.6.7",
"ws": "^8.18.3"
},
"optionalDependencies": {
"authenticate-pam": "^1.0.5"
},
"keywords": [
"terminal",
"multiplexer",
"websocket",
"asciinema"
],
"author": "",
"license": "MIT"
}

View file

@ -0,0 +1,73 @@
/**
* ESBuild plugin to handle node-pty resolution for npm packages
*/
const path = require('path');
const fs = require('fs');
const nodePtyPlugin = {
name: 'node-pty-resolver',
setup(build) {
// Resolve node-pty imports to our bundled version
build.onResolve({ filter: /^node-pty$/ }, args => {
// In development, use the normal node_modules resolution
if (process.env.NODE_ENV === 'development') {
return null;
}
// For npm builds, resolve to our bundled node-pty
return {
path: 'node-pty',
namespace: 'node-pty-stub'
};
});
// Provide stub that dynamically loads the bundled node-pty
build.onLoad({ filter: /^node-pty$/, namespace: 'node-pty-stub' }, () => {
return {
contents: `
const path = require('path');
const fs = require('fs');
// Try multiple possible locations for node-pty
const possiblePaths = [
// When installed via npm
path.join(__dirname, '../node-pty'),
path.join(__dirname, '../../node-pty'),
// During development
path.join(__dirname, '../node_modules/node-pty'),
// Fallback to regular require
'node-pty'
];
let nodePty;
let loadError;
for (const tryPath of possiblePaths) {
try {
if (tryPath === 'node-pty') {
// Try regular require as last resort
nodePty = require(tryPath);
} else if (fs.existsSync(tryPath)) {
// Check if the path exists before trying to load
nodePty = require(tryPath);
}
if (nodePty) break;
} catch (err) {
loadError = err;
}
}
if (!nodePty) {
throw new Error(\`Failed to load node-pty from any location. Last error: \${loadError?.message}\`);
}
module.exports = nodePty;
`,
loader: 'js'
};
});
}
};
module.exports = { nodePtyPlugin };

View file

@ -0,0 +1,257 @@
#!/usr/bin/env node
/**
* Postinstall script for npm package
* Handles prebuild extraction and fallback compilation
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const os = require('os');
console.log('Setting up native modules for VibeTunnel...');
// Check for npm_config_prefix conflict with NVM
if (process.env.npm_config_prefix && process.env.NVM_DIR) {
const nvmNodeVersion = process.execPath;
const npmPrefix = process.env.npm_config_prefix;
// Check if npm_config_prefix conflicts with NVM path
if (!nvmNodeVersion.includes(npmPrefix) && nvmNodeVersion.includes('.nvm')) {
console.warn('⚠️ Detected npm_config_prefix conflict with NVM');
console.warn(` npm_config_prefix: ${npmPrefix}`);
console.warn(` NVM Node path: ${nvmNodeVersion}`);
console.warn(' This may cause npm global installs to fail or install in wrong location.');
console.warn(' Run: unset npm_config_prefix');
console.warn(' Then reinstall VibeTunnel for proper NVM compatibility.');
}
}
// Check if we're in development (has src directory) or npm install
const isDevelopment = fs.existsSync(path.join(__dirname, '..', 'src'));
if (isDevelopment) {
// In development, run the existing ensure-native-modules script
require('./ensure-native-modules.js');
return;
}
// For npm package, node-pty is bundled in the package root
// No need to create symlinks as it's accessed directly
// Get Node ABI version
const nodeABI = process.versions.modules;
// Get platform and architecture
const platform = process.platform;
const arch = os.arch();
// Convert architecture names
const archMap = {
'arm64': 'arm64',
'aarch64': 'arm64',
'x64': 'x64',
'x86_64': 'x64'
};
const normalizedArch = archMap[arch] || arch;
console.log(`Platform: ${platform}-${normalizedArch}, Node ABI: ${nodeABI}`);
// Function to try prebuild-install first
const tryPrebuildInstall = (moduleName, moduleDir) => {
try {
// Check if prebuild-install is available
const prebuildInstallPath = require.resolve('prebuild-install/bin.js');
console.log(` Attempting to use prebuild-install for ${moduleName}...`);
execSync(`node "${prebuildInstallPath}"`, {
cwd: moduleDir,
stdio: 'inherit',
env: { ...process.env, npm_config_build_from_source: 'false' }
});
return true;
} catch (error) {
console.log(` prebuild-install failed for ${moduleName}, will try manual extraction`);
return false;
}
};
// Function to manually extract prebuild
const extractPrebuild = (name, version, targetDir, skipDirCheck = false) => {
const prebuildFile = path.join(__dirname, '..', 'prebuilds',
`${name}-v${version}-node-v${nodeABI}-${platform}-${normalizedArch}.tar.gz`);
if (!fs.existsSync(prebuildFile)) {
console.log(` No prebuild found for ${name} on this platform`);
return false;
}
// For optional dependencies like authenticate-pam, check if the module exists
// If not, extract to a different location
let extractDir = targetDir;
if (skipDirCheck && name === 'authenticate-pam' && !fs.existsSync(targetDir)) {
// Extract to a controlled location since node_modules/authenticate-pam doesn't exist
extractDir = path.join(__dirname, '..', 'optional-modules', name);
}
// Create the parent directory
fs.mkdirSync(extractDir, { recursive: true });
try {
// Extract directly into the module directory - the tar already contains build/Release structure
execSync(`tar -xzf "${prebuildFile}" -C "${extractDir}"`, { stdio: 'inherit' });
console.log(`${name} prebuilt binary extracted`);
return true;
} catch (error) {
console.error(` Failed to extract ${name} prebuild:`, error.message);
return false;
}
};
// Function to compile from source
const compileFromSource = (moduleName, moduleDir) => {
console.log(` Building ${moduleName} from source...`);
try {
// First check if node-gyp is available
try {
execSync('node-gyp --version', { stdio: 'pipe' });
} catch (e) {
console.log(' Installing node-gyp...');
execSync('npm install -g node-gyp', { stdio: 'inherit' });
}
// For node-pty, node-addon-api is included as a dependency in its package.json
// npm should handle it automatically during source compilation
execSync('node-gyp rebuild', {
cwd: moduleDir,
stdio: 'inherit'
});
console.log(`${moduleName} built successfully`);
return true;
} catch (error) {
console.error(` Failed to build ${moduleName}:`, error.message);
return false;
}
};
// Handle both native modules
const modules = [
{
name: 'node-pty',
version: '1.0.0',
dir: path.join(__dirname, '..', 'node-pty'),
build: path.join(__dirname, '..', 'node-pty', 'build', 'Release', 'pty.node'),
essential: true
},
{
name: 'authenticate-pam',
version: '1.0.5',
dir: path.join(__dirname, '..', 'node_modules', 'authenticate-pam'),
build: path.join(__dirname, '..', 'node_modules', 'authenticate-pam', 'build', 'Release', 'authenticate_pam.node'),
essential: false, // Optional - falls back to other auth methods
platforms: ['linux', 'darwin'], // Needed on Linux and macOS
skipDirCheck: true // Don't check if dir exists since it's optional
}
];
let hasErrors = false;
for (const module of modules) {
console.log(`\nProcessing ${module.name}...`);
// Skip platform-specific modules if not on that platform
if (module.platforms && !module.platforms.includes(platform)) {
console.log(` Skipping ${module.name} (not needed on ${platform})`);
continue;
}
// Check if module directory exists
if (!fs.existsSync(module.dir)) {
if (module.skipDirCheck) {
// For optional modules, we'll try to extract the prebuild anyway
console.log(` ${module.name} not installed via npm (optional dependency), will extract prebuild`);
} else {
console.warn(` Warning: ${module.name} directory not found at ${module.dir}`);
if (module.essential) {
hasErrors = true;
}
continue;
}
}
// Check if already built
if (fs.existsSync(module.build)) {
console.log(`${module.name} already available`);
continue;
}
// Try installation methods in order
let success = false;
// Method 1: Try prebuild-install (preferred) - skip if directory doesn't exist
if (fs.existsSync(module.dir)) {
success = tryPrebuildInstall(module.name, module.dir);
}
// Method 2: Manual prebuild extraction
if (!success) {
success = extractPrebuild(module.name, module.version, module.dir, module.skipDirCheck);
}
// Method 3: Compile from source (skip if directory doesn't exist)
if (!success && fs.existsSync(module.dir) && fs.existsSync(path.join(module.dir, 'binding.gyp'))) {
success = compileFromSource(module.name, module.dir);
}
// Check final result
if (!success) {
// Special handling for authenticate-pam on macOS
if (module.name === 'authenticate-pam' && process.platform === 'darwin') {
console.warn(`⚠️ Warning: ${module.name} installation failed on macOS.`);
console.warn(' This is expected - macOS will fall back to environment variable or SSH key authentication.');
console.warn(' To enable PAM authentication, install Xcode Command Line Tools and rebuild.');
} else if (module.essential) {
console.error(`\n${module.name} is required for VibeTunnel to function.`);
console.error('You may need to install build tools for your platform:');
console.error('- macOS: Install Xcode Command Line Tools');
console.error('- Linux: Install build-essential and libpam0g-dev packages');
hasErrors = true;
} else {
console.warn(`⚠️ Warning: ${module.name} installation failed. Some features may be limited.`);
}
}
}
// Install vt symlink/wrapper
if (!hasErrors && !isDevelopment) {
console.log('\nSetting up vt command...');
const vtSource = path.join(__dirname, '..', 'bin', 'vt');
// Check if vt script exists
if (!fs.existsSync(vtSource)) {
console.warn('⚠️ vt command script not found in package');
console.log(' Use "vibetunnel" command instead');
} else {
try {
// Make vt script executable
fs.chmodSync(vtSource, '755');
console.log('✓ vt command configured');
console.log(' Note: The vt command is available through npm/npx');
} catch (error) {
console.warn('⚠️ Could not configure vt command:', error.message);
console.log(' Use "vibetunnel" command instead');
}
}
}
if (hasErrors) {
console.error('\n❌ Setup failed with errors');
process.exit(1);
} else {
console.log('\n✅ VibeTunnel is ready to use');
console.log('Run "vibetunnel --help" for usage information');
}

View file

@ -0,0 +1,45 @@
#!/bin/bash
# Test VibeTunnel npm package installation with verbose output
VERSION=${1:-latest}
echo "Testing VibeTunnel npm package version: $VERSION"
echo "================================================"
# Create test directory
TMP_DIR=$(mktemp -d)
echo "Test directory: $TMP_DIR"
# Create Dockerfile
cat > "$TMP_DIR/Dockerfile" << 'EOF'
FROM node:20-slim
WORKDIR /app
# Test installation and PAM extraction
RUN echo "=== Installing VibeTunnel ===" && \
npm install -g vibetunnel@VERSION && \
echo "=== Installation complete ===" && \
echo "=== Checking for node_modules/authenticate-pam ===" && \
ls -la /usr/local/lib/node_modules/vibetunnel/node_modules/ | grep -E "(authenticate|optional)" || echo "No authenticate-pam in node_modules" && \
echo "=== Checking for optional-modules ===" && \
ls -la /usr/local/lib/node_modules/vibetunnel/optional-modules/ 2>/dev/null || echo "No optional-modules directory" && \
echo "=== Checking postinstall output ===" && \
cd /usr/local/lib/node_modules/vibetunnel && \
npm run postinstall || echo "Postinstall failed" && \
echo "=== Final check ===" && \
find /usr/local/lib/node_modules/vibetunnel -name "authenticate_pam.node" -type f 2>/dev/null || echo "No authenticate_pam.node found"
EOF
# Replace VERSION placeholder
sed -i.bak "s/VERSION/$VERSION/g" "$TMP_DIR/Dockerfile"
# Build and run
echo "Building Docker image..."
docker build -t vibetunnel-npm-test-verbose "$TMP_DIR"
# Cleanup
rm -rf "$TMP_DIR"
echo "✅ Test complete!"

60
web/scripts/test-npm-docker.sh Executable file
View file

@ -0,0 +1,60 @@
#!/bin/bash
# Test VibeTunnel npm package installation in Docker
# Usage: ./scripts/test-npm-docker.sh [version]
# Example: ./scripts/test-npm-docker.sh 1.0.0-beta.11.4
VERSION=${1:-latest}
echo "Testing VibeTunnel npm package version: $VERSION"
echo "================================================"
# Create temporary dockerfile
TEMP_DIR=$(mktemp -d)
DOCKERFILE="$TEMP_DIR/Dockerfile"
cat > "$DOCKERFILE" << EOF
FROM node:20-slim
# Test 1: Install without PAM headers (should succeed)
RUN echo "=== Test 1: Installing without PAM headers ===" && \
npm install -g vibetunnel@$VERSION && \
vibetunnel --version && \
node -e "try { require('authenticate-pam'); console.log('PAM available'); } catch { console.log('PAM not available - this is expected'); }"
# Test 2: Install PAM headers and check if module compiles
RUN echo "=== Test 2: Installing PAM headers ===" && \
apt-get update && apt-get install -y libpam0g-dev && \
echo "PAM headers installed"
# Test 3: Verify VibeTunnel still works
RUN echo "=== Test 3: Verifying VibeTunnel functionality ===" && \
vibetunnel --version && \
vibetunnel --help > /dev/null && \
echo "VibeTunnel is working correctly!"
CMD ["echo", "All tests passed successfully!"]
EOF
# Build and run the test
echo "Building Docker image..."
docker build -f "$DOCKERFILE" -t vibetunnel-npm-test . || {
echo "❌ Docker build failed!"
rm -rf "$TEMP_DIR"
exit 1
}
echo ""
echo "Running tests..."
docker run --rm vibetunnel-npm-test || {
echo "❌ Tests failed!"
rm -rf "$TEMP_DIR"
exit 1
}
# Cleanup
rm -rf "$TEMP_DIR"
docker rmi vibetunnel-npm-test > /dev/null 2>&1
echo ""
echo "✅ All tests passed! VibeTunnel $VERSION installs correctly on Linux."

View file

@ -64,7 +64,7 @@ if (fs.existsSync(seaPamPath) || fs.existsSync(seaNativePamPath)) {
} else {
// Development mode - use regular require
let loaded = false;
// First, try the normal require path
try {
const pamModule = require('authenticate-pam');
@ -74,20 +74,10 @@ if (fs.existsSync(seaPamPath) || fs.existsSync(seaNativePamPath)) {
} catch (_error) {
// Module not found via normal require
}
// If normal require failed, try the optional-modules location
if (!loaded) {
const optionalModulePath = path.join(
__dirname,
'..',
'..',
'..',
'optional-modules',
'authenticate-pam',
'build',
'Release',
'authenticate_pam.node'
);
const optionalModulePath = path.join(__dirname, '..', '..', '..', 'optional-modules', 'authenticate-pam', 'build', 'Release', 'authenticate_pam.node');
if (fs.existsSync(optionalModulePath)) {
try {
const nativeModule = loadNativeModule(optionalModulePath);
@ -101,7 +91,7 @@ if (fs.existsSync(seaPamPath) || fs.existsSync(seaNativePamPath)) {
}
}
}
if (!loaded) {
console.warn(
'Warning: authenticate-pam native module not found. PAM authentication will not work.'

View file

@ -0,0 +1,29 @@
# Test VibeTunnel npm package with prebuilds
FROM node:22
# Create a test user
RUN useradd -m -s /bin/bash testuser
# Install dependencies
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Install vibetunnel globally
RUN npm install -g --ignore-scripts vibetunnel@latest
# Test script
RUN echo '#!/bin/bash\n\
echo "Testing VibeTunnel npm package..."\n\
echo "Node version: $(node --version)"\n\
echo "npm version: $(npm --version)"\n\
echo ""\n\
echo "Testing vibetunnel command..."\n\
which vibetunnel && echo "✅ vibetunnel command found" || echo "❌ vibetunnel command not found"\n\
echo ""\n\
echo "Checking version..."\n\
vibetunnel --version 2>&1 || echo "Note: Version check may fail if native modules are missing"\n\
echo ""\n\
echo "Checking help..."\n\
vibetunnel --help 2>&1 | head -20\n\
' > /test.sh && chmod +x /test.sh
CMD ["/test.sh"]

View file

@ -0,0 +1,26 @@
# Test VibeTunnel npm package with postinstall
FROM node:22
# Install dependencies
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Install vibetunnel globally (with postinstall)
RUN npm install -g vibetunnel@latest
# Test script
RUN echo '#!/bin/bash\n\
echo "Testing VibeTunnel npm package..."\n\
echo "Node version: $(node --version)"\n\
echo ""\n\
echo "Checking installed files..."\n\
ls -la /usr/local/lib/node_modules/vibetunnel/node-pty/build/ 2>/dev/null || echo "No build directory"\n\
ls -la /usr/local/lib/node_modules/vibetunnel/prebuilds/ 2>/dev/null || echo "No prebuilds directory"\n\
echo ""\n\
echo "Testing vibetunnel command..."\n\
vibetunnel --version\n\
echo ""\n\
echo "Starting server test..."\n\
timeout 5 vibetunnel --port 4021 --no-auth || echo "Server test completed"\n\
' > /test.sh && chmod +x /test.sh
CMD ["/test.sh"]

View file

@ -0,0 +1,43 @@
# Test VibeTunnel npm package on different Node.js versions
FROM node:22-slim
# Install dependencies for terminal functionality and building native modules
RUN apt-get update && apt-get install -y \
curl \
procps \
python3 \
build-essential \
libpam0g-dev \
&& rm -rf /var/lib/apt/lists/*
# Install vibetunnel globally as root
RUN npm install -g vibetunnel@latest
# Create a test user
RUN useradd -m -s /bin/bash testuser
# Switch to test user
USER testuser
WORKDIR /home/testuser
# Create a test script
RUN echo '#!/bin/bash\n\
echo "Testing VibeTunnel npm package..."\n\
echo "Node version: $(node --version)"\n\
echo "npm version: $(npm --version)"\n\
echo "VibeTunnel version:"\n\
vibetunnel --version\n\
echo ""\n\
echo "Starting VibeTunnel server..."\n\
vibetunnel --port 4021 --no-auth &\n\
SERVER_PID=$!\n\
sleep 3\n\
echo ""\n\
echo "Testing if server is running..."\n\
curl -s http://localhost:4021 > /dev/null && echo "✅ Server is responding" || echo "❌ Server not responding"\n\
echo ""\n\
echo "Stopping server..."\n\
kill $SERVER_PID\n\
' > test.sh && chmod +x test.sh
CMD ["./test.sh"]