| 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:
Presentation (CLI) → Application (handlers) → Domain (business logic)
Benefits for infrastructure applications:
- Test immediately - Run command and check output after each step
- Validate UX early - Verify command interface before business logic
- Defer hard problems - Infrastructure code (filesystems, APIs, databases) is hard to unit test
- 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:
pub enum Commands {
YourCommand {
name: String,
#[arg(long)]
force: bool,
},
}
Commands::YourCommand { name, force } => {
self.handle_your_command(name, force).await
}
pub struct YourCommandController {
}
impl YourCommandController {
pub fn execute(&mut self, name: &str) -> Result<()> {
self.progress.start("Step 1: Validate input")?;
self.progress.complete("Validation complete")?;
Ok(())
}
}
#[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()
),
}
}
}
pub fn your_command_controller(&self) -> YourCommandController {
YourCommandController::new()
}
Test Phase 1:
cargo run -- your-command --help
cargo run -- your-command /path/to/nonexistent
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/ as a reference.
Phase 2: Application Handler
Goal: Implement business logic that actually does the work.
What to build:
pub struct YourCommandHandler {
repository: Arc<dyn EnvironmentRepository>,
working_directory: Arc<Path>,
}
impl YourCommandHandler {
pub fn execute(&self, name: &EnvironmentName) -> Result<()> {
if !self.repository.exists(name)? {
return Err(YourCommandError::NotFound);
}
self.do_the_work(name)?;
Ok(())
}
fn do_the_work(&self, name: &EnvironmentName) -> Result<()> {
tracing::info!("Executing work for {}", name);
Ok(())
}
}
#[derive(Error, Debug)]
pub enum YourCommandError {
#[error("Environment not found: {0}")]
NotFound(String),
#[error("Operation failed: {0}")]
OperationFailed(String),
}
pub struct YourCommandController {
handler: YourCommandHandler,
progress: ProgressReporter,
}
impl YourCommandController {
pub fn execute(&mut self, name: &str) -> Result<()> {
let env_name = EnvironmentName::try_from(name)?;
self.progress.start("Validating environment")?;
self.progress.start("Performing operation")?;
self.handler.execute(&env_name)?;
self.progress.complete("Operation complete")?;
Ok(())
}
}
Test Phase 2:
cargo run -- your-command valid-input
cargo run -- your-command nonexistent
cargo run -- your-command invalid-format
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):
⏳ [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:
❌ 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):
❌ 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):
❌ 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:
cargo check
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt
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:
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:
-
Add a method your_command(...) in both:
packages/sdk/src/deployer.rs
src/presentation/sdk/deployer.rs
-
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:
#[error(transparent)]
YourCommand(#[from] YourCommandHandlerError),
-
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):
impl YourCommandController {
pub fn execute(&mut self, name: &str, force: bool) -> Result<()> {
let env_name = EnvironmentName::try_from(name)?;
if !force && !self.confirm_operation(&env_name)? {
return Err(YourCommandError::UserCancelled);
}
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()))?;
Ok(input.trim().eq_ignore_ascii_case("y"))
}
}
Test Phase 3:
cargo run -- your-command test-name
cargo run -- your-command test-name --force
Commit: feat: [#ISSUE] add your-command confirmation prompt
Phase 4: E2E Tests
Goal: Black-box testing validating end-to-end behavior.
Note: Application handler unit tests belong in Phase 2 (src/application/command_handlers/your_command/tests/). Phase 4 E2E tests verify the complete command as a black box through the CLI binary.
Why E2E over unit tests for infrastructure code: Infrastructure code interacts with external systems (filesystems, databases, APIs, VMs). E2E tests validate real behavior without complex mocking.
What to build:
use crate::support::*;
#[test]
fn it_should_execute_successfully_when_conditions_met() {
let runner = ProcessRunner::new();
let env_name = "e2e-your-command-test";
runner.run_create_command(env_name, &create_config());
let result = runner.run_your_command(env_name, &["--force"]);
assert!(result.success());
assert!(result.output().contains("Operation complete"));
assert_expected_state_changes(env_name);
}
#[test]
fn it_should_fail_when_preconditions_not_met() {
let runner = ProcessRunner::new();
let result = runner.run_your_command("nonexistent", &["--force"]);
assert!(!result.success());
assert!(result.output().contains("not found"));
}
#[test]
fn it_should_handle_edge_cases() {
}
impl ProcessRunner {
pub fn run_your_command(&self, name: &str, flags: &[&str]) -> ExecutionResult {
let mut args = vec!["your-command", name];
args.extend_from_slice(flags);
self.run(&args)
}
}
pub fn assert_expected_state_changes(env_name: &str) {
}
Test Phase 4:
cargo test --test e2e_integration your_command -- --test-threads=1
cargo test --test e2e_integration your_command -- --nocapture --test-threads=1
Expected Behavior Phase 4:
✅ All Test Scenarios Pass:
running 5 tests
test e2e::your_command::test_scenario_1 ... ok
test e2e::your_command::test_scenario_2 ... ok
test e2e::your_command::test_scenario_3 ... ok
test e2e::your_command::test_scenario_4 ... ok
test e2e::your_command::test_scenario_5 ... ok
test result: ok. 5 passed; 0 failed; 0 ignored
Test Coverage Checklist:
- ✅ Success scenario with valid input
- ✅ Presentation layer errors (file not found, invalid path)
- ✅ Application layer errors (parsing failures, schema validation)
- ✅ Domain layer errors (constraint violations, business rules)
- ✅ Read-only verification (no side effects for dry-run commands)
Example Test Cases (validate command):
- File Not Found - Reports missing configuration file
- Invalid JSON - Shows JSON parsing error with line numbers
- Missing SSH Keys - Catches domain validation errors
- Valid Configuration - Succeeds and displays environment details
- No Deployment Created - Verifies read-only behavior
What Validates:
- Complete end-to-end workflow
- Error propagation through all layers
- User-facing messages are helpful
- No unintended side effects
- Command works in realistic scenarios
What Doesn't Validate:
- User documentation (Phase 5)
- Integration with other commands (Phase 6)
- Full production deployment (manual testing)
Commit: feat: [#ISSUE] add your-command E2E tests
Phase 5: Documentation
Goal: Comprehensive user documentation for the new command.
What to create:
<!-- docs/user-guide/commands/your-command.md -->
# Your Command
Brief description of what the command does and when to use it.
## Command Syntax
\```bash
torrust-tracker-deployer your-command <NAME> [OPTIONS]
\```
### Arguments
- `<NAME>` - Environment name (required)
### Options
- `--force` - Skip confirmation prompt (optional)
## Usage Examples
### Basic Usage
\```bash
torrust-tracker-deployer your-command my-env
\```
### Automated/CI Usage
\```bash
torrust-tracker-deployer your-command my-env --force
\```
## What This Command Does
1. [Step 1 description]
2. [Step 2 description]
3. [Step 3 description]
## When to Use
- [Use case 1]
- [Use case 2]
- [Use case 3]
## When NOT to Use
- [Anti-pattern 1]
- [Anti-pattern 2]
## Common Scenarios
### Scenario 1: [Description]
\```bash
# Commands...
\```
### Scenario 2: [Description]
\```bash
# Commands...
\```
## Troubleshooting
### Error: [Common Error Message]
**Cause**: [Why this happens]
**Solution**: [How to fix]
\```bash
# Fix command
\```
## Related Commands
- [`other-command`](./other-command.md) - [When to use instead]
- [`related-command`](./related-command.md) - [Use before/after]
## See Also
- [Feature documentation](../../features/your-feature/)
- [Architecture decisions](../../decisions/)
Also update:
<!-- docs/user-guide/commands/README.md -->
## [Category] Commands
- **[`your-command`](./your-command.md)** - Brief description
Also update docs/console-commands.md — this file lists every command with its full CLI syntax and is separate from the user guide. Add your command in the appropriate section.
Also update the command workflow (if appropriate):
<!-- docs/user-guide/commands/README.md -->
The typical command sequence for a complete deployment:
```text
1. create template → Generate configuration template
2. (edit template) → Customize your settings
3. your-command → [Where your command fits in workflow]
4. create environment → Create environment from config
...
```
Test Phase 5:
ls docs/user-guide/commands/your-command.md
grep "your-command" docs/user-guide/commands/README.md
grep "your-command" docs/user-guide/commands/README.md -A 5
cargo run --bin linter markdown
✅ Expected Behaviors:
| Aspect | Expected Result |
|---|
| Documentation File | docs/user-guide/commands/your-command.md exists with 200+ lines |
| Content Sections | All required sections present (Syntax, Examples, When to Use, Scenarios, Troubleshooting) |
| Command Index | your-command listed in docs/user-guide/commands/README.md |
| Workflow Integration | Command appears in workflow sequence if it's part of main deployment path |
| Markdown Linting | ✅ All checks pass with proper formatting |
| Links Work | All internal links to other commands/docs are valid |
Documentation Quality Checklist:
- ✅ Clear command syntax with all arguments and options
- ✅ At least 3 common scenarios with complete code
- ✅ Error examples showing both error message and solution
- ✅ "When to Use" and "When NOT to Use" sections
- ✅ Troubleshooting section with 3+ common issues
- ✅ Related commands section with appropriate cross-links
- ✅ Examples use realistic names (not "foo", "bar")
Example Documentation (validate command - 280+ lines):
# Validate Command
Validates a Torrust Tracker Deployer configuration file without creating an environment.
## Command Syntax
[...]
## Common Scenarios
### Scenario 1: Pre-creation Validation
[Complete workflow with commands]
### Scenario 2: CI/CD Pipeline Check
[Automated validation example]
### Scenario 3: Troubleshooting Invalid Config
[Debug workflow]
## Troubleshooting
### Error: Configuration file not found
**Cause**: File path is incorrect
**Solution**: Verify file exists: `ls -la envs/your-config.json`
[...]
What Phase 5 Validates:
- User-facing documentation is comprehensive
- All command aspects are documented
- Examples are practical and realistic
- Troubleshooting covers common issues
- Integration with command index is complete
What Phase 5 Doesn't Validate:
- Whether users find the docs helpful (requires user testing)
- Documentation accuracy over time (requires maintenance)
- Completeness of edge cases (evolves with usage)
Commit: docs: [#ISSUE] add your-command user documentation
Phase 6: Integration Polish (Optional)
Goal: Improve discoverability and user guidance.
Ideas:
- Add hints in related command outputs
- Update help text to mention new command
- Add to command workflow diagrams
- Cross-link documentation
Example (from purge command):
self.progress.output().lock().borrow_mut().result(&format!(
"💡 Local data preserved for debugging. To completely remove:\n \
torrust-tracker-deployer purge {name} --force"
));
Commit: feat: [#ISSUE] improve your-command discoverability
Implementation Checklist
Use this for tracking progress:
- [ ] Phase 1: Presentation layer stub
- [ ] CLI command variant
- [ ] Router integration
- [ ] Controller skeleton
- [ ] Error types with help()
- [ ] Container wiring
- [ ] Manual test: command runs
- [ ] Commit presentation stub
- [ ] Phase 2: Application handler
- [ ] Handler with business logic
- [ ] Application error types
- [ ] Unit tests for handler (tests/mod.rs)
- [ ] Controller delegates to handler
- [ ] SDK method added (if applicable)
- [ ] SdkError variant + From impl added (if applicable)
- [ ] `cargo test --doc --workspace` passes
- [ ] Manual test: real behavior
- [ ] Commit application handler
- [ ] Phase 3: Confirmation (if needed)
- [ ] Interactive prompt
- [ ] --force flag support
- [ ] Stdin reading
- [ ] Manual test: both modes
- [ ] Commit confirmation
- [ ] Phase 4: E2E tests
- [ ] Success scenario test
- [ ] Error handling test
- [ ] Edge case tests
- [ ] Helper methods in ProcessRunner
- [ ] Assertion methods
- [ ] All tests pass
- [ ] Commit E2E tests
- [ ] Phase 5: Documentation
- [ ] Create docs/user-guide/commands/your-command.md
- [ ] Update docs/user-guide/commands/README.md
- [ ] Update docs/console-commands.md
- [ ] Update docs/features/active-features.md status
- [ ] Pass markdown linting
- [ ] Commit documentation
- [ ] Phase 6: Polish (optional)
- [ ] Add discoverability hints
- [ ] Update related commands
- [ ] Commit polish
- [ ] Final verification
- [ ] cargo run --bin linter all
- [ ] All E2E tests pass
- [ ] Pre-commit checks pass
- [ ] Create PR
Code Organization
Follow DDD layer placement rules from docs/contributing/ddd-layer-placement.md:
Presentation Layer (src/presentation/)
- CLI command definitions
- Controllers (orchestration, user interaction)
- User output formatting
- Confirmation prompts
- Progress reporting
- Presentation-specific errors
Application Layer (src/application/)
- Command handlers (business workflows)
- Use case orchestration
- Application service coordination
- Application-specific errors
- Command DTOs (if needed)
Domain Layer (src/domain/)
- Business entities (EnvironmentName, etc.)
- Value objects
- Domain errors
- Business rules and invariants
- Domain services (if pure logic)
Infrastructure Layer (src/infrastructure/)
- External tool wrappers (Ansible, OpenTofu)
- Repository implementations
- File system operations
- API clients
- Database access
Example: The Purge Command
Real implementation reference: PR #323
Phases executed:
- Presentation stub (6841d94b) - 671 lines
- Application handler (e053e57b) - 521 lines
- Confirmation prompt (45513e99) - 85 lines
- E2E tests (1aaf7573) - 449 lines, 5 scenarios
- Documentation (13f773f2) - 555 lines (426-line user guide)
- Destroy hint (be2a5a74) - Small UX improvement
Total: ~2,224 insertions, production-ready feature
Key files to reference:
Example: validate Command (This Skill Guide's Validation)
Read-only command following this skill guide (Phases 1-2, 4-5):
Key learnings from validate implementation:
- Phase 3 skipped (read-only command needs no confirmation)
- Phase 6 skipped (integration polish deferred to future iteration)
- Domain validation reuses
EnvironmentParams conversion (SSH key checking, constraints)
- E2E tests verify read-only behavior (no deployment created)
- Documentation includes "When NOT to Use" section for anti-patterns
Tips & Best Practices
Start Simple, Iterate Fast
- Phase 1 stub can be 10 lines - just make it runnable
- Test after every phase before moving to the next
- Commit after each phase (small, focused commits)
Defer Complexity
- Don't worry about error handling in Phase 1
- Don't implement full business logic until Phase 2
- Don't add confirmation until basic flow works
Leverage E2E Tests
- Write tests that match how users invoke the command
- Don't mock external dependencies - test real behavior
- Use
--force flag in tests to skip interactive prompts
Focus on User Experience
- Clear progress messages at each step
- Helpful error messages with actionable
.help() suggestions
- Show what changed after command completes
- Provide examples in help text
Follow Project Conventions
- Read
docs/contributing/ before starting
- Follow commit message format:
type: [#ISSUE] description
- Run pre-commit checks:
./scripts/pre-commit.sh
- Update roadmap after completion
Watch for Clippy Pedantic Gotchas
This project enables clippy::pedantic. Two patterns that commonly trigger warnings when adding commands:
-
unused_self: Validation helper methods that don't use self must be associated functions:
fn validate_name(&self, name: &str) -> Result<()> { ... }
fn validate_name(name: &str) -> Result<()> { ... }
-
doc_link_with_quotes: Shell command substitutions in /// doc comments (e.g., `"$(torrust-tracker-deployer your-command my-env)"`) trigger this lint. Place the allow attribute before the entire doc block, not inside it:
#[allow(clippy::doc_link_with_quotes)]
YourCommand { ... },
Related Documentation
Next Steps After Implementation
- Update feature status: Change the command's row from
📋 Specified → ✅ Implemented in docs/features/active-features.md. This is the per-command tracking file; it is separate from the general docs/roadmap.md.
- Remove issue spec: Delete from
docs/issues/ after PR merge
- Update GitHub issue: Close linked issue or update epic progress
- Consider skill: If command pattern is reusable, document in skills/