- name
- add-new-command
- description
- Guide for implementing new CLI commands in the deployer using outside-in development. Covers presentation layer scaffolding, application handler, confirmation prompts, E2E testing, and documentation. Use when adding new commands like validate, render, backup, or any console subcommand. Triggers on "add command", "new command", "implement command", "create subcommand", or "add CLI command".
- metadata
- {"author":"torrust","version":"1.0"}
# Adding New Commands to the Deployer
This skill guides you through implementing new CLI commands for the Torrust Tracker Deployer using an **outside-in** (presentation → application → domain) development approach.
## Why Outside-In?
**Start from the outer layers (user interface) and work inward:**
```text
Presentation (CLI) → Application (handlers) → Domain (business logic)
```
**Benefits for infrastructure applications:**
1. **Test immediately** - Run command and check output after each step
2. **Validate UX early** - Verify command interface before business logic
3. **Defer hard problems** - Infrastructure code (filesystems, APIs, databases) is hard to unit test
4. **E2E focus** - Integration tests work from day one
**Alternative (inside-out)**: Starting from domain/business logic requires mocking external dependencies or waiting until full implementation to test real behavior.
## Implementation Phases
### Phase 1: Presentation Layer Stub
**Goal**: Make command runnable with proper routing and empty implementation.
**What to build**:
```rust
// 1. Add CLI command variant
// src/presentation/input/cli/commands.rs
pub enum Commands {
// ... existing commands
YourCommand {
name: String,
#[arg(long)]
force: bool, // If needed
},
}
// 2. Add routing
// src/presentation/dispatch/router.rs
Commands::YourCommand { name, force } => {
self.handle_your_command(name, force).await
}
// 3. Create controller stub
// src/presentation/controllers/your_command/handler.rs
pub struct YourCommandController {
// Dependencies (injected later)
}
impl YourCommandController {
pub fn execute(&mut self, name: &str) -> Result<()> {
// Stub: just show progress steps
self.progress.start("Step 1: Validate input")?;
self.progress.complete("Validation complete")?;
Ok(())
}
}
// 4. Define presentation errors
// src/presentation/controllers/your_command/errors.rs
#[derive(Error, Debug)]
pub enum YourCommandError {
#[error("Invalid name: {0}")]
InvalidName(String),
}
impl YourCommandError {
pub fn help(&self) -> Option<String> {
match self {
Self::InvalidName(_) => Some(
"Use lowercase alphanumeric with hyphens".to_string()
),
}
}
}
// 5. Wire in container
// src/bootstrap/container.rs
pub fn your_command_controller(&self) -> YourCommandController {
YourCommandController::new(/* dependencies */)
}
```
**Test Phase 1**:
```bash
# Test 1: Help text displays correctly
cargo run -- your-command --help
# Test 2: Error handling works (file not found, invalid input)
cargo run -- your-command /path/to/nonexistent
# Test 3: Success path (stub shows progress)
cargo run -- your-command valid-input
```
**Expected Behavior Phase 1**:
✅ Help text shows:
- Command description
- Arguments and flags
- Usage examples
- Options documentation
✅ Error handling shows:
- Clear error message
- Troubleshooting help (via `.help()` method)
- Actionable guidance
✅ Success path shows:
- Progress steps (1/N, 2/N, 3/N)
- Step descriptions
- Success message
- Stub completion (no real work yet)
**What Validates**:
- CLI registration works
- Routing is correct
- Controller is wired
- Error types have `.help()` methods
- Progress reporting displays properly
**What Doesn't Validate**:
- Real business logic (Phase 2)
- Domain validation (Phase 2)
- Actual operations (Phase 2)
**Commit**: `feat: [#ISSUE] add your-command presentation layer stub`
> **Query commands (commands that return data)**: If your command returns a value rather than just performing an action (e.g., `exists`, `show`, `list`), the presentation layer needs a view layer in addition to the controller. Create:
>
> - `src/presentation/cli/views/commands/your_command/view_data/your_details.rs` — DTO populated with the result
> - `src/presentation/cli/views/commands/your_command/views/text_view.rs` — `impl Render<YourResult>`
> - `src/presentation/cli/views/commands/your_command/views/json_view.rs` — `impl Render<YourResult>`
>
> See [`src/presentation/cli/views/commands/exists/`](../../../src/presentation/cli/views/commands/exists/) as a reference.
### Phase 2: Application Handler
**Goal**: Implement business logic that actually does the work.
**What to build**:
```rust
// 1. Create application handler
// src/application/command_handlers/your_command/handler.rs
pub struct YourCommandHandler {
repository: Arc<dyn EnvironmentRepository>,
working_directory: Arc<Path>,
}
impl YourCommandHandler {
pub fn execute(&self, name: &EnvironmentName) -> Result<()> {
// 1. Verify preconditions
if !self.repository.exists(name)? {
return Err(YourCommandError::NotFound);
}
// 2. Perform the actual work
self.do_the_work(name)?;
// 3. Update state if needed
// repository.update(...)?;
Ok(())
}
fn do_the_work(&self, name: &EnvironmentName) -> Result<()> {
// Real implementation:
// - Call external tools (Ansible, OpenTofu)
// - Modify filesystem
// - Update database/registry
// - Generate artifacts
tracing::info!("Executing work for {}", name);
Ok(())
}
}
// 2. Create application errors
// src/application/command_handlers/your_command/errors.rs
#[derive(Error, Debug)]
pub enum YourCommandError {
#[error("Environment not found: {0}")]
NotFound(String),
#[error("Operation failed: {0}")]
OperationFailed(String),
}
// 3. Update controller to use handler
// src/presentation/controllers/your_command/handler.rs
pub struct YourCommandController {
handler: YourCommandHandler, // Inject handler
progress: ProgressReporter,
}
impl YourCommandController {
pub fn execute(&mut self, name: &str) -> Result<()> {
let env_name = EnvironmentName::try_from(name)?;
self.progress.start("Validating environment")?;
// Validation...
self.progress.start("Performing operation")?;
self.handler.execute(&env_name)?; // Delegate to handler
self.progress.complete("Operation complete")?;
Ok(())
}
}
```
**Test Phase 2**:
```bash
# Test 1: Valid input performs real work
cargo run -- your-command valid-input
# Test 2: Invalid preconditions fail gracefully
cargo run -- your-command nonexistent
# Test 3: Domain validation errors are caught
cargo run -- your-command invalid-format
# Test 4: Business rule violations show helpful errors
cargo run -- your-command edge-case-input
```
**Expected Behavior Phase 2**:
✅ **Valid Input** - Real operations execute:
- Progress steps complete with actual work
- State changes occur (files created/updated, database modified)
- Detailed success message with results
- Operation completes successfully
Example (validate command):
```text
⏳ [1/3] Loading configuration file...
⏳ ✓ Configuration file loaded (took 0ms)
⏳ [2/3] Validating JSON schema...
⏳ ✓ Schema validation passed (took 0ms)
⏳ [3/3] Validating configuration fields...
⏳ ✓ Field validation passed (took 0ms)
✅ Configuration file 'envs/lxd-local-example.json' is valid
Environment Details:
• Name: lxd-local-example
• Provider: lxd
• Prometheus: Enabled
• Grafana: Enabled
```
✅ **Invalid Preconditions** - Clear presentation errors:
- Error message explains what's wrong
- `.help()` provides actionable guidance
- Exit code is non-zero
Example:
```text
❌ Command failed: Configuration file not found: /tmp/nonexistent.json
For detailed troubleshooting:
Verify the file path is correct: /tmp/nonexistent.json
Use 'create template' to generate a valid configuration file.
```
✅ **Domain Validation Errors** - Application layer catches issues:
- Specific error message (not generic)
- Context about what was validated
- Helpful troubleshooting steps
Example (JSON parsing):
```text
❌ Validation failed for configuration file: /tmp/invalid.json
For detailed troubleshooting:
JSON parsing failed for file '/tmp/invalid.json'.
Error details:
key must be a string at line 1 column 3
Common issues:
- Missing or extra commas
- Unmatched braces or brackets
- Invalid escape sequences
```
✅ **Business Rule Violations** - Domain layer enforces constraints:
- Detailed error about which rule failed
- Why the rule exists
- How to fix the problem
Example (missing SSH keys):
```text
❌ Validation failed: SSH private key file not found: /tmp/nonexistent-key
This means the configuration file has valid JSON syntax but violates
domain constraints or business rules.
Common issues:
- SSH key files don't exist at specified paths
- Invalid environment name (must be lowercase with dashes)
- Invalid port numbers or IP addresses
```
**What Validates**:
- Real business logic executes
- Application handler performs actual work
- Domain validation catches constraint violations
- Error propagation works (domain → application → presentation)
- State changes occur correctly
**What Doesn't Validate**:
- User confirmation prompts (Phase 3)
- E2E integration across commands (Phase 4)
- Full error coverage (Phase 4)
**Verification Checklist**:
```bash
# 1. Compilation succeeds
cargo check
# 2. Code quality passes
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt
# 3. Manual tests cover all scenarios
# - Valid input success path
# - Presentation errors (file not found, wrong type)
# - Application errors (JSON parsing, schema validation)
# - Domain errors (business rules, constraints)
# 4. Verify state changes
# - Check filesystem modifications
# - Verify database/registry updates
# - Confirm generated artifacts
```
**Commit**: `feat: [#ISSUE] add your-command application layer handler`
#### Unit Tests for the Handler
Write unit tests directly alongside the handler in `src/application/command_handlers/your_command/tests/mod.rs`. Cover:
- Success path (e.g., operation succeeds when environment exists/does not exist)
- Error path (repository failure propagates correctly)
Use `FileEnvironmentRepository` (via `EnvironmentTestBuilder`) or a simple inline stub:
```rust
struct FailingRepository;
impl EnvironmentRepository for FailingRepository {
fn find(...) -> Result<...> { Err(RepositoryError::...) }
}
```
Add `#[cfg(test)] mod tests;` to `src/application/command_handlers/your_command/mod.rs`.
#### SDK Layer Update
If the command is useful for programmatic workflows, expose it through the SDK:
1. Add a method `your_command(...)` in both:
- `packages/sdk/src/deployer.rs`
- `src/presentation/sdk/deployer.rs`
2. Add a `From<YourCommandHandlerError>` impl in `packages/sdk/src/error.rs` (new `SdkError` variant). **Without this, doc examples using the `?` operator will fail doc tests**:
```rust
// packages/sdk/src/error.rs
/// [`super::deployer::Deployer::your_command`] failed.
#[error(transparent)]
YourCommand(#[from] YourCommandHandlerError),
```
3. Verify doc tests pass: `cargo test --doc --workspace`
### Phase 3: User Confirmation (Optional)
**Goal**: Add interactive confirmation for destructive/important operations.
**When to add**:
- ✅ Destructive operations (delete, purge, destroy)
- ✅ Operations that modify infrastructure
- ✅ Operations that cost money
- ✅ Operations that are hard to undo
**When to SKIP**:
- ❌ Read-only operations (list, show, validate)
- ❌ Dry-run commands that don't change state
- ❌ Operations with `--dry-run` mode
- ❌ Commands that generate artifacts without side effects
**Decision**: If your command is read-only or non-destructive, **skip to Phase 4** (E2E Tests).
**What to build** (if needed):
```rust
// src/presentation/controllers/your_command/handler.rs
impl YourCommandController {
pub fn execute(&mut self, name: &str, force: bool) -> Result<()> {
let env_name = EnvironmentName::try_from(name)?;
// Show confirmation unless --force
if !force && !self.confirm_operation(&env_name)? {
return Err(YourCommandError::UserCancelled);
}
// Continue with operation...
self.handler.execute(&env_name)?;
Ok(())
}
fn confirm_operation(&mut self, name: &EnvironmentName) -> Result<bool> {
self.progress.blank_line()?;
self.progress.output().lock().borrow_mut().warning(
"⚠️ WARNING: This operation will [describe impact]"
);
self.progress.output().lock().borrow_mut().warning(
"This action cannot be undone!"
);
self.progress.blank_line()?;
print!("Continue? [y/N]: ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)
.map_err(|e| YourCommandError::IoError(e.to_string()))?;
GitHub에서 보기