diff --git a/.changeset/keep-alive-option.md b/.changeset/keep-alive-option.md new file mode 100644 index 0000000..cad137e --- /dev/null +++ b/.changeset/keep-alive-option.md @@ -0,0 +1,10 @@ +--- +'start-command': minor +--- + +Add --keep-alive option for isolation environments + +- All isolation environments (screen, tmux, docker) now automatically exit after command completion by default +- New --keep-alive (-k) flag keeps the isolation environment running after command completes +- Add ARCHITECTURE.md documentation describing system design +- Update REQUIREMENTS.md with new option and auto-exit behavior documentation diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..eae5c81 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,297 @@ +# Architecture + +This document describes the architecture of the `$` command (start-command). + +## Overview + +The start-command is a CLI tool that wraps shell commands to provide automatic logging, error reporting, natural language aliases, and process isolation capabilities. + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ User Command │ +│ $ [options] command [args] │ +└──────────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ CLI Entry Point │ +│ src/bin/cli.js │ +├─────────────────────────────────────────────────────────────────────┤ +│ • Parse command line arguments │ +│ • Handle --version, --help flags │ +│ • Route to isolation or direct execution │ +└──────────────────────────────┬──────────────────────────────────────┘ + │ + ┌────────────────┴────────────────┐ + │ │ + ▼ ▼ +┌─────────────────────────┐ ┌─────────────────────────────────────┐ +│ Direct Execution │ │ Isolated Execution │ +│ (no --isolated) │ │ (--isolated screen/tmux/docker) │ +├─────────────────────────┤ ├─────────────────────────────────────┤ +│ • Spawn shell process │ │ src/lib/isolation.js │ +│ • Capture stdout/stderr │ │ • runInScreen() │ +│ • Log to temp file │ │ • runInTmux() │ +│ • Report failures │ │ • runInDocker() │ +└─────────────────────────┘ └─────────────────────────────────────┘ +``` + +## Core Modules + +### 1. CLI Entry Point (`src/bin/cli.js`) + +The main entry point that: + +- Parses command line arguments using `args-parser.js` +- Processes command substitutions using `substitution.js` +- Routes execution to direct mode or isolation mode +- Handles logging and error reporting + +### 2. Argument Parser (`src/lib/args-parser.js`) + +Parses wrapper options and extracts the command to execute: + +```javascript +{ + isolated: null, // 'screen' | 'tmux' | 'docker' | null + attached: false, // Run in attached/foreground mode + detached: false, // Run in detached/background mode + session: null, // Custom session name + image: null, // Docker image name + keepAlive: false, // Keep environment alive after command exits +} +``` + +### 3. Substitution Engine (`src/lib/substitution.js`) + +Provides natural language command aliases: + +- Loads patterns from `substitutions.lino` +- Matches user input against patterns with variables +- Returns substituted command or original if no match + +### 4. Isolation Module (`src/lib/isolation.js`) + +Handles process isolation in terminal multiplexers and containers: + +``` +┌────────────────────────────────────────────────────────────┐ +│ runIsolated() │ +│ (dispatcher function) │ +└───────────────┬───────────────┬───────────────┬────────────┘ + │ │ │ + ┌───────▼───────┐ ┌─────▼─────┐ ┌───────▼───────┐ + │ runInScreen │ │runInTmux │ │ runInDocker │ + │ │ │ │ │ │ + │ GNU Screen │ │ tmux │ │ Docker │ + │ multiplexer │ │ terminal │ │ containers │ + └───────────────┘ └───────────┘ └───────────────┘ +``` + +## Isolation Architecture + +### Execution Modes + +| Mode | Description | Default Behavior | +| ------------ | ------------------------------------------- | ------------------------------ | +| Attached | Command runs in foreground, interactive | Session exits after completion | +| Detached | Command runs in background | Session exits after completion | +| + Keep-Alive | Session stays alive after command completes | Requires `--keep-alive` flag | + +### Auto-Exit Behavior + +By default, all isolation environments automatically exit after command completion: + +``` +┌───────────────────────────────────────────────────────────────┐ +│ Default (keepAlive=false) │ +├───────────────────────────────────────────────────────────────┤ +│ 1. Start isolation environment │ +│ 2. Execute command │ +│ 3. Capture output (if attached mode) │ +│ 4. Environment exits automatically │ +│ 5. Resources freed │ +└───────────────────────────────────────────────────────────────┘ + +┌───────────────────────────────────────────────────────────────┐ +│ With --keep-alive │ +├───────────────────────────────────────────────────────────────┤ +│ 1. Start isolation environment │ +│ 2. Execute command │ +│ 3. Command completes │ +│ 4. Shell stays running in session │ +│ 5. User can reattach and interact │ +└───────────────────────────────────────────────────────────────┘ +``` + +### Screen Isolation + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Screen Execution Flow │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Attached Mode: │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Uses detached mode with log capture internally │ │ +│ │ • Start: screen -dmS -L -Logfile │ │ +│ │ • Poll for session completion │ │ +│ │ • Read and display captured output │ │ +│ │ • Clean up log file │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ Detached Mode: │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ • Without keep-alive: screen -dmS sh -c cmd │ │ +│ │ • With keep-alive: screen -dmS sh -c "cmd; sh"│ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### tmux Isolation + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ tmux Execution Flow │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Attached Mode: │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ • tmux new-session -s │ │ +│ │ • Interactive, exits when command completes │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ Detached Mode: │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ • Without keep-alive: tmux new-session -d -s │ │ +│ │ • With keep-alive: command followed by shell exec │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Docker Isolation + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Docker Execution Flow │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Attached Mode: │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ • docker run -it --rm --name sh -c cmd │ │ +│ │ • Interactive, container auto-removed on exit │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ Detached Mode: │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ • Without keep-alive: docker run -d --name ... │ │ +│ │ • With keep-alive: command followed by shell exec │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Logging Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Logging Flow │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │ +│ │ Command │───▶│ Capture │───▶│ Write to │ │ +│ │ Execution │ │ stdout/stderr│ │ Temp Log File │ │ +│ └─────────────┘ └──────────────┘ └───────────────────┘ │ +│ │ +│ Log File Format: │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ === Start Command Log === │ │ +│ │ Timestamp: 2024-01-15 10:30:45 │ │ +│ │ Command: │ │ +│ │ Shell: /bin/bash │ │ +│ │ Platform: linux │ │ +│ │ ================================================== │ │ +│ │ │ │ +│ │ ================================================== │ │ +│ │ Finished: 2024-01-15 10:30:46 │ │ +│ │ Exit Code: 0 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## File Structure + +``` +start-command/ +├── src/ +│ ├── bin/ +│ │ └── cli.js # Main entry point +│ └── lib/ +│ ├── args-parser.js # Argument parsing +│ ├── isolation.js # Isolation backends +│ ├── substitution.js # Command aliases +│ └── substitutions.lino # Alias patterns +├── test/ +│ ├── cli.test.js # CLI tests +│ ├── isolation.test.js # Isolation tests +│ ├── args-parser.test.js # Parser tests +│ └── substitution.test.js # Substitution tests +├── docs/ +│ ├── PIPES.md # Piping documentation +│ └── USAGE.md # Usage examples +├── experiments/ # Experimental scripts +├── REQUIREMENTS.md # Requirements specification +├── ARCHITECTURE.md # This file +└── README.md # Project overview +``` + +## Design Decisions + +### 1. Auto-Exit by Default + +All isolation environments exit automatically after command completion to: + +- Prevent resource leaks from orphaned sessions +- Ensure consistent behavior across backends +- Match user expectations for command execution + +### 2. Log Capture in Attached Screen Mode + +Screen's attached mode uses internal detached mode with log capture because: + +- Direct attached mode loses output for quick commands +- Screen's virtual terminal is destroyed before output is visible +- Log capture ensures reliable output preservation + +### 3. Keep-Alive as Opt-In + +The `--keep-alive` flag is disabled by default because: + +- Most use cases don't require persistent sessions +- Prevents accidental resource consumption +- Explicit opt-in for advanced workflows + +### 4. Uniform Backend Interface + +All isolation backends share a consistent interface: + +```javascript +async function runInBackend(command, options) { + return { + success: boolean, + sessionName: string, + message: string, + exitCode?: number, + output?: string + }; +} +``` + +This enables: + +- Easy addition of new backends +- Consistent error handling +- Unified logging format diff --git a/README.md b/README.md index a7820a0..719b0d1 100644 --- a/README.md +++ b/README.md @@ -162,16 +162,37 @@ $ -i tmux -s my-session -d bun start #### Isolation Options -| Option | Description | -| ---------------- | -------------------------------------------- | -| `--isolated, -i` | Isolation backend (screen, tmux, docker) | -| `--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) | +| Option | Description | +| -------------------------------- | ----------------------------------------------------- | +| `--isolated, -i` | Isolation backend (screen, tmux, docker) | +| `--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) | +| `--keep-alive, -k` | Keep session alive after command completes | +| `--auto-remove-docker-container` | Auto-remove docker container after exit (docker only) | **Note:** Using both `--attached` and `--detached` together will result in an error - you must choose one mode. +#### Auto-Exit Behavior + +By default, all isolation environments (screen, tmux, docker) automatically exit after the target command completes. This ensures resources are freed immediately and provides uniform behavior across all backends. + +Use `--keep-alive` (`-k`) to keep the session running after command completion: + +```bash +# Default: session exits after command completes +$ -i screen -d -- echo "hello" +# Session will exit automatically after command completes. + +# With --keep-alive: session stays running for interaction +$ -i screen -d -k -- echo "hello" +# Session will stay alive after command completes. +# You can reattach with: screen -r +``` + +For Docker containers, by default the container filesystem is preserved (appears in `docker ps -a`) so you can re-enter it later. Use `--auto-remove-docker-container` to remove the container immediately after exit. + ### Graceful Degradation The tool works in any environment: diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index df4882d..e18bd70 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -142,6 +142,8 @@ Support two patterns for passing wrapper options: - `--detached, -d`: Run in detached/background mode - `--session, -s `: Custom session name - `--image `: Docker image (required for docker backend) +- `--keep-alive, -k`: Keep isolation environment alive after command exits (disabled by default) +- `--auto-remove-docker-container`: Automatically remove docker container after exit (disabled by default, only valid with --isolated docker) #### 6.3 Supported Backends @@ -155,7 +157,30 @@ Support two patterns for passing wrapper options: - **Detached mode**: Command runs in background, session info displayed for reattachment - **Conflict handling**: If both --attached and --detached are specified, show error asking user to choose one -#### 6.5 Graceful Degradation +#### 6.5 Auto-Exit Behavior + +By default, all isolation environments (screen, tmux, docker) automatically exit after the target command completes execution. This ensures: + +- Resources are freed immediately after command execution +- No orphaned sessions/containers remain running +- Uniform behavior across all isolation backends +- Command output is still captured and logged before exit + +The `--keep-alive` flag can be used to override this behavior and keep the isolation environment running after command completion, useful for debugging or interactive workflows. + +**Docker Container Filesystem Preservation:** +By default, when using Docker isolation, the container filesystem is preserved after the container exits. This allows you to re-enter the container and access any files created during command execution, which is useful for retrieving additional output files or debugging. The container appears in `docker ps -a` in an "exited" state but is not removed. + +The `--auto-remove-docker-container` flag enables automatic removal of the container after exit, which is useful when you don't need to preserve the container filesystem and want to clean up resources completely. When this flag is enabled: + +- The container is removed immediately after exiting (using docker's `--rm` flag) +- The container will not appear in `docker ps -a` after command completion +- You cannot re-enter the container to access files +- Resources are freed more aggressively + +Note: `--auto-remove-docker-container` is only valid with `--isolated docker` and is independent of the `--keep-alive` flag. + +#### 6.6 Graceful Degradation - If isolation backend is not installed, show informative error with installation instructions - If session creation fails, report error with details diff --git a/package.json b/package.json index 42142e6..d9c7163 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "start-command", - "version": "0.7.6", + "version": "0.8.0", "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub", "main": "index.js", "bin": { diff --git a/src/bin/cli.js b/src/bin/cli.js index e9a3f43..dcc1a55 100755 --- a/src/bin/cli.js +++ b/src/bin/cli.js @@ -228,6 +228,12 @@ function printUsage() { console.log( ' --image Docker image (required for docker isolation)' ); + console.log( + ' --keep-alive, -k Keep isolation environment alive after command exits' + ); + console.log( + ' --auto-remove-docker-container Automatically remove docker container after exit (disabled by default)' + ); console.log(' --version, -v Show version information'); console.log(''); console.log('Examples:'); @@ -360,6 +366,8 @@ async function runWithIsolation(options, cmd) { session: options.session, image: options.image, detached: mode === 'detached', + keepAlive: options.keepAlive, + autoRemoveDockerContainer: options.autoRemoveDockerContainer, }); // Get exit code diff --git a/src/lib/args-parser.js b/src/lib/args-parser.js index 8d69d59..3bf9ff7 100644 --- a/src/lib/args-parser.js +++ b/src/lib/args-parser.js @@ -6,11 +6,13 @@ * 2. $ [wrapper-options] command [command-options] * * Wrapper Options: - * --isolated, -i Run in isolated environment (screen, tmux, docker) - * --attached, -a Run in attached mode (foreground) - * --detached, -d Run in detached mode (background) - * --session, -s Session name for isolation - * --image Docker image (required for docker isolation) + * --isolated, -i Run in isolated environment (screen, tmux, docker) + * --attached, -a Run in attached mode (foreground) + * --detached, -d Run in detached mode (background) + * --session, -s Session name for isolation + * --image Docker image (required for docker isolation) + * --keep-alive, -k Keep isolation environment alive after command exits + * --auto-remove-docker-container Automatically remove docker container after exit (disabled by default) */ // Debug mode from environment @@ -34,6 +36,8 @@ function parseArgs(args) { detached: false, // Run in detached mode session: null, // Session name image: null, // Docker image + keepAlive: false, // Keep environment alive after command exits + autoRemoveDockerContainer: false, // Auto-remove docker container after exit }; let commandArgs = []; @@ -171,6 +175,18 @@ function parseOption(args, index, options) { return 1; } + // --keep-alive or -k + if (arg === '--keep-alive' || arg === '-k') { + options.keepAlive = true; + return 1; + } + + // --auto-remove-docker-container + if (arg === '--auto-remove-docker-container') { + options.autoRemoveDockerContainer = true; + return 1; + } + // Not a recognized wrapper option return 0; } @@ -213,6 +229,18 @@ function validateOptions(options) { if (options.image && options.isolated !== 'docker') { throw new Error('--image option is only valid with --isolated docker'); } + + // Keep-alive is only valid with isolation + if (options.keepAlive && !options.isolated) { + throw new Error('--keep-alive option is only valid with --isolated'); + } + + // Auto-remove-docker-container is only valid with docker isolation + if (options.autoRemoveDockerContainer && options.isolated !== 'docker') { + throw new Error( + '--auto-remove-docker-container option is only valid with --isolated docker' + ); + } } /** diff --git a/src/lib/isolation.js b/src/lib/isolation.js index 05a6bd7..70e1f60 100644 --- a/src/lib/isolation.js +++ b/src/lib/isolation.js @@ -299,7 +299,7 @@ function runScreenWithLogCapture(command, sessionName, shellInfo) { /** * Run command in GNU Screen * @param {string} command - Command to execute - * @param {object} options - Options (session, detached) + * @param {object} options - Options (session, detached, keepAlive) * @returns {Promise<{success: boolean, sessionName: string, message: string}>} */ function runInScreen(command, options = {}) { @@ -319,10 +319,28 @@ function runInScreen(command, options = {}) { try { if (options.detached) { // Detached mode: screen -dmS -c '' - const screenArgs = ['-dmS', sessionName, shell, shellArg, command]; + // By default (keepAlive=false), the session will exit after command completes + // With keepAlive=true, we start a shell that runs the command but stays alive + let effectiveCommand = command; + + if (options.keepAlive) { + // With keep-alive: run command, then keep shell open + // Use exec to replace the shell, but first run command + effectiveCommand = `${command}; exec ${shell}`; + } + // Without keep-alive: command runs and session exits naturally when done + + const screenArgs = [ + '-dmS', + sessionName, + shell, + shellArg, + effectiveCommand, + ]; if (DEBUG) { console.log(`[DEBUG] Running: screen ${screenArgs.join(' ')}`); + console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`); } // Use spawnSync with array arguments to avoid shell quoting issues @@ -336,10 +354,18 @@ function runInScreen(command, options = {}) { throw result.error; } + let message = `Command started in detached screen session: ${sessionName}`; + if (options.keepAlive) { + message += `\nSession will stay alive after command completes.`; + } else { + message += `\nSession will exit automatically after command completes.`; + } + message += `\nReattach with: screen -r ${sessionName}`; + return Promise.resolve({ success: true, sessionName, - message: `Command started in detached screen session: ${sessionName}\nReattach with: screen -r ${sessionName}`, + message, }); } else { // Attached mode: always use detached mode with log capture @@ -371,7 +397,7 @@ function runInScreen(command, options = {}) { /** * Run command in tmux * @param {string} command - Command to execute - * @param {object} options - Options (session, detached) + * @param {object} options - Options (session, detached, keepAlive) * @returns {Promise<{success: boolean, sessionName: string, message: string}>} */ function runInTmux(command, options = {}) { @@ -385,24 +411,48 @@ function runInTmux(command, options = {}) { } const sessionName = options.session || generateSessionName('tmux'); + const shellInfo = getShell(); + const { shell } = shellInfo; try { if (options.detached) { // Detached mode: tmux new-session -d -s '' + // By default (keepAlive=false), the session will exit after command completes + // With keepAlive=true, we keep the shell alive after the command + let effectiveCommand = command; + + if (options.keepAlive) { + // With keep-alive: run command, then keep shell open + effectiveCommand = `${command}; exec ${shell}`; + } + // Without keep-alive: command runs and session exits naturally when done + if (DEBUG) { console.log( - `[DEBUG] Running: tmux new-session -d -s "${sessionName}" "${command}"` + `[DEBUG] Running: tmux new-session -d -s "${sessionName}" "${effectiveCommand}"` ); + console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`); } - execSync(`tmux new-session -d -s "${sessionName}" "${command}"`, { - stdio: 'inherit', - }); + execSync( + `tmux new-session -d -s "${sessionName}" "${effectiveCommand}"`, + { + stdio: 'inherit', + } + ); + + let message = `Command started in detached tmux session: ${sessionName}`; + if (options.keepAlive) { + message += `\nSession will stay alive after command completes.`; + } else { + message += `\nSession will exit automatically after command completes.`; + } + message += `\nReattach with: tmux attach -t ${sessionName}`; return Promise.resolve({ success: true, sessionName, - message: `Command started in detached tmux session: ${sessionName}\nReattach with: tmux attach -t ${sessionName}`, + message, }); } else { // Attached mode: tmux new-session -s '' @@ -451,7 +501,7 @@ function runInTmux(command, options = {}) { /** * Run command in Docker container * @param {string} command - Command to execute - * @param {object} options - Options (image, session/name, detached) + * @param {object} options - Options (image, session/name, detached, keepAlive, autoRemoveDockerContainer) * @returns {Promise<{success: boolean, containerName: string, message: string}>} */ function runInDocker(command, options = {}) { @@ -477,6 +527,16 @@ function runInDocker(command, options = {}) { try { if (options.detached) { // Detached mode: docker run -d --name -c '' + // By default (keepAlive=false), the container exits after command completes + // With keepAlive=true, we keep the container running with a shell + let effectiveCommand = command; + + if (options.keepAlive) { + // With keep-alive: run command, then keep shell alive + effectiveCommand = `${command}; exec /bin/sh`; + } + // Without keep-alive: container exits naturally when command completes + const dockerArgs = [ 'run', '-d', @@ -485,22 +545,47 @@ function runInDocker(command, options = {}) { options.image, '/bin/sh', '-c', - command, + effectiveCommand, ]; + // Add --rm flag if autoRemoveDockerContainer is true + // Note: --rm must come before the image name + if (options.autoRemoveDockerContainer) { + dockerArgs.splice(2, 0, '--rm'); + } + if (DEBUG) { console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`); + console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`); + console.log( + `[DEBUG] autoRemoveDockerContainer: ${options.autoRemoveDockerContainer || false}` + ); } const containerId = execSync(`docker ${dockerArgs.join(' ')}`, { encoding: 'utf8', }).trim(); + let message = `Command started in detached docker container: ${containerName}`; + message += `\nContainer ID: ${containerId.substring(0, 12)}`; + if (options.keepAlive) { + message += `\nContainer will stay alive after command completes.`; + } else { + message += `\nContainer will exit automatically after command completes.`; + } + if (options.autoRemoveDockerContainer) { + message += `\nContainer will be automatically removed after exit.`; + } else { + message += `\nContainer filesystem will be preserved after exit.`; + } + message += `\nAttach with: docker attach ${containerName}`; + message += `\nView logs: docker logs ${containerName}`; + return Promise.resolve({ success: true, containerName, containerId, - message: `Command started in detached docker container: ${containerName}\nContainer ID: ${containerId.substring(0, 12)}\nAttach with: docker attach ${containerName}\nView logs: docker logs ${containerName}`, + message, }); } else { // Attached mode: docker run -it --name -c '' diff --git a/test/args-parser.test.js b/test/args-parser.test.js index 94f1baa..617eea8 100644 --- a/test/args-parser.test.js +++ b/test/args-parser.test.js @@ -219,6 +219,136 @@ describe('parseArgs', () => { }); }); + describe('keep-alive option', () => { + it('should parse --keep-alive flag', () => { + const result = parseArgs([ + '--isolated', + 'tmux', + '--keep-alive', + '--', + 'npm', + 'test', + ]); + assert.strictEqual(result.wrapperOptions.keepAlive, true); + }); + + it('should parse -k shorthand', () => { + const result = parseArgs(['-i', 'screen', '-k', '--', 'npm', 'start']); + assert.strictEqual(result.wrapperOptions.keepAlive, true); + }); + + it('should default keepAlive to false', () => { + const result = parseArgs(['-i', 'tmux', '--', 'npm', 'test']); + assert.strictEqual(result.wrapperOptions.keepAlive, false); + }); + + it('should throw error for keep-alive without isolation', () => { + assert.throws(() => { + parseArgs(['--keep-alive', '--', 'npm', 'test']); + }, /--keep-alive option is only valid with --isolated/); + }); + + it('should work with detached mode', () => { + const result = parseArgs([ + '-i', + 'screen', + '-d', + '-k', + '--', + 'npm', + 'start', + ]); + assert.strictEqual(result.wrapperOptions.isolated, 'screen'); + assert.strictEqual(result.wrapperOptions.detached, true); + assert.strictEqual(result.wrapperOptions.keepAlive, true); + }); + + it('should work with docker', () => { + const result = parseArgs([ + '-i', + 'docker', + '--image', + 'node:20', + '-k', + '--', + 'npm', + 'test', + ]); + assert.strictEqual(result.wrapperOptions.isolated, 'docker'); + assert.strictEqual(result.wrapperOptions.image, 'node:20'); + assert.strictEqual(result.wrapperOptions.keepAlive, true); + }); + }); + + describe('auto-remove-docker-container option', () => { + it('should parse --auto-remove-docker-container flag', () => { + const result = parseArgs([ + '--isolated', + 'docker', + '--image', + 'alpine', + '--auto-remove-docker-container', + '--', + 'npm', + 'test', + ]); + assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, true); + }); + + it('should default autoRemoveDockerContainer to false', () => { + const result = parseArgs([ + '-i', + 'docker', + '--image', + 'alpine', + '--', + 'npm', + 'test', + ]); + assert.strictEqual( + result.wrapperOptions.autoRemoveDockerContainer, + false + ); + }); + + it('should throw error for auto-remove-docker-container without docker isolation', () => { + assert.throws(() => { + parseArgs([ + '-i', + 'tmux', + '--auto-remove-docker-container', + '--', + 'npm', + 'test', + ]); + }, /--auto-remove-docker-container option is only valid with --isolated docker/); + }); + + it('should throw error for auto-remove-docker-container without isolation', () => { + assert.throws(() => { + parseArgs(['--auto-remove-docker-container', '--', 'npm', 'test']); + }, /--auto-remove-docker-container option is only valid with --isolated docker/); + }); + + it('should work with keep-alive and auto-remove-docker-container', () => { + const result = parseArgs([ + '-i', + 'docker', + '--image', + 'node:20', + '-k', + '--auto-remove-docker-container', + '--', + 'npm', + 'test', + ]); + assert.strictEqual(result.wrapperOptions.isolated, 'docker'); + assert.strictEqual(result.wrapperOptions.image, 'node:20'); + assert.strictEqual(result.wrapperOptions.keepAlive, true); + assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, true); + }); + }); + describe('command without separator', () => { it('should parse command after options without separator', () => { const result = parseArgs(['-i', 'tmux', '-d', 'npm', 'start']); diff --git a/test/docker-autoremove.test.js b/test/docker-autoremove.test.js new file mode 100644 index 0000000..ad2a0cf --- /dev/null +++ b/test/docker-autoremove.test.js @@ -0,0 +1,169 @@ +#!/usr/bin/env bun +/** + * Tests for Docker auto-remove container feature + */ + +const { describe, it } = require('node:test'); +const assert = require('assert'); +const { isCommandAvailable } = require('../src/lib/isolation'); +const { runInDocker } = require('../src/lib/isolation'); +const { execSync } = require('child_process'); + +// Helper to wait for a condition with timeout +async function waitFor(conditionFn, timeout = 5000, interval = 100) { + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + if (conditionFn()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + return false; +} + +// Helper function to check if docker daemon is running +function isDockerRunning() { + if (!isCommandAvailable('docker')) { + return false; + } + try { + execSync('docker info', { stdio: 'ignore', timeout: 5000 }); + return true; + } catch { + return false; + } +} + +describe('Docker Auto-Remove Container Feature', () => { + // These tests verify the --auto-remove-docker-container option + // which automatically removes the container after exit (disabled by default) + + describe('auto-remove enabled', () => { + it('should automatically remove container when autoRemoveDockerContainer is true', async () => { + if (!isDockerRunning()) { + console.log(' Skipping: docker not available or daemon not running'); + return; + } + + const containerName = `test-autoremove-${Date.now()}`; + + // Run command with autoRemoveDockerContainer enabled + const result = await runInDocker('echo "test" && sleep 0.5', { + image: 'alpine:latest', + session: containerName, + detached: true, + keepAlive: false, + autoRemoveDockerContainer: true, + }); + + assert.strictEqual(result.success, true); + assert.ok( + result.message.includes('automatically removed'), + 'Message should indicate auto-removal' + ); + + // Wait for container to finish and be removed + const containerRemoved = await waitFor(() => { + try { + execSync(`docker inspect -f '{{.State.Status}}' ${containerName}`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return false; // Container still exists + } catch { + return true; // Container does not exist (removed) + } + }, 10000); + + assert.ok( + containerRemoved, + 'Container should be automatically removed after exit with --auto-remove-docker-container' + ); + + // Double-check with docker ps -a that container is completely removed + try { + const allContainers = execSync('docker ps -a', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + assert.ok( + !allContainers.includes(containerName), + 'Container should NOT appear in docker ps -a (completely removed)' + ); + console.log( + ' ✓ Docker container auto-removed after exit (filesystem not preserved)' + ); + } catch (err) { + assert.fail(`Failed to verify container removal: ${err.message}`); + } + + // No cleanup needed - container should already be removed + }); + }); + + describe('auto-remove disabled (default)', () => { + it('should preserve container filesystem by default (without autoRemoveDockerContainer)', async () => { + if (!isDockerRunning()) { + console.log(' Skipping: docker not available or daemon not running'); + return; + } + + const containerName = `test-preserve-${Date.now()}`; + + // Run command without autoRemoveDockerContainer + const result = await runInDocker('echo "test" && sleep 0.1', { + image: 'alpine:latest', + session: containerName, + detached: true, + keepAlive: false, + autoRemoveDockerContainer: false, + }); + + assert.strictEqual(result.success, true); + assert.ok( + result.message.includes('filesystem will be preserved'), + 'Message should indicate filesystem preservation' + ); + + // Wait for container to exit + await waitFor(() => { + try { + const status = execSync( + `docker inspect -f '{{.State.Status}}' ${containerName}`, + { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + } + ).trim(); + return status === 'exited'; + } catch { + return false; + } + }, 10000); + + // Container should still exist (in exited state) + try { + const allContainers = execSync('docker ps -a', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + assert.ok( + allContainers.includes(containerName), + 'Container should appear in docker ps -a (filesystem preserved)' + ); + console.log( + ' ✓ Docker container filesystem preserved by default (can be re-entered)' + ); + } catch (err) { + assert.fail(`Failed to verify container preservation: ${err.message}`); + } + + // Clean up + try { + execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' }); + } catch { + // Ignore cleanup errors + } + }); + }); +}); diff --git a/test/isolation-cleanup.test.js b/test/isolation-cleanup.test.js new file mode 100644 index 0000000..c8a0376 --- /dev/null +++ b/test/isolation-cleanup.test.js @@ -0,0 +1,377 @@ +#!/usr/bin/env bun +/** + * Resource cleanup tests for isolation module + * Tests that verify isolation environments release resources after command execution + */ + +const { describe, it } = require('node:test'); +const assert = require('assert'); +const { isCommandAvailable } = require('../src/lib/isolation'); + +describe('Isolation Resource Cleanup Verification', () => { + // These tests verify that isolation environments release resources after command execution + // This ensures uniform behavior across all backends where resources are freed by default + + const { + runInScreen, + runInTmux, + runInDocker, + } = require('../src/lib/isolation'); + const { execSync } = require('child_process'); + + // Helper to wait for a condition with timeout + async function waitFor(conditionFn, timeout = 5000, interval = 100) { + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + if (conditionFn()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + return false; + } + + describe('screen resource cleanup', () => { + it('should not list screen session after command completes (auto-exit by default)', async () => { + if (!isCommandAvailable('screen')) { + console.log(' Skipping: screen not installed'); + return; + } + + const sessionName = `test-cleanup-screen-${Date.now()}`; + + // Run a quick command in detached mode + const result = await runInScreen('echo "test" && sleep 0.1', { + session: sessionName, + detached: true, + keepAlive: false, + }); + + assert.strictEqual(result.success, true); + + // Wait for the session to exit naturally (should happen quickly) + const sessionGone = await waitFor(() => { + try { + const sessions = execSync('screen -ls', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return !sessions.includes(sessionName); + } catch { + // screen -ls returns non-zero when no sessions exist + return true; + } + }, 10000); + + assert.ok( + sessionGone, + 'Screen session should not be in the list after command completes (auto-exit by default)' + ); + + // Double-check with screen -ls to verify no active session + try { + const sessions = execSync('screen -ls', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + assert.ok( + !sessions.includes(sessionName), + 'Session should not appear in screen -ls output' + ); + console.log(' ✓ Screen session auto-exited and resources released'); + } catch { + // screen -ls returns non-zero when no sessions - this is expected + console.log( + ' ✓ Screen session auto-exited (no sessions found in screen -ls)' + ); + } + }); + + it('should keep screen session alive when keepAlive is true', async () => { + if (!isCommandAvailable('screen')) { + console.log(' Skipping: screen not installed'); + return; + } + + const sessionName = `test-keepalive-screen-${Date.now()}`; + + // Run command with keepAlive enabled + const result = await runInScreen('echo "test"', { + session: sessionName, + detached: true, + keepAlive: true, + }); + + assert.strictEqual(result.success, true); + + // Wait a bit for the command to complete + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Session should still exist + try { + const sessions = execSync('screen -ls', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + assert.ok( + sessions.includes(sessionName), + 'Session should still be alive with keepAlive=true' + ); + console.log( + ' ✓ Screen session kept alive as expected with --keep-alive' + ); + } catch { + assert.fail( + 'screen -ls should show the session when keepAlive is true' + ); + } + + // Clean up + try { + execSync(`screen -S ${sessionName} -X quit`, { stdio: 'ignore' }); + } catch { + // Ignore cleanup errors + } + }); + }); + + describe('tmux resource cleanup', () => { + it('should not list tmux session after command completes (auto-exit by default)', async () => { + if (!isCommandAvailable('tmux')) { + console.log(' Skipping: tmux not installed'); + return; + } + + const sessionName = `test-cleanup-tmux-${Date.now()}`; + + // Run a quick command in detached mode + const result = await runInTmux('echo "test" && sleep 0.1', { + session: sessionName, + detached: true, + keepAlive: false, + }); + + assert.strictEqual(result.success, true); + + // Wait for the session to exit naturally + const sessionGone = await waitFor(() => { + try { + const sessions = execSync('tmux ls', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return !sessions.includes(sessionName); + } catch { + // tmux ls returns non-zero when no sessions exist + return true; + } + }, 10000); + + assert.ok( + sessionGone, + 'Tmux session should not be in the list after command completes (auto-exit by default)' + ); + + // Double-check with tmux ls + try { + const sessions = execSync('tmux ls', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + assert.ok( + !sessions.includes(sessionName), + 'Session should not appear in tmux ls output' + ); + console.log(' ✓ Tmux session auto-exited and resources released'); + } catch { + // tmux ls returns non-zero when no sessions - this is expected + console.log( + ' ✓ Tmux session auto-exited (no sessions found in tmux ls)' + ); + } + }); + + it('should keep tmux session alive when keepAlive is true', async () => { + if (!isCommandAvailable('tmux')) { + console.log(' Skipping: tmux not installed'); + return; + } + + const sessionName = `test-keepalive-tmux-${Date.now()}`; + + // Run command with keepAlive enabled + const result = await runInTmux('echo "test"', { + session: sessionName, + detached: true, + keepAlive: true, + }); + + assert.strictEqual(result.success, true); + + // Wait a bit for the command to complete + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Session should still exist + try { + const sessions = execSync('tmux ls', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + assert.ok( + sessions.includes(sessionName), + 'Session should still be alive with keepAlive=true' + ); + console.log( + ' ✓ Tmux session kept alive as expected with --keep-alive' + ); + } catch { + assert.fail('tmux ls should show the session when keepAlive is true'); + } + + // Clean up + try { + execSync(`tmux kill-session -t ${sessionName}`, { stdio: 'ignore' }); + } catch { + // Ignore cleanup errors + } + }); + }); + + describe('docker resource cleanup', () => { + // Helper function to check if docker daemon is running + function isDockerRunning() { + if (!isCommandAvailable('docker')) { + return false; + } + try { + execSync('docker info', { stdio: 'ignore', timeout: 5000 }); + return true; + } catch { + return false; + } + } + + it('should show docker container as exited after command completes (auto-exit by default)', async () => { + if (!isDockerRunning()) { + console.log(' Skipping: docker not available or daemon not running'); + return; + } + + const containerName = `test-cleanup-docker-${Date.now()}`; + + // Run a quick command in detached mode + const result = await runInDocker('echo "test" && sleep 0.1', { + image: 'alpine:latest', + session: containerName, + detached: true, + keepAlive: false, + }); + + assert.strictEqual(result.success, true); + + // Wait for the container to exit + const containerExited = await waitFor(() => { + try { + const status = execSync( + `docker inspect -f '{{.State.Status}}' ${containerName}`, + { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + } + ).trim(); + return status === 'exited'; + } catch { + return false; + } + }, 10000); + + assert.ok( + containerExited, + 'Docker container should be in exited state after command completes (auto-exit by default)' + ); + + // Verify with docker ps -a that container is exited (not running) + try { + const allContainers = execSync('docker ps -a', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + assert.ok( + allContainers.includes(containerName), + 'Container should appear in docker ps -a' + ); + + const runningContainers = execSync('docker ps', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + assert.ok( + !runningContainers.includes(containerName), + 'Container should NOT appear in docker ps (not running)' + ); + console.log( + ' ✓ Docker container auto-exited and stopped (resources released, filesystem preserved)' + ); + } catch (err) { + assert.fail(`Failed to verify container status: ${err.message}`); + } + + // Clean up + try { + execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' }); + } catch { + // Ignore cleanup errors + } + }); + + it('should keep docker container running when keepAlive is true', async () => { + if (!isDockerRunning()) { + console.log(' Skipping: docker not available or daemon not running'); + return; + } + + const containerName = `test-keepalive-docker-${Date.now()}`; + + // Run command with keepAlive enabled + const result = await runInDocker('echo "test"', { + image: 'alpine:latest', + session: containerName, + detached: true, + keepAlive: true, + }); + + assert.strictEqual(result.success, true); + + // Wait a bit for the command to complete + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Container should still be running + try { + const status = execSync( + `docker inspect -f '{{.State.Status}}' ${containerName}`, + { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + } + ).trim(); + assert.strictEqual( + status, + 'running', + 'Container should still be running with keepAlive=true' + ); + console.log( + ' ✓ Docker container kept running as expected with --keep-alive' + ); + } catch (err) { + assert.fail(`Failed to verify container is running: ${err.message}`); + } + + // Clean up + try { + execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' }); + } catch { + // Ignore cleanup errors + } + }); + }); +}); diff --git a/test/isolation.test.js b/test/isolation.test.js index 43b2f2d..cccbbbd 100644 --- a/test/isolation.test.js +++ b/test/isolation.test.js @@ -243,6 +243,206 @@ describe('Isolation Runner Error Handling', () => { }); }); +describe('Isolation Keep-Alive Behavior', () => { + // Tests for the --keep-alive option behavior + // These test the message output and options handling + + const { + runInScreen, + runInTmux, + runInDocker, + } = require('../src/lib/isolation'); + const { execSync } = require('child_process'); + + describe('runInScreen keep-alive messages', () => { + it('should include auto-exit message by default in detached mode', async () => { + if (!isCommandAvailable('screen')) { + console.log(' Skipping: screen not installed'); + return; + } + + const result = await runInScreen('echo test', { + session: `test-autoexit-${Date.now()}`, + detached: true, + keepAlive: false, + }); + + assert.strictEqual(result.success, true); + assert.ok( + result.message.includes('exit automatically'), + 'Message should indicate auto-exit behavior' + ); + + // Clean up + try { + execSync(`screen -S ${result.sessionName} -X quit`, { + stdio: 'ignore', + }); + } catch { + // Session may have already exited + } + }); + + it('should include keep-alive message when keepAlive is true', async () => { + if (!isCommandAvailable('screen')) { + console.log(' Skipping: screen not installed'); + return; + } + + const result = await runInScreen('echo test', { + session: `test-keepalive-${Date.now()}`, + detached: true, + keepAlive: true, + }); + + assert.strictEqual(result.success, true); + assert.ok( + result.message.includes('stay alive'), + 'Message should indicate keep-alive behavior' + ); + + // Clean up + try { + execSync(`screen -S ${result.sessionName} -X quit`, { + stdio: 'ignore', + }); + } catch { + // Ignore cleanup errors + } + }); + }); + + describe('runInTmux keep-alive messages', () => { + it('should include auto-exit message by default in detached mode', async () => { + if (!isCommandAvailable('tmux')) { + console.log(' Skipping: tmux not installed'); + return; + } + + const result = await runInTmux('echo test', { + session: `test-autoexit-${Date.now()}`, + detached: true, + keepAlive: false, + }); + + assert.strictEqual(result.success, true); + assert.ok( + result.message.includes('exit automatically'), + 'Message should indicate auto-exit behavior' + ); + + // Clean up + try { + execSync(`tmux kill-session -t ${result.sessionName}`, { + stdio: 'ignore', + }); + } catch { + // Session may have already exited + } + }); + + it('should include keep-alive message when keepAlive is true', async () => { + if (!isCommandAvailable('tmux')) { + console.log(' Skipping: tmux not installed'); + return; + } + + const result = await runInTmux('echo test', { + session: `test-keepalive-${Date.now()}`, + detached: true, + keepAlive: true, + }); + + assert.strictEqual(result.success, true); + assert.ok( + result.message.includes('stay alive'), + 'Message should indicate keep-alive behavior' + ); + + // Clean up + try { + execSync(`tmux kill-session -t ${result.sessionName}`, { + stdio: 'ignore', + }); + } catch { + // Ignore cleanup errors + } + }); + }); + + describe('runInDocker keep-alive messages', () => { + // Helper function to check if docker daemon is running + function isDockerRunning() { + if (!isCommandAvailable('docker')) { + return false; + } + try { + // Try to ping the docker daemon + execSync('docker info', { stdio: 'ignore', timeout: 5000 }); + return true; + } catch { + return false; + } + } + + it('should include auto-exit message by default in detached mode', async () => { + if (!isDockerRunning()) { + console.log(' Skipping: docker not available or daemon not running'); + return; + } + + const containerName = `test-autoexit-${Date.now()}`; + const result = await runInDocker('echo test', { + image: 'alpine:latest', + session: containerName, + detached: true, + keepAlive: false, + }); + + assert.strictEqual(result.success, true); + assert.ok( + result.message.includes('exit automatically'), + 'Message should indicate auto-exit behavior' + ); + + // Clean up + try { + execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' }); + } catch { + // Container may have already been removed + } + }); + + it('should include keep-alive message when keepAlive is true', async () => { + if (!isDockerRunning()) { + console.log(' Skipping: docker not available or daemon not running'); + return; + } + + const containerName = `test-keepalive-${Date.now()}`; + const result = await runInDocker('echo test', { + image: 'alpine:latest', + session: containerName, + detached: true, + keepAlive: true, + }); + + assert.strictEqual(result.success, true); + assert.ok( + result.message.includes('stay alive'), + 'Message should indicate keep-alive behavior' + ); + + // Clean up + try { + execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' }); + } catch { + // Ignore cleanup errors + } + }); + }); +}); + describe('Isolation Runner with Available Backends', () => { // Integration-style tests that run if backends are available // These test actual execution in detached mode (quick and non-blocking)