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/keep-alive-option.md
Original file line number Diff line number Diff line change
@@ -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
297 changes: 297 additions & 0 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

35 changes: 28 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <session-name>
```

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:
Expand Down
27 changes: 26 additions & 1 deletion REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ Support two patterns for passing wrapper options:
- `--detached, -d`: Run in detached/background mode
- `--session, -s <name>`: Custom session name
- `--image <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

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
8 changes: 8 additions & 0 deletions src/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,12 @@ function printUsage() {
console.log(
' --image <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:');
Expand Down Expand Up @@ -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
Expand Down
38 changes: 33 additions & 5 deletions src/lib/args-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
* 2. $ [wrapper-options] command [command-options]
*
* Wrapper Options:
* --isolated, -i <backend> Run in isolated environment (screen, tmux, docker)
* --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)
* --isolated, -i <backend> Run in isolated environment (screen, tmux, docker)
* --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)
* --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
Expand All @@ -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 = [];
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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'
);
}
}

/**
Expand Down
109 changes: 97 additions & 12 deletions src/lib/isolation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) {
Expand All @@ -319,10 +319,28 @@ function runInScreen(command, options = {}) {
try {
if (options.detached) {
// Detached mode: screen -dmS <session> <shell> -c '<command>'
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
Expand All @@ -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
Expand Down Expand Up @@ -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 = {}) {
Expand All @@ -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 <session> '<command>'
// 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 <session> '<command>'
Expand Down Expand Up @@ -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 = {}) {
Expand All @@ -477,6 +527,16 @@ function runInDocker(command, options = {}) {
try {
if (options.detached) {
// Detached mode: docker run -d --name <name> <image> <shell> -c '<command>'
// 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',
Expand All @@ -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 <name> <image> <shell> -c '<command>'
Expand Down
Loading
Loading