diff --git a/docs/case-studies/issue-77/README.md b/docs/case-studies/issue-77/README.md new file mode 100644 index 0000000..a77a81c --- /dev/null +++ b/docs/case-studies/issue-77/README.md @@ -0,0 +1,51 @@ +# Case Study: Isolation Environment Stacking (Issue #77) + +## Overview + +This case study analyzes the implementation of stacking/queuing multiple isolation environments in sequence for the start-command CLI tool. + +## Problem Statement + +Currently, start-command supports running commands in a single isolation environment: +- `--isolated screen` - Run in GNU Screen +- `--isolated tmux` - Run in tmux +- `--isolated docker` - Run in Docker container +- `--isolated ssh` - Run via SSH on remote server + +The request is to support **stacking** multiple isolation environments in sequence: + +```bash +$ --isolated "screen ssh tmux ssh docker" -- npm test +``` + +This would create a nested execution environment where: +1. Start in a screen session +2. SSH to a remote server +3. Start a tmux session on that server +4. SSH to another server +5. Run in a Docker container + +## Related Documents + +- [Requirements Analysis](./requirements.md) - Detailed analysis of all requirements +- [Options Analysis](./options-analysis.md) - Analysis of options that need stacking support +- [Solution Proposals](./solutions.md) - Proposed implementation approaches +- [Timeline Visualization](./timeline.md) - Execution timeline considerations +- [Research Findings](./research.md) - External research and related tools + +## Key Concepts + +### Links Notation +The sequence syntax is based on [Links Notation](https://github.com/link-foundation/links-notation), a format for representing structured data through references and links. + +### Underscore Placeholder +The underscore (`_`) serves as a placeholder for "default" or "skip this level": + +```bash +--image "_ _ _ _ oven/bun:latest" # Only 5th level uses custom image +--endpoint "_ user@server1 _ user@server2 _" # SSH endpoints for levels 2 and 4 +``` + +## Implementation Summary + +See [solutions.md](./solutions.md) for detailed implementation proposals. diff --git a/docs/case-studies/issue-77/options-analysis.md b/docs/case-studies/issue-77/options-analysis.md new file mode 100644 index 0000000..c79269e --- /dev/null +++ b/docs/case-studies/issue-77/options-analysis.md @@ -0,0 +1,179 @@ +# Options Analysis for Isolation Stacking + +## Current Wrapper Options + +Based on the current implementation in `args-parser.js`: + +| Option | Current Usage | Stack Support Needed | Notes | +|--------|--------------|---------------------|-------| +| `--isolated, -i` | Single backend | **Primary target** | Becomes sequence | +| `--attached, -a` | Boolean flag | No | Global setting | +| `--detached, -d` | Boolean flag | No | Global setting | +| `--session, -s` | Session name | Maybe | Could be per-level | +| `--session-id` | UUID tracking | No | Global tracking | +| `--image` | Docker image | **Yes** | Only applies to docker levels | +| `--endpoint` | SSH target | **Yes** | Only applies to ssh levels | +| `--isolated-user, -u` | Username | Maybe | Per-level or global | +| `--keep-user` | Boolean flag | No | Global setting | +| `--keep-alive, -k` | Boolean flag | Maybe | Could be per-level | +| `--auto-remove-docker-container` | Boolean flag | Maybe | Per docker level | +| `--use-command-stream` | Boolean flag | No | Global experimental | +| `--status` | UUID query | N/A | Query mode, not execution | +| `--output-format` | Format string | N/A | Query mode | +| `--cleanup` | Boolean flag | N/A | Cleanup mode | + +## Options Requiring Stacking Support + +### 1. `--isolated` (Primary) + +**Current:** Single value from `[screen, tmux, docker, ssh]` + +**Proposed:** Space-separated sequence parsed with Links Notation + +```bash +--isolated "screen ssh tmux ssh docker" +``` + +**Parsing:** +```javascript +// Single value (backward compatible) +"docker" → ["docker"] + +// Multiple values +"screen ssh docker" → ["screen", "ssh", "docker"] +``` + +### 2. `--image` + +**Current:** Single Docker image name + +**Proposed:** Space-separated sequence with `_` placeholders + +```bash +--image "_ _ _ _ oven/bun:latest" # 5-level stack, only last is docker +--image "ubuntu:22.04" # Single value applies to all docker levels +``` + +**Parsing:** +```javascript +// Single value (applies to all docker levels) +"ubuntu:22.04" → ["ubuntu:22.04"] // replicate for each docker level + +// Sequence with placeholders +"_ _ ubuntu:22.04" → [null, null, "ubuntu:22.04"] +``` + +### 3. `--endpoint` + +**Current:** Single SSH endpoint (user@host) + +**Proposed:** Space-separated sequence with `_` placeholders + +```bash +--endpoint "_ user@server1 _ user@server2 _" # SSH at levels 2 and 4 +--endpoint "user@host" # Single value for all SSH levels +``` + +**Parsing:** +```javascript +// Single value (applies to all ssh levels) +"user@host" → ["user@host"] + +// Sequence with placeholders +"_ user@host1 _ user@host2" → [null, "user@host1", null, "user@host2"] +``` + +## Options with Optional Stacking Support + +### 4. `--session` + +Could support per-level session names: + +```bash +--session "myscreen _ mytmux _ mycontainer" +``` + +**Recommendation:** Keep simple for now, auto-generate per-level names. + +### 5. `--keep-alive` + +Could be per-level: + +```bash +--keep-alive "true _ true _ false" +``` + +**Recommendation:** Keep as global flag for simplicity. When set, applies to all levels. + +### 6. `--auto-remove-docker-container` + +Could be per docker level: + +```bash +--auto-remove-docker-container "true false" # For two docker levels +``` + +**Recommendation:** Keep as global flag for simplicity. + +## Validation Rules for Stacked Options + +### Rule 1: Length Matching + +When both `--isolated` and option sequences are provided, lengths should match: + +```bash +# Valid: 5 isolation levels, 5 image specs (using _ for non-docker) +--isolated "screen ssh tmux ssh docker" --image "_ _ _ _ ubuntu:22.04" + +# Invalid: Mismatched lengths +--isolated "screen ssh docker" --image "_ _ ubuntu:22.04 ubuntu:24.04" # 3 vs 4 +``` + +### Rule 2: Type Compatibility + +Options should only have non-placeholder values for compatible isolation types: + +```bash +# Valid: --image only has value for docker level (5th) +--isolated "screen ssh tmux ssh docker" --image "_ _ _ _ ubuntu:22.04" + +# Warning/Error: --image has value for non-docker level +--isolated "screen ssh tmux ssh docker" --image "ubuntu:22.04 _ _ _ _" # screen doesn't use image +``` + +### Rule 3: Required Options + +SSH isolation still requires endpoint, Docker still works with default image: + +```bash +# Error: SSH level 2 missing endpoint +--isolated "screen ssh docker" --endpoint "_ _ _" + +# Valid: SSH level 2 has endpoint +--isolated "screen ssh docker" --endpoint "_ user@host _" +``` + +## Implementation Considerations + +### Parsing Strategy + +1. Check if value contains spaces +2. If spaces: parse as Links Notation sequence +3. If no spaces: treat as single value (backward compatible) + +### Default Value Handling + +- For single values: replicate to match isolation stack length +- For sequences with `_`: substitute defaults at runtime + +### Error Messages + +Provide clear feedback for configuration errors: + +``` +Error: Isolation stack has 5 levels but --image has 3 values + --isolated "screen ssh tmux ssh docker" + --image "_ _ ubuntu:22.04" + + Consider: --image "_ _ _ _ ubuntu:22.04" (5 values to match isolation levels) +``` diff --git a/docs/case-studies/issue-77/requirements.md b/docs/case-studies/issue-77/requirements.md new file mode 100644 index 0000000..4236aa9 --- /dev/null +++ b/docs/case-studies/issue-77/requirements.md @@ -0,0 +1,111 @@ +# Requirements Analysis for Issue #77 + +## Functional Requirements + +### FR1: Multi-Level Isolation Stacking + +**Requirement:** Support specifying multiple isolation environments in a single `--isolated` argument. + +**Syntax:** +```bash +$ --isolated "screen ssh tmux ssh docker" -- command +``` + +**Behavior:** +- Parse space-separated sequence of isolation backends +- Execute command through each level in sequence +- Each level wraps the next level's execution + +### FR2: Per-Level Option Distribution + +**Requirement:** Support specifying options (like `--image`, `--endpoint`) for specific levels in the stack. + +**Syntax with placeholders:** +```bash +# Custom image only for the 5th (docker) level +$ --isolated "screen ssh tmux ssh docker" --image "_ _ _ _ oven/bun:latest" -- command + +# SSH endpoints for levels 2 and 4 +$ --isolated "screen ssh tmux ssh docker" --endpoint "_ user@server1 _ user@server2 _" -- command +``` + +**Placeholder semantics:** +- `_` (underscore) means "use default" or "not applicable" for that level +- Non-underscore values apply to corresponding levels + +### FR3: Recursive Execution + +**Requirement:** Each isolation level should call `$` with remaining levels. + +**Example transformation:** +```bash +# Original command +$ --isolated "screen ssh tmux ssh docker" --image "_ _ _ _ oven/bun:latest" -- npm test + +# Level 1 (screen) executes: +$ --isolated "ssh tmux ssh docker" --image "_ _ _ oven/bun:latest" -- npm test + +# Level 2 (ssh) executes: +$ --isolated "tmux ssh docker" --image "_ _ oven/bun:latest" -- npm test + +# Level 3 (tmux) executes: +$ --isolated "ssh docker" --image "_ oven/bun:latest" -- npm test + +# Level 4 (ssh) executes: +$ --isolated "docker" --image "oven/bun:latest" -- npm test + +# Level 5 (docker) executes: +npm test +``` + +### FR4: Timeline Visualization + +**Requirement:** Show execution timeline that traces through all isolation levels. + +**Example output:** +``` +│ session abc-123-def-456-ghi +│ start 2024-01-15 10:30:45 +│ isolation screen → ssh → tmux → ssh → docker +│ +$ npm test + + +✓ +│ finish 2024-01-15 10:30:52 +│ duration 7.456s +│ exit 0 +``` + +### FR5: Backward Compatibility + +**Requirement:** Existing single-level isolation commands must continue to work unchanged. + +**Verification:** +```bash +# These must work exactly as before +$ --isolated screen -- echo hello +$ --isolated docker --image ubuntu:22.04 -- npm test +$ --isolated ssh --endpoint user@host -- ls +``` + +## Non-Functional Requirements + +### NFR1: Links Notation Parsing + +Use Links Notation for parsing sequences, providing a consistent syntax with other link-foundation projects. + +### NFR2: Error Handling + +- Validate that stacked environments make sense (e.g., can't have docker inside docker without special configuration) +- Provide clear error messages for invalid sequences +- Handle connection failures at any level gracefully + +### NFR3: Performance + +- Minimal overhead for single-level isolation (backward compatibility) +- Efficient parsing of multi-level specifications + +## Options Analysis + +See [options-analysis.md](./options-analysis.md) for detailed analysis of which options need stacking support. diff --git a/docs/case-studies/issue-77/research.md b/docs/case-studies/issue-77/research.md new file mode 100644 index 0000000..a915786 --- /dev/null +++ b/docs/case-studies/issue-77/research.md @@ -0,0 +1,166 @@ +# Research Findings for Issue #77 + +## Related Tools and Approaches + +### 1. Nested tmux Sessions + +**Source:** [Tmux in Practice: Local and Nested Remote Sessions](https://www.freecodecamp.org/news/tmux-in-practice-local-and-nested-remote-tmux-sessions-4f7ba5db8795/) + +**Key Findings:** +- Nested tmux sessions are a valid and practical use case +- Visual distinction can be achieved by positioning status lines differently (top vs bottom) +- Remote sessions can be detected via `SSH_CLIENT` environment variable +- Prefix key conflicts can be resolved with conditional configuration + +**Relevance:** Confirms that nested terminal multiplexer sessions are a recognized pattern with established best practices. + +### 2. intmux - Multi-Host SSH/Docker in tmux + +**Source:** [GitHub - dsummersl/intmux](https://github.com/dsummersl/intmux) + +**Key Findings:** +- Connects to multiple SSH/Docker hosts within a tmux session +- Creates panes for each matching host +- Supports `synchronize-panes` for parallel operations +- Can work inside or outside existing tmux sessions + +**Relevance:** Shows existing tooling for multi-host management through terminal multiplexers. + +### 3. tmux with Docker + +**Source:** [Docker Docs - Multiplexers](https://dockerdocs.org/multiplexers/) + +**Key Findings:** +- tmux inside Docker requires proper TTY allocation +- `docker attach` vs `docker exec` matters for multiplexer behavior +- Multiple terminals within containers via tmux + +**Relevance:** Confirms Docker + tmux combinations work but require attention to TTY handling. + +### 4. Mosh + tmux/screen + +**Source:** [Terminal Multiplexers - Ubuntu Server](https://documentation.ubuntu.com/server/reference/other-tools/terminal-multiplexers/) + +**Key Findings:** +- Mosh complements SSH + multiplexers for unreliable connections +- Handles connection loss, IP changes, sleep/wake cycles +- Often used in combination: local tmux → ssh/mosh → remote tmux + +**Relevance:** Real-world pattern of stacking connection layers for reliability. + +## Links Notation + +### Overview + +**Source:** [link-foundation/links-notation](https://github.com/link-foundation/links-notation) + +**Key Concepts:** +- Based on references and links +- Space-separated values form sequences +- Parentheses group related items +- Natural text parsing - "most text in the world already may be parsed as links notation" + +### Parsing Sequence Syntax + +For our use case, the simplest form is sufficient: + +``` +"screen ssh tmux ssh docker" +``` + +This parses as a sequence of 5 references/values. + +### Underscore Convention + +The underscore (`_`) as placeholder is **not native to Links Notation** but is a common convention in programming: +- Go uses `_` for ignored return values +- Many languages use `_` for unused parameters +- Shell uses `_` as last argument placeholder + +**Decision:** Adopt `_` as our "default/skip" placeholder for consistency with programming conventions. + +## lino-objects-codec + +**Source:** Project dependency `lino-objects-codec@0.1.1` + +The project already uses this codec for serialization. It depends on `links-notation@^0.11.0`. + +**Potential Use:** +```javascript +const { Parser } = require('links-notation'); +const parser = new Parser(); +const sequence = parser.parse("screen ssh tmux ssh docker"); +``` + +However, for simple space-separated parsing, direct string splitting may be more appropriate. + +## CLI Argument Conventions + +### POSIX Standards + +**Source:** [GNU Argument Syntax](https://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html) + +- Options beginning with `-` are flags +- `--` terminates option parsing +- Multiple short options can combine (`-abc` = `-a -b -c`) + +### Google Style Guide + +**Source:** [Google Developer Documentation - Command Line Syntax](https://developers.google.com/style/code-syntax) + +- Square brackets `[]` for optional arguments +- Curly braces `{}` for mutually exclusive choices +- Ellipsis `...` for repeated arguments + +### Recommendation + +Our syntax aligns well with conventions: +- Quoted strings for sequences: `--isolated "screen ssh docker"` +- Underscores for placeholders: `--image "_ _ ubuntu:22.04"` +- Standard `--` separator for command + +## Best Practices for Nested Isolation + +### Security Considerations + +1. **Privilege escalation:** Each layer may have different permissions +2. **Network isolation:** Docker may have isolated networks +3. **SSH key forwarding:** May need agent forwarding through layers + +### Performance Considerations + +1. **Latency stacking:** Each SSH hop adds latency +2. **Resource overhead:** Each container/multiplexer uses resources +3. **Connection timeout handling:** Longer chains need higher timeouts + +### Recommended Limits + +- Maximum nesting depth: 5-7 levels (practical limit) +- Timeout scaling: Multiply base timeout by level count +- Connection verification: Health check at each level before proceeding + +## Existing Similar Implementations + +### 1. SSH ProxyJump (-J) + +```bash +ssh -J jump1,jump2 target +``` + +Native SSH supports connection chaining. Our approach is more general (mixing different isolation types). + +### 2. Docker-in-Docker (DinD) + +Docker can run inside Docker with proper configuration. Shows that nested containerization is possible but requires special handling. + +### 3. Kubernetes Pod Exec + +Kubernetes allows exec into pods which may themselves run containers. Multi-level container access is a recognized pattern. + +## Conclusion + +The proposed isolation stacking feature: +1. Aligns with established patterns (nested tmux, SSH jump hosts) +2. Has clear precedents in existing tools (intmux, DinD) +3. Can be implemented with reasonable complexity +4. Should include appropriate guardrails (depth limits, timeouts) diff --git a/docs/case-studies/issue-77/solutions.md b/docs/case-studies/issue-77/solutions.md new file mode 100644 index 0000000..0936a60 --- /dev/null +++ b/docs/case-studies/issue-77/solutions.md @@ -0,0 +1,425 @@ +# Solution Proposals for Issue #77 + +## Solution Overview + +This document proposes implementation approaches for the isolation stacking feature. + +## Proposed Solution: Recursive Self-Invocation + +### Architecture + +The core approach is **recursive self-invocation**: each isolation level calls `$` with one less level in the stack. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Level 0: Original Command │ +│ $ --isolated "screen ssh tmux ssh docker" --image "...img..." npm test +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Level 1: Screen Session │ +│ $ --isolated "ssh tmux ssh docker" --image "...img..." npm test │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Level 2: SSH Connection to server1 │ +│ $ --isolated "tmux ssh docker" --image "..img.." npm test │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Level 3: tmux Session │ +│ $ --isolated "ssh docker" --image ".img." npm test │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Level 4: SSH Connection to server2 │ +│ $ --isolated "docker" --image "img" npm test │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Level 5: Docker Container │ +│ npm test │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Key Components + +#### 1. Sequence Parser (`lib/sequence-parser.js`) + +```javascript +/** + * Parse a space-separated sequence with underscore placeholders + * @param {string} value - Space-separated values (e.g., "screen ssh docker") + * @returns {string[]} Array of values or null for placeholders + */ +function parseSequence(value) { + if (!value || !value.includes(' ')) { + // Single value - backward compatible + return [value]; + } + + return value.split(/\s+/).map(v => v === '_' ? null : v); +} + +/** + * Shift sequence by removing first element + * @param {string[]} sequence - Parsed sequence + * @returns {string} New sequence string + */ +function shiftSequence(sequence) { + const remaining = sequence.slice(1); + return remaining.map(v => v === null ? '_' : v).join(' '); +} + +/** + * Distribute option values across isolation levels + * @param {string} optionValue - Space-separated or single value + * @param {number} stackDepth - Number of isolation levels + * @returns {string[]} Array of values for each level + */ +function distributeOption(optionValue, stackDepth) { + const parsed = parseSequence(optionValue); + + if (parsed.length === 1 && stackDepth > 1) { + // Single value: replicate for all levels + return Array(stackDepth).fill(parsed[0]); + } + + // Validate length matches stack depth + if (parsed.length !== stackDepth) { + throw new Error( + `Option has ${parsed.length} values but isolation stack has ${stackDepth} levels` + ); + } + + return parsed; +} +``` + +#### 2. Updated Args Parser + +Modify `args-parser.js` to handle sequences: + +```javascript +// In parseOption function, update --isolated handling: +if (arg === '--isolated' || arg === '-i') { + if (index + 1 < args.length && !args[index + 1].startsWith('-')) { + const value = args[index + 1]; + + // Check for sequence (contains spaces) + if (value.includes(' ')) { + // Parse as sequence + const backends = value.split(/\s+/).filter(Boolean); + options.isolated = backends[0]; // Current level + options.isolatedStack = backends; // Full stack + } else { + options.isolated = value.toLowerCase(); + options.isolatedStack = [value.toLowerCase()]; + } + return 2; + } + // ... error handling +} + +// Similar updates for --image and --endpoint to store arrays +``` + +#### 3. Command Builder + +New module to construct recursive commands: + +```javascript +/** + * Build command for next isolation level + * @param {object} options - Current wrapper options + * @param {string} command - User command to execute + * @returns {string} Command to execute at current level + */ +function buildNextLevelCommand(options, command) { + if (options.isolatedStack.length <= 1) { + // Last level - execute actual command + return command; + } + + const parts = ['$']; + + // Remaining isolation stack + const remainingStack = options.isolatedStack.slice(1); + parts.push(`--isolated "${remainingStack.join(' ')}"`); + + // Shift option values + if (options.imageStack && options.imageStack.length > 1) { + const remainingImages = options.imageStack.slice(1); + parts.push(`--image "${remainingImages.map(v => v || '_').join(' ')}"`); + } + + if (options.endpointStack && options.endpointStack.length > 1) { + const remainingEndpoints = options.endpointStack.slice(1); + parts.push(`--endpoint "${remainingEndpoints.map(v => v || '_').join(' ')}"`); + } + + // Pass through global flags + if (options.detached) parts.push('--detached'); + if (options.keepAlive) parts.push('--keep-alive'); + if (options.sessionId) parts.push(`--session-id ${options.sessionId}`); + + // Separator and command + parts.push('--', command); + + return parts.join(' '); +} +``` + +#### 4. Updated Isolation Runner + +Modify `isolation.js` to use command builder: + +```javascript +async function runIsolated(backend, command, options = {}) { + // Build the command to execute at this level + const effectiveCommand = options.isolatedStack?.length > 1 + ? buildNextLevelCommand(options, command) + : command; + + switch (backend) { + case 'screen': + return runInScreen(effectiveCommand, { + ...options, + session: options.sessionStack?.[0] || options.session, + }); + case 'tmux': + return runInTmux(effectiveCommand, { + ...options, + session: options.sessionStack?.[0] || options.session, + }); + case 'docker': + return runInDocker(effectiveCommand, { + ...options, + image: options.imageStack?.[0] || options.image, + }); + case 'ssh': + return runInSsh(effectiveCommand, { + ...options, + endpoint: options.endpointStack?.[0] || options.endpoint, + }); + default: + return Promise.resolve({ + success: false, + message: `Unknown isolation environment: ${backend}`, + }); + } +} +``` + +### Validation Rules + +```javascript +function validateIsolationStack(options) { + const stack = options.isolatedStack || [options.isolated]; + + // Validate each backend + for (const backend of stack) { + if (!VALID_BACKENDS.includes(backend)) { + throw new Error( + `Invalid isolation environment: "${backend}". Valid options are: ${VALID_BACKENDS.join(', ')}` + ); + } + } + + // Check SSH levels have endpoints + const sshCount = stack.filter(b => b === 'ssh').length; + const endpointCount = (options.endpointStack || []).filter(v => v !== null).length; + + if (sshCount > 0 && endpointCount === 0) { + throw new Error( + `Stack contains ${sshCount} SSH level(s) but no --endpoint specified` + ); + } + + // Warn if option counts don't match (non-fatal) + if (options.imageStack && options.imageStack.length !== stack.length) { + console.warn( + `Warning: --image has ${options.imageStack.length} values but stack has ${stack.length} levels` + ); + } + + // Check depth limit + const MAX_DEPTH = 7; + if (stack.length > MAX_DEPTH) { + throw new Error( + `Isolation stack too deep: ${stack.length} levels (max: ${MAX_DEPTH})` + ); + } +} +``` + +### Timeline Integration + +Update `output-blocks.js` to show isolation chain: + +```javascript +function createStartBlock(params) { + let content = ''; + content += `│ session ${params.sessionId}\n`; + content += `│ start ${params.startTime}\n`; + + // Show isolation chain if stacked + if (params.isolatedStack && params.isolatedStack.length > 1) { + const chain = formatIsolationChain(params.isolatedStack, params); + content += `│ isolation ${chain}\n`; + } + + content += '│\n'; + return content; +} + +function formatIsolationChain(stack, params) { + return stack.map((backend, i) => { + if (backend === 'ssh' && params.endpointStack?.[i]) { + return `ssh@${params.endpointStack[i]}`; + } + if (backend === 'docker' && params.imageStack?.[i]) { + const imageName = params.imageStack[i].split(':')[0].split('/').pop(); + return `docker:${imageName}`; + } + return backend; + }).join(' → '); +} +``` + +## Alternative Solutions + +### Alternative A: Single-Pass Deep Nesting + +Instead of recursive self-invocation, build the entire nested command at once: + +```bash +screen -dmS session sh -c "ssh user@host 'tmux new -d -s session sh -c \"docker run ubuntu sh -c \\\"npm test\\\"\"'" +``` + +**Pros:** +- Single command execution +- No dependency on `$` being available at each level + +**Cons:** +- Extremely complex quoting/escaping +- Harder to debug +- Difficult to capture output at each level +- Doesn't support `$` features (logging, tracking) at intermediate levels + +**Recommendation:** Not recommended due to complexity. + +### Alternative B: Agent-Based Execution + +Deploy a lightweight agent at each level that receives commands via stdin or sockets: + +**Pros:** +- More control over execution +- Better error reporting + +**Cons:** +- Requires agent installation +- More complex infrastructure +- Overkill for this use case + +**Recommendation:** Out of scope for current requirements. + +## Implementation Plan + +### Phase 1: Core Parsing (Minimum Viable) +1. Add sequence parser utility +2. Update `--isolated` parsing for sequences +3. Add `isolatedStack` to options +4. Validate single-backend case still works + +### Phase 2: Option Distribution +1. Add sequence parsing for `--image` +2. Add sequence parsing for `--endpoint` +3. Validate option/stack length matching +4. Add validation rules + +### Phase 3: Command Building +1. Implement `buildNextLevelCommand` +2. Update isolation runners to use it +3. Test 2-level stacking +4. Test 5-level stacking + +### Phase 4: Timeline Integration +1. Add `isolation` field to timeline +2. Format isolation chain +3. Update logging + +### Phase 5: Testing & Documentation +1. Unit tests for parser +2. Integration tests for stacking +3. Update README +4. Update ARCHITECTURE.md + +## Testing Strategy + +### Unit Tests + +```javascript +describe('Sequence Parser', () => { + it('should parse single value', () => { + expect(parseSequence('docker')).toEqual(['docker']); + }); + + it('should parse space-separated sequence', () => { + expect(parseSequence('screen ssh docker')).toEqual(['screen', 'ssh', 'docker']); + }); + + it('should handle underscore placeholders', () => { + expect(parseSequence('_ ssh _ docker')).toEqual([null, 'ssh', null, 'docker']); + }); +}); + +describe('Command Builder', () => { + it('should build next level command', () => { + const options = { + isolatedStack: ['screen', 'ssh', 'docker'], + imageStack: [null, null, 'ubuntu:22.04'], + endpointStack: [null, 'user@host', null], + }; + const result = buildNextLevelCommand(options, 'npm test'); + expect(result).toBe('$ --isolated "ssh docker" --image "_ ubuntu:22.04" --endpoint "user@host _" -- npm test'); + }); +}); +``` + +### Integration Tests + +```javascript +describe('Isolation Stacking', () => { + it('should execute command through screen → docker', async () => { + const result = await executeWithStacking( + '--isolated "screen docker" --image "_ alpine" -- echo hello', + ); + expect(result.success).toBe(true); + expect(result.output).toContain('hello'); + }); +}); +``` + +## Dependencies + +### Existing Dependencies +- `links-notation` (via `lino-objects-codec`) - Could use for advanced parsing + +### New Dependencies +- None required for basic implementation + +## Risks and Mitigations + +| Risk | Mitigation | +|------|------------| +| Infinite recursion | Depth limit (MAX_DEPTH = 7) | +| `$` not available in remote | Document requirement, add check | +| SSH connection failures | Timeout handling, clear error messages | +| Complex escaping issues | Use array args instead of string concatenation | +| Backward compatibility | Extensive testing of single-level cases | diff --git a/docs/case-studies/issue-77/timeline.md b/docs/case-studies/issue-77/timeline.md new file mode 100644 index 0000000..5ed5ccf --- /dev/null +++ b/docs/case-studies/issue-77/timeline.md @@ -0,0 +1,211 @@ +# Timeline Visualization for Isolation Stacking + +## Current Timeline Output + +For single-level isolation, the current output looks like: + +``` +│ session abc-123-def-456-ghi +│ start 2024-01-15 10:30:45 +│ +$ npm test + + +✓ +│ finish 2024-01-15 10:30:52 +│ duration 7.456s +│ exit 0 +│ +│ log /tmp/start-command-1705312245123-abc123.log +│ session abc-123-def-456-ghi +``` + +## Proposed Timeline for Stacked Isolation + +### Option A: Flat Timeline with Isolation Chain + +Show the full isolation chain in metadata, but keep execution flat: + +``` +│ session abc-123-def-456-ghi +│ start 2024-01-15 10:30:45 +│ isolation screen → ssh@server1 → tmux → ssh@server2 → docker:ubuntu +│ +$ npm test + + +✓ +│ finish 2024-01-15 10:30:52 +│ duration 7.456s +│ exit 0 +│ +│ log /tmp/start-command-1705312245123-abc123.log +│ session abc-123-def-456-ghi +``` + +**Pros:** +- Clean, simple output +- Easy to understand at a glance +- No additional complexity in output handling + +**Cons:** +- Doesn't show timing per level +- Can't see where failures occurred in the chain + +### Option B: Nested Timeline with Indentation + +Show each level's entry and exit: + +``` +│ session abc-123-def-456-ghi +│ start 2024-01-15 10:30:45 +│ +│ → screen (session: myscreen) +│ → ssh (endpoint: user@server1) +│ → tmux (session: mytmux) +│ → ssh (endpoint: user@server2) +│ → docker (image: ubuntu:22.04) +│ +$ npm test + + +✓ +│ ← docker (exit: 0, duration: 1.234s) +│ ← ssh (exit: 0, duration: 1.456s) +│ ← tmux (exit: 0, duration: 1.678s) +│ ← ssh (exit: 0, duration: 2.123s) +│ ← screen (exit: 0, duration: 2.345s) +│ +│ finish 2024-01-15 10:30:52 +│ duration 7.456s +│ exit 0 +│ +│ session abc-123-def-456-ghi +``` + +**Pros:** +- Detailed visibility into each level +- Shows exactly where time is spent +- Clear failure point identification + +**Cons:** +- More verbose output +- More complex implementation +- May be overwhelming for deep stacks + +### Option C: Compact Entry with Detailed Exit + +Show simple entry, detailed exit: + +``` +│ session abc-123-def-456-ghi +│ start 2024-01-15 10:30:45 +│ entering screen → ssh → tmux → ssh → docker +│ +$ npm test + + +✓ +│ exiting docker ✓ → ssh ✓ → tmux ✓ → ssh ✓ → screen ✓ +│ finish 2024-01-15 10:30:52 +│ duration 7.456s (screen: 2.3s, ssh: 0.6s, tmux: 1.2s, ssh: 0.8s, docker: 2.5s) +│ exit 0 +│ +│ session abc-123-def-456-ghi +``` + +**Pros:** +- Balanced verbosity +- Shows per-level timing without cluttering main output +- Clear success/failure indication per level + +**Cons:** +- Complex formatting logic +- Duration breakdown may be imprecise + +## Recommendation + +**Start with Option A (Flat Timeline)** for initial implementation: +- Simplest to implement +- Maintains backward compatibility in output format +- Add `isolation` metadata field showing the chain + +Later, can add `--verbose` or `--timeline-detail` flag to enable Option B or C. + +## Implementation Notes + +### Isolation Chain Formatting + +```javascript +function formatIsolationChain(stack, options) { + return stack.map((backend, i) => { + let label = backend; + if (backend === 'ssh' && options.endpoints[i]) { + label = `ssh@${options.endpoints[i]}`; + } + if (backend === 'docker' && options.images[i]) { + label = `docker:${options.images[i].split(':')[0]}`; + } + return label; + }).join(' → '); +} +``` + +### Exit Status Aggregation + +When unwinding the stack, collect exit codes: + +```javascript +const results = { + levels: [ + { backend: 'docker', exitCode: 0, duration: 1234 }, + { backend: 'ssh', exitCode: 0, duration: 1456 }, + { backend: 'tmux', exitCode: 0, duration: 1678 }, + { backend: 'ssh', exitCode: 0, duration: 2123 }, + { backend: 'screen', exitCode: 0, duration: 2345 }, + ], + totalDuration: 7456, + overallExitCode: 0, +}; +``` + +### Failure Handling + +If any level fails, show the failure point: + +``` +│ session abc-123-def-456-ghi +│ start 2024-01-15 10:30:45 +│ isolation screen → ssh@server1 → tmux → ssh@server2 → docker:ubuntu +│ + +✗ +│ failed ssh@server2 (connection refused) +│ finish 2024-01-15 10:30:47 +│ duration 2.123s +│ exit 255 +│ +│ session abc-123-def-456-ghi +``` + +## Virtual Commands + +When entering each level, we may want to show the "virtual command" being executed: + +``` +│ session abc-123-def-456-ghi +│ start 2024-01-15 10:30:45 +│ +$ [screen] entering isolation +$ [ssh] connecting to user@server1 +$ [tmux] entering isolation +$ [ssh] connecting to user@server2 +$ [docker] starting container ubuntu:22.04 +$ npm test + + +✓ +│ finish 2024-01-15 10:30:52 +``` + +This aligns with existing virtual command visualization for Docker image pulls. diff --git a/js/.changeset/issue-77-isolation-stacking.md b/js/.changeset/issue-77-isolation-stacking.md new file mode 100644 index 0000000..77c14b3 --- /dev/null +++ b/js/.changeset/issue-77-isolation-stacking.md @@ -0,0 +1,33 @@ +--- +'start-command': minor +--- + +feat: Add isolation stacking support + +Added support for stacking multiple isolation environments in sequence, +allowing complex isolation chains like: + +```bash +$ echo hi --isolated "screen ssh tmux docker" +``` + +Key features: + +- Space-separated sequences for `--isolated`, `--image`, and `--endpoint` options +- Underscore (`_`) placeholder for "default/skip" values in option sequences +- Recursive execution where each level invokes `$` with remaining levels +- Maximum isolation depth of 7 levels (prevents infinite recursion) + +Example usage: + +```bash +# SSH to remote host, then run in Docker +$ cmd --isolated "ssh docker" --endpoint "user@host _" --image "_ node:20" + +# Create screen session, SSH to host, start tmux, run in Docker +$ cmd --isolated "screen ssh tmux docker" --endpoint "_ user@host _ _" --image "_ _ _ node:20" +``` + +Backward compatible: All existing single-level isolation commands work unchanged. + +Fixes #77 diff --git a/js/src/lib/args-parser.js b/js/src/lib/args-parser.js index f4bed16..157e530 100644 --- a/js/src/lib/args-parser.js +++ b/js/src/lib/args-parser.js @@ -24,6 +24,7 @@ */ const { getDefaultDockerImage } = require('./docker-utils'); +const { parseSequence, isSequence } = require('./sequence-parser'); // Debug mode from environment const DEBUG = @@ -34,6 +35,11 @@ const DEBUG = */ const VALID_BACKENDS = ['screen', 'tmux', 'docker', 'ssh']; +/** + * Maximum depth for isolation stacking + */ +const MAX_ISOLATION_DEPTH = 7; + /** * Valid output formats for --status */ @@ -73,6 +79,63 @@ function generateUUID() { } } +/** + * Parse --isolated value, handling both single values and sequences + * @param {string} value - Isolation value (e.g., "docker" or "screen ssh docker") + * @param {object} options - Options object to populate + */ +function parseIsolatedValue(value, options) { + if (isSequence(value)) { + // Multi-value sequence (e.g., "screen ssh docker") + const backends = parseSequence(value).map((v) => + v ? v.toLowerCase() : null + ); + options.isolatedStack = backends; + options.isolated = backends[0]; // Current level + } else { + // Single value (backward compatible) + const backend = value.toLowerCase(); + options.isolated = backend; + options.isolatedStack = [backend]; + } +} + +/** + * Parse --image value, handling both single values and sequences + * @param {string} value - Image value (e.g., "ubuntu:22.04" or "_ _ ubuntu:22.04") + * @param {object} options - Options object to populate + */ +function parseImageValue(value, options) { + if (isSequence(value)) { + // Multi-value sequence with placeholders + const images = parseSequence(value); + options.imageStack = images; + options.image = images[0]; // Current level + } else { + // Single value - will be distributed later during validation + options.image = value; + options.imageStack = null; // Will be populated during validation + } +} + +/** + * Parse --endpoint value, handling both single values and sequences + * @param {string} value - Endpoint value (e.g., "user@host" or "_ user@host1 _ user@host2") + * @param {object} options - Options object to populate + */ +function parseEndpointValue(value, options) { + if (isSequence(value)) { + // Multi-value sequence with placeholders + const endpoints = parseSequence(value); + options.endpointStack = endpoints; + options.endpoint = endpoints[0]; // Current level + } else { + // Single value - will be distributed later during validation + options.endpoint = value; + options.endpointStack = null; // Will be populated during validation + } +} + /** * Parse command line arguments into wrapper options and command * @param {string[]} args - Array of command line arguments @@ -80,13 +143,17 @@ function generateUUID() { */ function parseArgs(args) { const wrapperOptions = { - isolated: null, // Isolation environment: screen, tmux, docker, ssh + isolated: null, // Isolation environment: screen, tmux, docker, ssh (current level) + isolatedStack: null, // Full isolation stack for multi-level isolation (e.g., ["screen", "ssh", "docker"]) attached: false, // Run in attached mode detached: false, // Run in detached mode - session: null, // Session name + session: null, // Session name (current level) + sessionStack: null, // Session names for each level sessionId: null, // Session ID (UUID) for tracking - auto-generated if not provided - image: null, // Docker image - endpoint: null, // SSH endpoint (e.g., user@host) + image: null, // Docker image (current level) + imageStack: null, // Docker images for each level (with nulls for non-docker levels) + endpoint: null, // SSH endpoint (current level, e.g., user@host) + endpointStack: null, // SSH endpoints for each level (with nulls for non-ssh levels) user: false, // Create isolated user userName: null, // Optional custom username for isolated user keepUser: false, // Keep isolated user after command completes (don't delete) @@ -175,18 +242,20 @@ function parseOption(args, index, options) { // --isolated or -i if (arg === '--isolated' || arg === '-i') { if (index + 1 < args.length && !args[index + 1].startsWith('-')) { - options.isolated = args[index + 1].toLowerCase(); + const value = args[index + 1]; + parseIsolatedValue(value, options); return 2; } else { throw new Error( - `Option ${arg} requires a backend argument (screen, tmux, docker)` + `Option ${arg} requires a backend argument (screen, tmux, docker, ssh)` ); } } // --isolated= if (arg.startsWith('--isolated=')) { - options.isolated = arg.split('=')[1].toLowerCase(); + const value = arg.split('=')[1]; + parseIsolatedValue(value, options); return 1; } @@ -218,10 +287,11 @@ function parseOption(args, index, options) { return 1; } - // --image (for docker) + // --image (for docker) - supports sequence for stacked isolation if (arg === '--image') { if (index + 1 < args.length && !args[index + 1].startsWith('-')) { - options.image = args[index + 1]; + const value = args[index + 1]; + parseImageValue(value, options); return 2; } else { throw new Error(`Option ${arg} requires an image name argument`); @@ -230,14 +300,16 @@ function parseOption(args, index, options) { // --image= if (arg.startsWith('--image=')) { - options.image = arg.split('=')[1]; + const value = arg.split('=')[1]; + parseImageValue(value, options); return 1; } - // --endpoint (for ssh) + // --endpoint (for ssh) - supports sequence for stacked isolation if (arg === '--endpoint') { if (index + 1 < args.length && !args[index + 1].startsWith('-')) { - options.endpoint = args[index + 1]; + const value = args[index + 1]; + parseEndpointValue(value, options); return 2; } else { throw new Error(`Option ${arg} requires an endpoint argument`); @@ -246,7 +318,8 @@ function parseOption(args, index, options) { // --endpoint= if (arg.startsWith('--endpoint=')) { - options.endpoint = arg.split('=')[1]; + const value = arg.split('=')[1]; + parseEndpointValue(value, options); return 1; } @@ -375,23 +448,137 @@ function validateOptions(options) { ); } - // Validate isolation environment + // Validate isolation environment (with stacking support) if (options.isolated !== null) { - if (!VALID_BACKENDS.includes(options.isolated)) { + const stack = options.isolatedStack || [options.isolated]; + const stackDepth = stack.length; + + // Check depth limit + if (stackDepth > MAX_ISOLATION_DEPTH) { + throw new Error( + `Isolation stack too deep: ${stackDepth} levels (max: ${MAX_ISOLATION_DEPTH})` + ); + } + + // Validate each backend in the stack + for (const backend of stack) { + if (backend && !VALID_BACKENDS.includes(backend)) { + throw new Error( + `Invalid isolation environment: "${backend}". Valid options are: ${VALID_BACKENDS.join(', ')}` + ); + } + } + + // Distribute single option values across stack if needed + if (options.image && !options.imageStack) { + // Single image value - replicate for all levels + options.imageStack = Array(stackDepth).fill(options.image); + } + + if (options.endpoint && !options.endpointStack) { + // Single endpoint value - replicate for all levels + options.endpointStack = Array(stackDepth).fill(options.endpoint); + } + + // Validate stack lengths match + if (options.imageStack && options.imageStack.length !== stackDepth) { + throw new Error( + `--image has ${options.imageStack.length} value(s) but isolation stack has ${stackDepth} level(s). ` + + `Use underscores (_) as placeholders for levels that don't need this option.` + ); + } + + if (options.endpointStack && options.endpointStack.length !== stackDepth) { + throw new Error( + `--endpoint has ${options.endpointStack.length} value(s) but isolation stack has ${stackDepth} level(s). ` + + `Use underscores (_) as placeholders for levels that don't need this option.` + ); + } + + // Validate each level has required options + for (let i = 0; i < stackDepth; i++) { + const backend = stack[i]; + + // Docker uses --image or defaults to OS-matched image + if (backend === 'docker') { + const image = options.imageStack + ? options.imageStack[i] + : options.image; + if (!image) { + // Apply default image + if (!options.imageStack) { + options.imageStack = Array(stackDepth).fill(null); + } + options.imageStack[i] = getDefaultDockerImage(); + } + } + + // SSH requires --endpoint + if (backend === 'ssh') { + const endpoint = options.endpointStack + ? options.endpointStack[i] + : options.endpoint; + if (!endpoint) { + throw new Error( + `SSH isolation at level ${i + 1} requires --endpoint option. ` + + `Use a sequence like --endpoint "_ user@host _" to specify endpoints for specific levels.` + ); + } + } + } + + // Set current level values for backward compatibility + options.image = options.imageStack ? options.imageStack[0] : options.image; + options.endpoint = options.endpointStack + ? options.endpointStack[0] + : options.endpoint; + + // Validate option compatibility with current level (for backward compatible error messages) + const currentBackend = stack[0]; + + // Image is only valid if stack contains docker + if (options.image && !stack.includes('docker')) { throw new Error( - `Invalid isolation environment: "${options.isolated}". Valid options are: ${VALID_BACKENDS.join(', ')}` + '--image option is only valid when isolation stack includes docker' ); } - // Docker uses --image or defaults to OS-matched image - if (options.isolated === 'docker' && !options.image) { - options.image = getDefaultDockerImage(); + // Endpoint is only valid if stack contains ssh + if (options.endpoint && !stack.includes('ssh')) { + throw new Error( + '--endpoint option is only valid when isolation stack includes ssh' + ); } - // SSH requires --endpoint - if (options.isolated === 'ssh' && !options.endpoint) { + // Auto-remove-docker-container is only valid with docker in stack + if (options.autoRemoveDockerContainer && !stack.includes('docker')) { + throw new Error( + '--auto-remove-docker-container option is only valid when isolation stack includes docker' + ); + } + + // User isolation is not supported with Docker as first level + if (options.user && currentBackend === 'docker') { + throw new Error( + '--isolated-user is not supported with Docker as the first isolation level. ' + + 'Docker uses its own user namespace for isolation.' + ); + } + } else { + // Validate options that require isolation when no isolation is specified + if (options.autoRemoveDockerContainer) { throw new Error( - 'SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)' + '--auto-remove-docker-container option is only valid when isolation stack includes docker' + ); + } + if (options.image) { + throw new Error( + '--image option is only valid when isolation stack includes docker' + ); + } + if (options.endpoint) { + throw new Error( + '--endpoint option is only valid when isolation stack includes ssh' ); } } @@ -401,36 +588,13 @@ function validateOptions(options) { throw new Error('--session option is only valid with --isolated'); } - // Image is only valid with docker - if (options.image && options.isolated !== 'docker') { - 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'); } - // 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' - ); - } - // User isolation validation if (options.user) { - // User isolation is not supported with Docker (Docker has its own user mechanism) - if (options.isolated === 'docker') { - throw new Error( - '--isolated-user is not supported with Docker isolation. Docker uses its own user namespace for isolation.' - ); - } // Validate custom username if provided if (options.userName) { if (!/^[a-zA-Z0-9_-]+$/.test(options.userName)) { @@ -509,14 +673,25 @@ function getEffectiveMode(options) { return 'attached'; } +/** + * Check if isolation stack has multiple levels + * @param {object} options - Parsed wrapper options + * @returns {boolean} True if multiple isolation levels + */ +function hasStackedIsolation(options) { + return options.isolatedStack && options.isolatedStack.length > 1; +} + module.exports = { parseArgs, validateOptions, generateSessionName, hasIsolation, + hasStackedIsolation, getEffectiveMode, isValidUUID, generateUUID, VALID_BACKENDS, VALID_OUTPUT_FORMATS, + MAX_ISOLATION_DEPTH, }; diff --git a/js/src/lib/command-builder.js b/js/src/lib/command-builder.js new file mode 100644 index 0000000..49b703a --- /dev/null +++ b/js/src/lib/command-builder.js @@ -0,0 +1,130 @@ +/** + * Command Builder for Isolation Stacking + * + * Builds the command to execute at each isolation level, + * including the recursive $ invocation for nested levels. + */ + +const { formatSequence } = require('./sequence-parser'); + +/** + * Build command for next isolation level + * If more levels remain, builds a recursive $ command + * If this is the last level, returns the actual command + * + * @param {object} options - Current wrapper options + * @param {string} command - User command to execute + * @returns {string} Command to execute at current level + */ +function buildNextLevelCommand(options, command) { + // If no more isolation levels, execute actual command + if (!options.isolatedStack || options.isolatedStack.length <= 1) { + return command; + } + + // Build recursive $ command for remaining levels + const parts = ['$']; + + // Remaining isolation stack (skip first which is current level) + const remainingStack = options.isolatedStack.slice(1); + parts.push(`--isolated "${remainingStack.join(' ')}"`); + + // Shift option values and add if non-empty + if (options.imageStack && options.imageStack.length > 1) { + const remainingImages = options.imageStack.slice(1); + const imageStr = formatSequence(remainingImages); + if (imageStr && imageStr !== '_'.repeat(remainingImages.length)) { + parts.push(`--image "${imageStr}"`); + } + } + + if (options.endpointStack && options.endpointStack.length > 1) { + const remainingEndpoints = options.endpointStack.slice(1); + const endpointStr = formatSequence(remainingEndpoints); + if (endpointStr) { + parts.push(`--endpoint "${endpointStr}"`); + } + } + + if (options.sessionStack && options.sessionStack.length > 1) { + const remainingSessions = options.sessionStack.slice(1); + const sessionStr = formatSequence(remainingSessions); + if (sessionStr && sessionStr !== '_'.repeat(remainingSessions.length)) { + parts.push(`--session "${sessionStr}"`); + } + } + + // Pass through global flags + if (options.detached) { + parts.push('--detached'); + } + + if (options.keepAlive) { + parts.push('--keep-alive'); + } + + if (options.sessionId) { + parts.push(`--session-id ${options.sessionId}`); + } + + if (options.autoRemoveDockerContainer) { + parts.push('--auto-remove-docker-container'); + } + + // Separator and command + parts.push('--'); + parts.push(command); + + return parts.join(' '); +} + +/** + * Escape a command for safe execution in a shell context + * @param {string} cmd - Command to escape + * @returns {string} Escaped command + */ +function escapeForShell(cmd) { + // For now, simple escaping - could be enhanced + return cmd.replace(/'/g, "'\\''"); +} + +/** + * Check if we're at the last isolation level + * @param {object} options - Wrapper options + * @returns {boolean} True if this is the last level + */ +function isLastLevel(options) { + return !options.isolatedStack || options.isolatedStack.length <= 1; +} + +/** + * Get current isolation backend from options + * @param {object} options - Wrapper options + * @returns {string|null} Current backend or null + */ +function getCurrentBackend(options) { + if (options.isolatedStack && options.isolatedStack.length > 0) { + return options.isolatedStack[0]; + } + return options.isolated; +} + +/** + * Get option value for current level + * @param {(string|null)[]} stack - Option value stack + * @returns {string|null} Value for current level + */ +function getCurrentValue(stack) { + if (Array.isArray(stack) && stack.length > 0) { + return stack[0]; + } + return null; +} + +module.exports = { + buildNextLevelCommand, + escapeForShell, + isLastLevel, + getCurrentBackend, + getCurrentValue, +}; diff --git a/js/src/lib/isolation.js b/js/src/lib/isolation.js index 375fa97..66832fb 100644 --- a/js/src/lib/isolation.js +++ b/js/src/lib/isolation.js @@ -775,21 +775,48 @@ function runInDocker(command, options = {}) { /** * Run command in the specified isolation environment + * Supports stacked isolation where each level calls $ with remaining levels * @param {string} backend - Isolation environment (screen, tmux, docker, ssh) * @param {string} command - Command to execute * @param {object} options - Options * @returns {Promise<{success: boolean, message: string}>} */ function runIsolated(backend, command, options = {}) { + // If stacked isolation, build the command for next level + let effectiveCommand = command; + + if (options.isolatedStack && options.isolatedStack.length > 1) { + // Lazy load to avoid circular dependency + const { buildNextLevelCommand } = require('./command-builder'); + effectiveCommand = buildNextLevelCommand(options, command); + + if (DEBUG) { + console.log( + `[DEBUG] Stacked isolation - level command: ${effectiveCommand}` + ); + } + } + + // Get current level option values + const currentOptions = { + ...options, + // Use current level values from stacks + image: options.imageStack ? options.imageStack[0] : options.image, + endpoint: options.endpointStack + ? options.endpointStack[0] + : options.endpoint, + session: options.sessionStack ? options.sessionStack[0] : options.session, + }; + switch (backend) { case 'screen': - return runInScreen(command, options); + return runInScreen(effectiveCommand, currentOptions); case 'tmux': - return runInTmux(command, options); + return runInTmux(effectiveCommand, currentOptions); case 'docker': - return runInDocker(command, options); + return runInDocker(effectiveCommand, currentOptions); case 'ssh': - return runInSsh(command, options); + return runInSsh(effectiveCommand, currentOptions); default: return Promise.resolve({ success: false, diff --git a/js/src/lib/sequence-parser.js b/js/src/lib/sequence-parser.js new file mode 100644 index 0000000..d3a7df6 --- /dev/null +++ b/js/src/lib/sequence-parser.js @@ -0,0 +1,231 @@ +/** + * Sequence Parser for Isolation Stacking + * + * Parses space-separated sequences with underscore placeholders for + * distributing options across isolation levels. + * + * Based on Links Notation conventions (https://github.com/link-foundation/links-notation) + */ + +/** + * Parse a space-separated sequence with underscore placeholders + * @param {string} value - Space-separated values (e.g., "screen ssh docker") + * @returns {(string|null)[]} Array of values, with null for underscore placeholders + */ +function parseSequence(value) { + if (!value || typeof value !== 'string') { + return []; + } + + const trimmed = value.trim(); + if (!trimmed) { + return []; + } + + // Split by whitespace + const parts = trimmed.split(/\s+/); + + // Convert underscores to null (placeholder) + return parts.map((v) => (v === '_' ? null : v)); +} + +/** + * Format a sequence array back to a string + * @param {(string|null)[]} sequence - Array of values with nulls for placeholders + * @returns {string} Space-separated string with underscores for nulls + */ +function formatSequence(sequence) { + if (!Array.isArray(sequence) || sequence.length === 0) { + return ''; + } + + return sequence.map((v) => (v === null ? '_' : v)).join(' '); +} + +/** + * Shift sequence by removing first element + * @param {(string|null)[]} sequence - Parsed sequence + * @returns {(string|null)[]} New sequence without first element + */ +function shiftSequence(sequence) { + if (!Array.isArray(sequence) || sequence.length === 0) { + return []; + } + return sequence.slice(1); +} + +/** + * Check if a string represents a multi-value sequence (contains spaces) + * @param {string} value - Value to check + * @returns {boolean} True if contains spaces (multi-value) + */ +function isSequence(value) { + return typeof value === 'string' && value.includes(' '); +} + +/** + * Distribute a single option value across all isolation levels + * If the value is a sequence, validate length matches stack depth + * If the value is a single value, replicate it for all levels + * + * @param {string} optionValue - Space-separated or single value + * @param {number} stackDepth - Number of isolation levels + * @param {string} optionName - Name of option for error messages + * @returns {(string|null)[]} Array of values for each level + * @throws {Error} If sequence length doesn't match stack depth + */ +function distributeOption(optionValue, stackDepth, optionName) { + if (!optionValue) { + return Array(stackDepth).fill(null); + } + + const parsed = parseSequence(optionValue); + + // Single value: replicate for all levels + if (parsed.length === 1 && stackDepth > 1) { + return Array(stackDepth).fill(parsed[0]); + } + + // Sequence: validate length matches + if (parsed.length !== stackDepth) { + throw new Error( + `${optionName} has ${parsed.length} value(s) but isolation stack has ${stackDepth} level(s). ` + + `Use underscores (_) as placeholders for levels that don't need this option.` + ); + } + + return parsed; +} + +/** + * Get the value at a specific level from a distributed option + * @param {(string|null)[]} distributedOption - Distributed option array + * @param {number} level - Zero-based level index + * @returns {string|null} Value at that level or null + */ +function getValueAtLevel(distributedOption, level) { + if ( + !Array.isArray(distributedOption) || + level < 0 || + level >= distributedOption.length + ) { + return null; + } + return distributedOption[level]; +} + +/** + * Validate that required options are provided for specific isolation types + * @param {(string|null)[]} isolationStack - Stack of isolation backends + * @param {object} options - Object containing distributed options + * @param {(string|null)[]} options.endpoints - Distributed endpoints for SSH + * @param {(string|null)[]} options.images - Distributed images for Docker + * @throws {Error} If required options are missing + */ +function validateStackOptions(isolationStack, options) { + const errors = []; + + isolationStack.forEach((backend, i) => { + if (backend === 'ssh') { + if (!options.endpoints || !options.endpoints[i]) { + errors.push( + `Level ${i + 1} is SSH but no endpoint specified. ` + + `Use --endpoint with a value at position ${i + 1}.` + ); + } + } + // Docker doesn't require image - has default + // Screen and tmux don't require special options + }); + + if (errors.length > 0) { + throw new Error(errors.join('\n')); + } +} + +/** + * Build remaining options for next isolation level + * @param {object} options - Current level options + * @returns {object} Options for next level + */ +function buildNextLevelOptions(options) { + const next = { ...options }; + + // Shift isolation stack + if (next.isolatedStack && next.isolatedStack.length > 1) { + next.isolatedStack = shiftSequence(next.isolatedStack); + next.isolated = next.isolatedStack[0]; + } else { + next.isolatedStack = []; + next.isolated = null; + } + + // Shift distributed options + if (next.imageStack && next.imageStack.length > 1) { + next.imageStack = shiftSequence(next.imageStack); + next.image = next.imageStack[0]; + } else if (next.imageStack) { + next.imageStack = []; + } + + if (next.endpointStack && next.endpointStack.length > 1) { + next.endpointStack = shiftSequence(next.endpointStack); + next.endpoint = next.endpointStack[0]; + } else if (next.endpointStack) { + next.endpointStack = []; + } + + if (next.sessionStack && next.sessionStack.length > 1) { + next.sessionStack = shiftSequence(next.sessionStack); + next.session = next.sessionStack[0]; + } else if (next.sessionStack) { + next.sessionStack = []; + } + + return next; +} + +/** + * Format isolation chain for display + * @param {(string|null)[]} stack - Isolation stack + * @param {object} options - Options with distributed values + * @returns {string} Formatted chain (e.g., "screen → ssh@host → docker:ubuntu") + */ +function formatIsolationChain(stack, options = {}) { + if (!Array.isArray(stack) || stack.length === 0) { + return ''; + } + + return stack + .map((backend, i) => { + if (!backend) { + return '_'; + } + + if (backend === 'ssh' && options.endpointStack?.[i]) { + return `ssh@${options.endpointStack[i]}`; + } + + if (backend === 'docker' && options.imageStack?.[i]) { + // Extract short image name + const image = options.imageStack[i]; + const shortName = image.split(':')[0].split('/').pop(); + return `docker:${shortName}`; + } + + return backend; + }) + .join(' → '); +} + +module.exports = { + parseSequence, + formatSequence, + shiftSequence, + isSequence, + distributeOption, + getValueAtLevel, + validateStackOptions, + buildNextLevelOptions, + formatIsolationChain, +}; diff --git a/js/test/args-parser.test.js b/js/test/args-parser.test.js index 4f78c4c..3597296 100644 --- a/js/test/args-parser.test.js +++ b/js/test/args-parser.test.js @@ -232,7 +232,7 @@ describe('parseArgs', () => { 'npm', 'test', ]); - }, /--image option is only valid with --isolated docker/); + }, /--image option is only valid when isolation stack includes docker/); }); }); @@ -264,7 +264,7 @@ describe('parseArgs', () => { it('should throw error for ssh without endpoint', () => { assert.throws(() => { parseArgs(['--isolated', 'ssh', '--', 'npm', 'test']); - }, /SSH isolation requires --endpoint option/); + }, /SSH isolation at level 1 requires --endpoint option/); }); it('should throw error for endpoint with non-ssh backend', () => { @@ -278,7 +278,7 @@ describe('parseArgs', () => { 'npm', 'test', ]); - }, /--endpoint option is only valid with --isolated ssh/); + }, /--endpoint option is only valid when isolation stack includes ssh/); }); }); @@ -384,13 +384,13 @@ describe('parseArgs', () => { 'npm', 'test', ]); - }, /--auto-remove-docker-container option is only valid with --isolated docker/); + }, /--auto-remove-docker-container option is only valid when isolation stack includes 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/); + }, /--auto-remove-docker-container option is only valid when isolation stack includes docker/); }); it('should work with keep-alive and auto-remove-docker-container', () => { @@ -706,7 +706,7 @@ describe('user isolation option', () => { 'npm', 'install', ]); - }, /--isolated-user is not supported with Docker isolation/); + }, /--isolated-user is not supported with Docker as the first isolation level/); }); it('should work with tmux isolation', () => { diff --git a/js/test/isolation-stacking.test.js b/js/test/isolation-stacking.test.js new file mode 100644 index 0000000..257ec7f --- /dev/null +++ b/js/test/isolation-stacking.test.js @@ -0,0 +1,366 @@ +/** + * Tests for isolation stacking feature (issue #77) + */ + +const { describe, it, expect } = require('bun:test'); +const { + parseArgs, + validateOptions, + hasStackedIsolation, + MAX_ISOLATION_DEPTH, +} = require('../src/lib/args-parser'); + +describe('Isolation Stacking - Args Parser', () => { + describe('parseArgs with stacked --isolated', () => { + it('should parse single isolation (backward compatible)', () => { + const result = parseArgs(['--isolated', 'docker', '--', 'npm', 'test']); + expect(result.wrapperOptions.isolated).toBe('docker'); + expect(result.wrapperOptions.isolatedStack).toEqual(['docker']); + expect(result.command).toBe('npm test'); + }); + + it('should parse multi-level isolation', () => { + const result = parseArgs([ + '--isolated', + 'screen ssh docker', + '--endpoint', + '_ user@host _', + '--', + 'npm', + 'test', + ]); + expect(result.wrapperOptions.isolated).toBe('screen'); + expect(result.wrapperOptions.isolatedStack).toEqual([ + 'screen', + 'ssh', + 'docker', + ]); + }); + + it('should parse 5-level isolation', () => { + const result = parseArgs([ + '--isolated', + 'screen ssh tmux ssh docker', + '--endpoint', + '_ user@server1 _ user@server2 _', + '--', + 'npm', + 'test', + ]); + expect(result.wrapperOptions.isolatedStack).toEqual([ + 'screen', + 'ssh', + 'tmux', + 'ssh', + 'docker', + ]); + expect(result.wrapperOptions.endpointStack).toEqual([ + null, + 'user@server1', + null, + 'user@server2', + null, + ]); + }); + + it('should parse --isolated=value syntax', () => { + const result = parseArgs(['--isolated=screen tmux', '--', 'ls']); + expect(result.wrapperOptions.isolatedStack).toEqual(['screen', 'tmux']); + }); + }); + + describe('parseArgs with stacked --image', () => { + it('should parse single image (backward compatible)', () => { + const result = parseArgs([ + '--isolated', + 'docker', + '--image', + 'ubuntu:22.04', + '--', + 'bash', + ]); + expect(result.wrapperOptions.image).toBe('ubuntu:22.04'); + }); + + it('should parse image sequence with placeholders', () => { + const result = parseArgs([ + '--isolated', + 'screen docker', + '--image', + '_ ubuntu:22.04', + '--', + 'bash', + ]); + expect(result.wrapperOptions.imageStack).toEqual([null, 'ubuntu:22.04']); + }); + + it('should parse --image=value syntax', () => { + const result = parseArgs([ + '--isolated', + 'docker', + '--image=alpine:latest', + '--', + 'sh', + ]); + expect(result.wrapperOptions.image).toBe('alpine:latest'); + }); + }); + + describe('parseArgs with stacked --endpoint', () => { + it('should parse single endpoint (backward compatible)', () => { + const result = parseArgs([ + '--isolated', + 'ssh', + '--endpoint', + 'user@host', + '--', + 'ls', + ]); + expect(result.wrapperOptions.endpoint).toBe('user@host'); + }); + + it('should parse endpoint sequence with placeholders', () => { + const result = parseArgs([ + '--isolated', + 'screen ssh ssh docker', + '--endpoint', + '_ user@host1 user@host2 _', + '--', + 'bash', + ]); + expect(result.wrapperOptions.endpointStack).toEqual([ + null, + 'user@host1', + 'user@host2', + null, + ]); + }); + }); + + describe('hasStackedIsolation', () => { + it('should return true for multi-level', () => { + const { wrapperOptions } = parseArgs([ + '--isolated', + 'screen docker', + '--', + 'test', + ]); + expect(hasStackedIsolation(wrapperOptions)).toBe(true); + }); + + it('should return false for single level', () => { + const { wrapperOptions } = parseArgs([ + '--isolated', + 'docker', + '--', + 'test', + ]); + expect(hasStackedIsolation(wrapperOptions)).toBe(false); + }); + + it('should return false for no isolation', () => { + const { wrapperOptions } = parseArgs(['echo', 'hello']); + // When no isolation, isolatedStack is null, so hasStackedIsolation returns falsy + expect(hasStackedIsolation(wrapperOptions)).toBeFalsy(); + }); + }); +}); + +describe('Isolation Stacking - Validation', () => { + describe('validateOptions', () => { + it('should validate single backend (backward compatible)', () => { + const options = { + isolated: 'docker', + isolatedStack: ['docker'], + }; + expect(() => validateOptions(options)).not.toThrow(); + // Should apply default image + expect(options.imageStack[0]).toBeDefined(); + }); + + it('should validate multi-level stack', () => { + const options = { + isolated: 'screen', + isolatedStack: ['screen', 'ssh', 'docker'], + endpointStack: [null, 'user@host', null], + }; + expect(() => validateOptions(options)).not.toThrow(); + }); + + it('should throw on invalid backend in stack', () => { + const options = { + isolated: 'screen', + isolatedStack: ['screen', 'invalid', 'docker'], + }; + expect(() => validateOptions(options)).toThrow(/Invalid isolation/); + }); + + it('should throw on missing SSH endpoint', () => { + const options = { + isolated: 'ssh', + isolatedStack: ['ssh'], + endpointStack: [null], + }; + expect(() => validateOptions(options)).toThrow(/requires --endpoint/); + }); + + it('should throw on image/stack length mismatch', () => { + const options = { + isolated: 'screen', + isolatedStack: ['screen', 'ssh', 'docker'], + imageStack: [null, 'ubuntu:22.04'], // Only 2, should be 3 + endpointStack: [null, 'user@host', null], + }; + expect(() => validateOptions(options)).toThrow(/value\(s\)/); + }); + + it('should throw on depth exceeding limit', () => { + const tooDeep = Array(MAX_ISOLATION_DEPTH + 1).fill('screen'); + const options = { + isolated: 'screen', + isolatedStack: tooDeep, + }; + expect(() => validateOptions(options)).toThrow(/too deep/); + }); + + it('should distribute single image to all levels', () => { + const options = { + isolated: 'screen', + isolatedStack: ['screen', 'docker', 'docker'], + image: 'ubuntu:22.04', + }; + validateOptions(options); + expect(options.imageStack).toEqual([ + 'ubuntu:22.04', + 'ubuntu:22.04', + 'ubuntu:22.04', + ]); + }); + + it('should apply default docker image for each docker level', () => { + const options = { + isolated: 'docker', + isolatedStack: ['docker'], + }; + validateOptions(options); + expect(options.imageStack[0]).toBeDefined(); + expect(options.imageStack[0]).toContain(':'); // Should have image:tag format + }); + + it('should throw if image provided but no docker in stack', () => { + const options = { + isolated: 'screen', + isolatedStack: ['screen', 'tmux'], + image: 'ubuntu:22.04', + }; + expect(() => validateOptions(options)).toThrow(/docker/); + }); + + it('should throw if endpoint provided but no ssh in stack', () => { + const options = { + isolated: 'screen', + isolatedStack: ['screen', 'docker'], + endpoint: 'user@host', + imageStack: [null, 'ubuntu:22.04'], + }; + expect(() => validateOptions(options)).toThrow(/ssh/); + }); + }); +}); + +describe('Isolation Stacking - Backward Compatibility', () => { + it('should work with existing docker command', () => { + const result = parseArgs([ + '--isolated', + 'docker', + '--image', + 'node:18', + '--', + 'npm', + 'test', + ]); + expect(result.wrapperOptions.isolated).toBe('docker'); + expect(result.wrapperOptions.image).toBe('node:18'); + expect(result.wrapperOptions.isolatedStack).toEqual(['docker']); + expect(result.command).toBe('npm test'); + }); + + it('should work with existing ssh command', () => { + const result = parseArgs([ + '--isolated', + 'ssh', + '--endpoint', + 'user@server', + '--', + 'ls', + '-la', + ]); + expect(result.wrapperOptions.isolated).toBe('ssh'); + expect(result.wrapperOptions.endpoint).toBe('user@server'); + expect(result.wrapperOptions.isolatedStack).toEqual(['ssh']); + }); + + it('should work with existing screen command', () => { + const result = parseArgs([ + '--isolated', + 'screen', + '--detached', + '--keep-alive', + '--', + 'long-running-task', + ]); + expect(result.wrapperOptions.isolated).toBe('screen'); + expect(result.wrapperOptions.detached).toBe(true); + expect(result.wrapperOptions.keepAlive).toBe(true); + expect(result.wrapperOptions.isolatedStack).toEqual(['screen']); + }); + + it('should work with -i shorthand', () => { + const result = parseArgs(['-i', 'tmux', '--', 'vim']); + expect(result.wrapperOptions.isolated).toBe('tmux'); + expect(result.wrapperOptions.isolatedStack).toEqual(['tmux']); + }); + + it('should work with attached mode', () => { + const result = parseArgs([ + '--isolated', + 'docker', + '--attached', + '--image', + 'alpine', + '--', + 'sh', + ]); + expect(result.wrapperOptions.attached).toBe(true); + expect(result.wrapperOptions.isolated).toBe('docker'); + }); + + it('should work with session name', () => { + const result = parseArgs([ + '--isolated', + 'screen', + '--session', + 'my-session', + '--', + 'bash', + ]); + expect(result.wrapperOptions.session).toBe('my-session'); + }); + + it('should work with session-id', () => { + const result = parseArgs([ + '--isolated', + 'docker', + '--image', + 'alpine', + '--session-id', + '12345678-1234-4123-8123-123456789012', + '--', + 'echo', + 'hi', + ]); + expect(result.wrapperOptions.sessionId).toBe( + '12345678-1234-4123-8123-123456789012' + ); + }); +}); diff --git a/js/test/sequence-parser.test.js b/js/test/sequence-parser.test.js new file mode 100644 index 0000000..d7f2a07 --- /dev/null +++ b/js/test/sequence-parser.test.js @@ -0,0 +1,237 @@ +/** + * Tests for sequence-parser.js + * Isolation stacking feature for issue #77 + */ + +const { describe, it, expect } = require('bun:test'); +const { + parseSequence, + formatSequence, + shiftSequence, + isSequence, + distributeOption, + getValueAtLevel, + formatIsolationChain, + buildNextLevelOptions, +} = require('../src/lib/sequence-parser'); + +describe('Sequence Parser', () => { + describe('parseSequence', () => { + it('should parse single value', () => { + expect(parseSequence('docker')).toEqual(['docker']); + }); + + it('should parse space-separated sequence', () => { + expect(parseSequence('screen ssh docker')).toEqual([ + 'screen', + 'ssh', + 'docker', + ]); + }); + + it('should parse sequence with underscores as null', () => { + expect(parseSequence('_ ssh _ docker')).toEqual([ + null, + 'ssh', + null, + 'docker', + ]); + }); + + it('should handle all underscores', () => { + expect(parseSequence('_ _ _')).toEqual([null, null, null]); + }); + + it('should handle empty string', () => { + expect(parseSequence('')).toEqual([]); + }); + + it('should handle null/undefined', () => { + expect(parseSequence(null)).toEqual([]); + expect(parseSequence(undefined)).toEqual([]); + }); + + it('should trim whitespace', () => { + expect(parseSequence(' screen ssh ')).toEqual(['screen', 'ssh']); + }); + + it('should handle multiple spaces between values', () => { + expect(parseSequence('screen ssh docker')).toEqual([ + 'screen', + 'ssh', + 'docker', + ]); + }); + }); + + describe('formatSequence', () => { + it('should format array with values', () => { + expect(formatSequence(['screen', 'ssh', 'docker'])).toBe( + 'screen ssh docker' + ); + }); + + it('should format array with nulls as underscores', () => { + expect(formatSequence([null, 'ssh', null, 'docker'])).toBe( + '_ ssh _ docker' + ); + }); + + it('should handle empty array', () => { + expect(formatSequence([])).toBe(''); + }); + + it('should handle non-array', () => { + expect(formatSequence(null)).toBe(''); + expect(formatSequence(undefined)).toBe(''); + }); + }); + + describe('shiftSequence', () => { + it('should remove first element', () => { + expect(shiftSequence(['screen', 'ssh', 'docker'])).toEqual([ + 'ssh', + 'docker', + ]); + }); + + it('should handle nulls', () => { + expect(shiftSequence([null, 'ssh', null])).toEqual(['ssh', null]); + }); + + it('should handle single element', () => { + expect(shiftSequence(['docker'])).toEqual([]); + }); + + it('should handle empty array', () => { + expect(shiftSequence([])).toEqual([]); + }); + }); + + describe('isSequence', () => { + it('should return true for space-separated values', () => { + expect(isSequence('screen ssh docker')).toBe(true); + }); + + it('should return false for single value', () => { + expect(isSequence('docker')).toBe(false); + }); + + it('should return false for non-string', () => { + expect(isSequence(null)).toBe(false); + expect(isSequence(undefined)).toBe(false); + expect(isSequence(123)).toBe(false); + }); + }); + + describe('distributeOption', () => { + it('should replicate single value for all levels', () => { + expect(distributeOption('ubuntu:22.04', 3, '--image')).toEqual([ + 'ubuntu:22.04', + 'ubuntu:22.04', + 'ubuntu:22.04', + ]); + }); + + it('should parse sequence with matching length', () => { + expect(distributeOption('_ _ ubuntu:22.04', 3, '--image')).toEqual([ + null, + null, + 'ubuntu:22.04', + ]); + }); + + it('should throw on length mismatch', () => { + expect(() => distributeOption('_ _', 3, '--image')).toThrow(); + }); + + it('should handle null/undefined value', () => { + expect(distributeOption(null, 3, '--image')).toEqual([null, null, null]); + }); + }); + + describe('getValueAtLevel', () => { + it('should get value at valid index', () => { + expect(getValueAtLevel(['a', 'b', 'c'], 1)).toBe('b'); + }); + + it('should handle nulls', () => { + expect(getValueAtLevel([null, 'b', null], 0)).toBe(null); + expect(getValueAtLevel([null, 'b', null], 1)).toBe('b'); + }); + + it('should return null for out of bounds', () => { + expect(getValueAtLevel(['a', 'b'], 5)).toBe(null); + expect(getValueAtLevel(['a', 'b'], -1)).toBe(null); + }); + + it('should handle non-array', () => { + expect(getValueAtLevel(null, 0)).toBe(null); + }); + }); + + describe('formatIsolationChain', () => { + it('should format simple chain', () => { + expect(formatIsolationChain(['screen', 'tmux', 'docker'])).toBe( + 'screen → tmux → docker' + ); + }); + + it('should add SSH endpoint', () => { + const options = { endpointStack: [null, 'user@host', null] }; + expect(formatIsolationChain(['screen', 'ssh', 'docker'], options)).toBe( + 'screen → ssh@user@host → docker' + ); + }); + + it('should add Docker image short name', () => { + const options = { imageStack: [null, null, 'oven/bun:latest'] }; + expect(formatIsolationChain(['screen', 'ssh', 'docker'], options)).toBe( + 'screen → ssh → docker:bun' + ); + }); + + it('should handle placeholders', () => { + expect(formatIsolationChain([null, 'ssh', null])).toBe('_ → ssh → _'); + }); + + it('should handle empty array', () => { + expect(formatIsolationChain([])).toBe(''); + }); + }); + + describe('buildNextLevelOptions', () => { + it('should shift all stacks', () => { + const options = { + isolated: 'screen', + isolatedStack: ['screen', 'ssh', 'docker'], + image: null, + imageStack: [null, null, 'ubuntu:22.04'], + endpoint: null, + endpointStack: [null, 'user@host', null], + }; + + const next = buildNextLevelOptions(options); + + expect(next.isolated).toBe('ssh'); + expect(next.isolatedStack).toEqual(['ssh', 'docker']); + expect(next.image).toBe(null); + expect(next.imageStack).toEqual([null, 'ubuntu:22.04']); + expect(next.endpoint).toBe('user@host'); + expect(next.endpointStack).toEqual(['user@host', null]); + }); + + it('should handle last level', () => { + const options = { + isolated: 'docker', + isolatedStack: ['docker'], + imageStack: ['ubuntu:22.04'], + }; + + const next = buildNextLevelOptions(options); + + expect(next.isolated).toBe(null); + expect(next.isolatedStack).toEqual([]); + }); + }); +});