Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/manual-release-028aa924.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'start-command': minor
---

Add SSH isolation support for remote command execution.

- Implements SSH backend for executing commands on remote servers via SSH, similar to screen/tmux/docker isolation
- Uses `--endpoint` option to specify SSH target (e.g., `--endpoint [email protected]`)
- Supports both attached (interactive) and detached (background) modes
- Includes comprehensive SSH integration tests in CI with a local SSH server
34 changes: 34 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,40 @@ jobs:
- name: Run tests
run: bun test

# SSH Integration Tests - Linux only (most reliable for SSH testing)
- name: Setup SSH server for integration tests (Linux)
if: runner.os == 'Linux'
run: |
# Install openssh-server if not present
sudo apt-get install -y openssh-server

# Start SSH service
sudo systemctl start ssh

# Generate SSH key without passphrase for testing
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -C "ci-test-key"

# Add the public key to authorized_keys for passwordless login
cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

# Configure SSH to accept localhost connections without prompts
mkdir -p ~/.ssh
cat >> ~/.ssh/config << 'EOF'
Host localhost
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
EOF
chmod 600 ~/.ssh/config

# Test SSH connectivity
ssh localhost "echo 'SSH connection successful'"

- name: Run SSH integration tests (Linux)
if: runner.os == 'Linux'
run: bun test test/ssh-integration.test.js

# Release - only runs on main after tests pass (for push events)
release:
name: Release
Expand Down
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ Issue created: https://github.com/owner/some-npm-tool/issues/42

### Process Isolation

Run commands in isolated environments using terminal multiplexers or containers:
Run commands in isolated environments using terminal multiplexers, containers, or remote servers:

```bash
# Run in tmux (attached by default)
Expand All @@ -148,6 +148,9 @@ $ --isolated screen --detached -- bun start
# Run in docker container
$ --isolated docker --image oven/bun:latest -- bun install

# Run on remote server via SSH
$ --isolated ssh --endpoint [email protected] -- npm test

# Short form with custom session name
$ -i tmux -s my-session -d bun start
```
Expand Down Expand Up @@ -192,21 +195,23 @@ This is useful for:

#### Supported Backends

| Backend | Description | Installation |
| -------- | -------------------------------------- | ---------------------------------------------------------- |
| `screen` | GNU Screen terminal multiplexer | `apt install screen` / `brew install screen` |
| `tmux` | Modern terminal multiplexer | `apt install tmux` / `brew install tmux` |
| `docker` | Container isolation (requires --image) | [Docker Installation](https://docs.docker.com/get-docker/) |
| Backend | Description | Installation |
| -------- | ---------------------------------------------- | ---------------------------------------------------------- |
| `screen` | GNU Screen terminal multiplexer | `apt install screen` / `brew install screen` |
| `tmux` | Modern terminal multiplexer | `apt install tmux` / `brew install tmux` |
| `docker` | Container isolation (requires --image) | [Docker Installation](https://docs.docker.com/get-docker/) |
| `ssh` | Remote execution via SSH (requires --endpoint) | `apt install openssh-client` / `brew install openssh` |

#### Isolation Options

| Option | Description |
| -------------------------------- | --------------------------------------------------------- |
| `--isolated, -i` | Isolation backend (screen, tmux, docker) |
| `--isolated, -i` | Isolation backend (screen, tmux, docker, ssh) |
| `--attached, -a` | Run in attached/foreground mode (default) |
| `--detached, -d` | Run in detached/background mode |
| `--session, -s` | Custom session/container name |
| `--image` | Docker image (required for docker isolation) |
| `--endpoint` | SSH endpoint (required for ssh, e.g., user@host) |
| `--isolated-user, -u [name]` | Create isolated user with same permissions (screen/tmux) |
| `--keep-user` | Keep isolated user after command completes (don't delete) |
| `--keep-alive, -k` | Keep session alive after command completes |
Expand Down
10 changes: 8 additions & 2 deletions src/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,12 @@ function printUsage() {
$ <command> [args...]

Options:
--isolated, -i <env> Run in isolated environment (screen, tmux, docker)
--isolated, -i <env> Run in isolated environment (screen, tmux, docker, ssh)
--attached, -a Run in attached mode (foreground)
--detached, -d Run in detached mode (background)
--session, -s <name> Session name for isolation
--image <image> Docker image (required for docker isolation)
--endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
--isolated-user, -u [name] Create isolated user with same permissions
--keep-user Keep isolated user after command completes
--keep-alive, -k Keep isolation environment alive after command exits
Expand All @@ -241,6 +242,7 @@ Examples:
$ --isolated tmux -- bun start
$ -i screen -d bun start
$ --isolated docker --image oven/bun:latest -- bun install
$ --isolated ssh --endpoint [email protected] -- ls -la
$ --isolated-user -- npm test # Create isolated user
$ -u myuser -- npm start # Custom username
$ -i screen --isolated-user -- npm test # Combine with process isolation
Expand Down Expand Up @@ -404,6 +406,9 @@ async function runWithIsolation(options, cmd) {
if (options.image) {
console.log(`[Isolation] Image: ${options.image}`);
}
if (options.endpoint) {
console.log(`[Isolation] Endpoint: ${options.endpoint}`);
}
if (createdUser) {
console.log(`[Isolation] User: ${createdUser} (isolated)`);
}
Expand All @@ -423,10 +428,11 @@ async function runWithIsolation(options, cmd) {
let result;

if (environment) {
// Run in isolation backend (screen, tmux, docker)
// Run in isolation backend (screen, tmux, docker, ssh)
result = await runIsolated(environment, cmd, {
session: options.session,
image: options.image,
endpoint: options.endpoint,
detached: mode === 'detached',
user: createdUser,
keepAlive: options.keepAlive,
Expand Down
36 changes: 33 additions & 3 deletions src/lib/args-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
* 2. $ [wrapper-options] command [command-options]
*
* Wrapper Options:
* --isolated, -i <backend> Run in isolated environment (screen, tmux, docker)
* --isolated, -i <backend> Run in isolated environment (screen, tmux, docker, ssh)
* --attached, -a Run in attached mode (foreground)
* --detached, -d Run in detached mode (background)
* --session, -s <name> Session name for isolation
* --image <image> Docker image (required for docker isolation)
* --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
* --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified)
* --keep-user Keep isolated user after command completes (don't delete)
* --keep-alive, -k Keep isolation environment alive after command exits
Expand All @@ -24,7 +25,7 @@ const DEBUG =
/**
* Valid isolation backends
*/
const VALID_BACKENDS = ['screen', 'tmux', 'docker'];
const VALID_BACKENDS = ['screen', 'tmux', 'docker', 'ssh'];

/**
* Parse command line arguments into wrapper options and command
Expand All @@ -33,11 +34,12 @@ const VALID_BACKENDS = ['screen', 'tmux', 'docker'];
*/
function parseArgs(args) {
const wrapperOptions = {
isolated: null, // Isolation backend: screen, tmux, docker
isolated: null, // Isolation backend: screen, tmux, docker, ssh
attached: false, // Run in attached mode
detached: false, // Run in detached mode
session: null, // Session name
image: null, // Docker image
endpoint: null, // SSH endpoint (e.g., user@host)
user: false, // Create isolated user
userName: null, // Optional custom username for isolated user
keepUser: false, // Keep isolated user after command completes (don't delete)
Expand Down Expand Up @@ -180,6 +182,22 @@ function parseOption(args, index, options) {
return 1;
}

// --endpoint (for ssh)
if (arg === '--endpoint') {
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
options.endpoint = args[index + 1];
return 2;
} else {
throw new Error(`Option ${arg} requires an endpoint argument`);
}
}

// --endpoint=<value>
if (arg.startsWith('--endpoint=')) {
options.endpoint = arg.split('=')[1];
return 1;
}

// --isolated-user or -u [optional-username] - creates isolated user with same permissions
if (arg === '--isolated-user' || arg === '-u') {
options.user = true;
Expand Down Expand Up @@ -252,6 +270,13 @@ function validateOptions(options) {
'Docker isolation requires --image option to specify the container image'
);
}

// SSH requires --endpoint
if (options.isolated === 'ssh' && !options.endpoint) {
throw new Error(
'SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)'
);
}
}

// Session name is only valid with isolation
Expand All @@ -264,6 +289,11 @@ function validateOptions(options) {
throw new Error('--image option is only valid with --isolated docker');
}

// Endpoint is only valid with ssh
if (options.endpoint && options.isolated !== 'ssh') {
throw new Error('--endpoint option is only valid with --isolated ssh');
}

// Keep-alive is only valid with isolation
if (options.keepAlive && !options.isolated) {
throw new Error('--keep-alive option is only valid with --isolated');
Expand Down
98 changes: 97 additions & 1 deletion src/lib/isolation.js
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,99 @@ function runInTmux(command, options = {}) {
}
}

/**
* Run command over SSH on a remote server
* @param {string} command - Command to execute
* @param {object} options - Options (endpoint, session, detached)
* @returns {Promise<{success: boolean, sessionName: string, message: string}>}
*/
function runInSsh(command, options = {}) {
if (!isCommandAvailable('ssh')) {
return Promise.resolve({
success: false,
sessionName: null,
message:
'ssh is not installed. Install it with: sudo apt-get install openssh-client (Debian/Ubuntu) or brew install openssh (macOS)',
});
}

if (!options.endpoint) {
return Promise.resolve({
success: false,
sessionName: null,
message:
'SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)',
});
}

const sessionName = options.session || generateSessionName('ssh');
const sshTarget = options.endpoint;

try {
if (options.detached) {
// Detached mode: Run command in background on remote server using nohup
// The command will continue running even after SSH connection closes
const remoteCommand = `nohup ${command} > /tmp/${sessionName}.log 2>&1 &`;
const sshArgs = [sshTarget, remoteCommand];

if (DEBUG) {
console.log(`[DEBUG] Running: ssh ${sshArgs.join(' ')}`);
}

const result = spawnSync('ssh', sshArgs, {
stdio: 'inherit',
});

if (result.error) {
throw result.error;
}

return Promise.resolve({
success: true,
sessionName,
message: `Command started in detached SSH session on ${sshTarget}\nSession: ${sessionName}\nView logs: ssh ${sshTarget} "tail -f /tmp/${sessionName}.log"`,
});
} else {
// Attached mode: Run command interactively over SSH
// This creates a direct SSH connection and runs the command
const sshArgs = [sshTarget, command];

if (DEBUG) {
console.log(`[DEBUG] Running: ssh ${sshArgs.join(' ')}`);
}

return new Promise((resolve) => {
const child = spawn('ssh', sshArgs, {
stdio: 'inherit',
});

child.on('exit', (code) => {
resolve({
success: code === 0,
sessionName,
message: `SSH session "${sessionName}" on ${sshTarget} exited with code ${code}`,
exitCode: code,
});
});

child.on('error', (err) => {
resolve({
success: false,
sessionName,
message: `Failed to start SSH: ${err.message}`,
});
});
});
}
} catch (err) {
return Promise.resolve({
success: false,
sessionName,
message: `Failed to run over SSH: ${err.message}`,
});
}
}

/**
* Run command in Docker container
* @param {string} command - Command to execute
Expand Down Expand Up @@ -660,7 +753,7 @@ function runInDocker(command, options = {}) {

/**
* Run command in the specified isolation backend
* @param {string} backend - Isolation backend (screen, tmux, docker)
* @param {string} backend - Isolation backend (screen, tmux, docker, ssh)
* @param {string} command - Command to execute
* @param {object} options - Options
* @returns {Promise<{success: boolean, message: string}>}
Expand All @@ -673,6 +766,8 @@ function runIsolated(backend, command, options = {}) {
return runInTmux(command, options);
case 'docker':
return runInDocker(command, options);
case 'ssh':
return runInSsh(command, options);
default:
return Promise.resolve({
success: false,
Expand Down Expand Up @@ -825,6 +920,7 @@ module.exports = {
runInScreen,
runInTmux,
runInDocker,
runInSsh,
runIsolated,
runAsIsolatedUser,
wrapCommandWithUser,
Expand Down
Loading
Loading