Deploying an app is the beginning, not the end. After deployment comes the daily work of managing it: restarting after a configuration change, stopping during maintenance, deleting when a project is done, adding custom domains when it goes to production.
Phase 3 added five commands to handle the full application lifecycle. The technical implementation is straightforward -- these are thin wrappers around existing API endpoints. What makes them interesting is the safety design: how do you let a developer delete an app from the terminal without making it too easy to delete the wrong one?
The Thin Wrapper Pattern
All five Phase 3 commands follow the same structure:
- Parse the app argument (name or UUID)
- Resolve it via
client.resolve_app() - Make the API call
- Print the result
Here is restart in its entirety:
rustpub async fn run(client: &Sh0Client, app: &str) -> Result<()> {
let app_info = client.resolve_app(app).await?;
client.post(&format!("/apps/{}/restart", app_info.id), &()).await?;
print_success(&format!("Restarted {}", app_info.name));
Ok(())
}Seven lines. No business logic, no state management, no complex error handling. The server does the work. The CLI provides the interface.
start is identical in structure. stop and delete add confirmation prompts.
Confirmation Prompts: Two Levels of Caution
Stopping an app is reversible. Deleting an app is not. The CLI reflects this with two different confirmation patterns.
Stop: Simple Yes/No
$ sh0 stop my-app
Stop my-app? This will take the app offline. [y/N] y
Stopped my-appThe default is N (no). An accidental Enter does nothing. The --yes flag skips the prompt for scripting:
rustif !yes {
print!(" Stop {}? This will take the app offline. [y/N] ", app_info.name);
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim().to_lowercase() != "y" {
println!(" Cancelled");
return Ok(());
}
}Delete: Type the Name
Stopping is a bad day. Deleting is a catastrophe. The confirmation for delete requires the developer to type the full app name:
$ sh0 delete my-production-app
This will permanently delete my-production-app and all its data.
Type the app name to confirm: my-production-app
Deleted my-production-appThe typed name must match exactly. No fuzzy matching, no "yes" shortcut. This is the same pattern GitHub uses for deleting repositories, and for the same reason: the friction is the feature.
rustif !yes {
println!(" This will permanently delete {} and all its data.", app_info.name);
print!(" Type the app name to confirm: ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim() != app_info.name {
println!(" Name does not match. Cancelled.");
return Ok(());
}
}
client.delete(&format!("/apps/{}?delete_volumes=true", app_info.id)).await?;The Query Parameter Bug
The audit found the most subtle bug in the entire CLI enhancement project in this command. The original implementation used ?cleanup=true as the query parameter. The server expected ?delete_volumes=true.
Why was this critical? Because serde silently ignores unknown query parameters. The delete call succeeded -- the app was removed -- but its Docker volumes were preserved. Orphaned volumes accumulating on disk, invisible to the user, consuming storage indefinitely.
The fix was one word: cleanup to delete_volumes. But the bug would have been invisible in testing because the delete operation itself succeeded. Only a careful audit comparing the CLI query string against the server's handler revealed the mismatch.
Domain Management: A Subcommand
Domains are different from the other commands because they have multiple actions. Instead of four separate commands (sh0 domains-list, sh0 domains-add, etc.), we used a subcommand pattern:
$ sh0 domains my-app list
DOMAIN PRIMARY ID
my-app.sh0.app yes a1b2c3d4
custom.example.com no e5f6g7h8
$ sh0 domains my-app add staging.example.com
Added staging.example.com
$ sh0 domains my-app add production.example.com --primary
Added production.example.com (primary)
$ sh0 domains my-app remove staging.example.com
Remove staging.example.com? [y/N] y
Removed staging.example.comThe list action prints a formatted table using the same print_table utility that other CLI commands use. The add action accepts an optional --primary flag. The remove action includes a confirmation prompt that shows the domain name, not just the ID.
Domain Resolution by Name or ID
The audit improved the remove action. The original implementation only accepted domain IDs:
$ sh0 domains my-app remove e5f6g7h8This forced the developer to first run list, find the ID, copy it, and paste it into the remove command. The fix was to resolve domains by name first, falling back to ID:
rust// Try to find by domain name first
let domain = domains.iter()
.find(|d| d.domain == domain_arg)
.or_else(|| domains.iter().find(|d| d.id == domain_arg));
match domain {
Some(d) => {
// Confirm with domain name, not ID
if !yes {
print!(" Remove {}? [y/N] ", d.domain);
// ...
}
client.delete(&format!("/apps/{}/domains/{}", app_info.id, d.id)).await?;
}
None => anyhow::bail!("Domain '{}' not found", domain_arg),
}Now the developer can type what they see:
$ sh0 domains my-app remove staging.example.comThis is a small change that eliminates a full round-trip (list then remove) from the workflow.
The resolve_app Pattern
Every Phase 3 command starts with client.resolve_app(app). This function accepts both app names and UUIDs:
rustpub async fn resolve_app(&self, name_or_id: &str) -> Result<AppInfo> {
// Try as UUID first
if let Ok(uuid) = uuid::Uuid::parse_str(name_or_id) {
if let Ok(app) = self.get_app(&uuid.to_string()).await {
return Ok(app);
}
}
// Search by name
let apps = self.get_apps(1, 200).await?;
apps.into_iter()
.find(|a| a.name == name_or_id)
.ok_or_else(|| anyhow!("App '{}' not found", name_or_id))
}The global audit later found that the original implementation only fetched the first 100 apps (per_page=100). A server with more than 100 apps would silently fail to find apps that exist. The fix increased the limit to 200 (the server's maximum page size).
This is still not perfect -- a server with more than 200 apps would need pagination. But sh0's current deployment scale makes this a reasonable trade-off. The fix will be revisited when the first customer hits 200 apps.
Audit Results
Phase 3 had one audit round:
- 1 Critical: Wrong query parameter on delete (
cleanupinstead ofdelete_volumes) - 0 Important
- 4 Minor (2 fixed): domain remove now resolves by name, confirmation prompt shows domain name
The single Critical finding -- a one-word bug that would have caused silent data leaks -- validates the audit methodology. The developer who wrote the code knew the server's delete API. They had read the handler. They still wrote the wrong query parameter, because they were thinking about the CLI's user experience, not the server's query parsing.
A fresh pair of eyes, focused on correctness rather than features, caught it in minutes.
The Complete CLI Surface
After Phase 3, the sh0 CLI has 25 commands across six categories:
| Category | Commands |
|---|---|
| Auth & Config | login, whoami, config show/get/set |
| Deploy & Push | push, init, link, deploy |
| App Lifecycle | restart, stop, start, delete, open |
| Domains | domains list/add/remove |
| Management | status, logs, env, ssh, scale |
| Infrastructure | templates, compose, cron, yaml, hooks, preview, export |
Every action available in the dashboard is now available from the terminal. The CLI is not a subset of the dashboard -- it is a complete alternative interface.
Next in the series: Watch Mode and WebSocket Streaming -- Auto-deploy on file changes, and upgrading from HTTP polling to real-time WebSocket build log streaming.