Back to sh0
sh0

The sh0 CLI: 10 Commands That Mirror the Dashboard

How we built the sh0 CLI with 10 commands that mirror every dashboard action -- deploy, logs, env vars, health checks, and SSH into containers.

Thales & Claude | March 25, 2026 9 min sh0
clirustclapdeveloper-experiencedevopsterminal

A PaaS without a CLI is a toy. Dashboards are excellent for exploration and monitoring, but when you are in a terminal, SSH-ed into a server, or scripting a CI/CD pipeline, you need commands. You need sh0 deploy my-app --wait and sh0 logs my-app --follow and sh0 env my-app set DATABASE_URL=postgres://.... You need the same power you have in the browser, without opening a browser.

Phase 15 of the sh0 build -- March 12, 2026, day one of the project -- was the CLI client. We built it on the first day because we knew we would use it constantly for testing every subsequent phase. The CLI was not a nice-to-have bolted on at the end; it was a first-class citizen from the start, and it became one of the most-used tools in our development workflow.

Architecture: Sh0Client and Config

The CLI is a thin client. It does not embed the server, the Docker engine, or the database. It talks to a running sh0 server over HTTP and WebSocket. The entire client lives in the crates/sh0 binary -- the same binary that runs the server with sh0 serve.

Configuration is loaded from ~/.sh0/config.toml or environment variables:

pub struct Sh0Client {
    base_url: String,
    token: String,
    http: reqwest::Client,
}

impl Sh0Client { pub fn from_config() -> Result { // Priority: env vars > config file let base_url = std::env::var("SH0_API_URL") .or_else(|_| config_value("api_url")) .unwrap_or_else(|_| "http://localhost:3000".into());

let token = std::env::var("SH0_API_TOKEN") .or_else(|_| config_value("api_token"))?;

Ok(Self { base_url, token, http: reqwest::Client::new(), }) } } ```

The config file format is minimal:

api_url = "https://sh0.example.com"
api_token = "sh0_a1b2c3d4e5f6..."

Environment variables take precedence, which is critical for CI/CD. A GitHub Actions workflow can set SH0_API_URL and SH0_API_TOKEN as secrets, and every sh0 command in the pipeline will authenticate automatically.

The Sh0Client provides typed HTTP methods (get, post, delete, get_paginated) that handle the common patterns: Bearer token injection, JSON deserialization, automatic { "data": T } envelope unwrapping, and API error translation into user-friendly messages.

The 10 Commands

1. sh0 apps -- List Applications

$ sh0 apps
NAME            STATUS    BRANCH    PORT    REPLICAS    UPDATED
my-api          running   main      3000    2           5m ago
wordpress       running   -         80      1           2h ago
staging-fe      building  develop   5173    1           30s ago

Table formatting with auto-width columns, color-coded status (green for running, yellow for building, red for failed, gray for stopped). The --json flag outputs raw JSON for scripting.

2. sh0 deploy -- Trigger Deployment

$ sh0 deploy my-api -m "Fix payment endpoint" --wait
Triggered deployment for my-api
Waiting for deployment to complete...
  Status: cloning...
  Status: building...
  Status: deploying...
  Status: running
Deployment successful (42s)

The -m flag attaches a message to the deployment record. The --wait flag polls the deployment status every 2 seconds until it reaches "running" or "failed". Without --wait, the command returns immediately after triggering -- useful for fire-and-forget deployments in CI.

3. sh0 logs -- Stream Logs via WebSocket

$ sh0 logs my-api --follow --tail 100
2026-03-12T14:32:01Z [my-api] Server started on port 3000
2026-03-12T14:32:05Z [my-api] Connected to database
2026-03-12T14:32:06Z [my-api] GET /health 200 2ms

Log streaming uses tokio-tungstenite for a real-time WebSocket connection to the server, which in turn reads from the Docker container log stream. The --follow flag keeps the connection open for live tailing. The --tail N flag requests only the last N lines on initial connection.

This was one of the more complex commands to implement because WebSocket connections need proper lifecycle management -- handling server disconnects, authentication via the Sec-WebSocket-Protocol header (we moved the token out of the URL query string for security), and graceful shutdown on Ctrl+C.

4. sh0 env -- Environment Variable Management

$ sh0 env my-api list
KEY              VALUE         BUILD
DATABASE_URL     ********      false
API_SECRET       ********      false
NODE_ENV         production    false

$ sh0 env my-api set DATABASE_URL=postgres://user:pass@db:5432/mydb Set DATABASE_URL on my-api

$ sh0 env my-api delete API_SECRET Deleted API_SECRET from my-api ```

The env command uses clap's subcommand derive to support list, set, and delete actions. The list command masks values by default -- --reveal shows the decrypted values (requires developer+ role). The set command parses KEY=VALUE format. The --build flag marks a variable as build-time (injected during Docker image build) rather than runtime.

5. sh0 check -- Local Health Check

$ sh0 check ./my-project
Score: 85/100 (Good)

BLOCKING (1) SEC001: Hardcoded API key in src/config.ts:14

WARNINGS (2) NODE001: Missing "start" script in package.json BUILD002: No .dockerignore file found

INFO (3) CONFIG001: Port 3000 detected from package.json ... ```

Unlike the other commands, check runs entirely locally. It uses the sh0_builder::health::check_health function -- the same code health analysis that runs during the deploy pipeline. Findings are grouped by severity with colored output: red for blocking, yellow for warnings, green for auto-fixable, blue for informational.

This is the one command that does not need a running server. A developer can run sh0 check . in their project directory before pushing, catching the same issues the deploy pipeline would catch.

6. sh0 ssh -- Shell into Container

$ sh0 ssh my-api
root@a1b2c3d4e5f6:/app#

$ sh0 ssh my-api "cat /app/package.json" { "name": "my-api", "version": "1.0.0" ... } ```

The ssh command does not actually use SSH. It validates that the app's container is running, then executes docker exec -it sh0-{app} /bin/sh (or /bin/bash if available). The -it flags provide an interactive TTY session. If a command argument is provided, it runs that command non-interactively and returns the output.

This is the escape hatch. When the logs do not tell you enough, when you need to inspect the filesystem, check a running process, or test network connectivity from inside the container -- sh0 ssh drops you into the exact environment your app is running in.

7. sh0 status -- Server or App Status

$ sh0 status
sh0 v0.1.0
Uptime: 14d 6h 32m
Apps: 12 (10 running, 1 building, 1 stopped)
Docker: connected
OS: Linux 6.1.0 (Debian 12)

$ sh0 status my-api my-api (running) Branch: main Port: 3000 Replicas: 2 Last deploy: 5h ago (commit a1b2c3d) Created: 2026-03-12 ```

Without an app argument, status shows server-wide information: version, uptime, aggregate app counts, Docker connectivity. With an app argument, it shows the app's details including its latest deployment information.

8. sh0 scale -- Replica Management

$ sh0 scale my-api 3
Scaled my-api to 3 replicas

$ sh0 scale my-api --status my-api: 3 replicas (autoscale: off) ```

Manual horizontal scaling. The number sets the desired replica count. The --auto flag enables autoscaling with CPU/memory thresholds. The --status flag shows the current scaling configuration.

9. sh0 cron -- Cron Job Management

$ sh0 cron ls
ID    APP        SCHEDULE       COMMAND              STATUS    LAST RUN
1     my-api     */5 * * * *    node cleanup.js      enabled   2m ago
2     my-api     0 2 * * *      pg_dump mydb > ...   enabled   22h ago

List, create, trigger, view run history, and delete cron jobs. The trigger subcommand runs a job immediately, bypassing the schedule -- useful for testing.

10. sh0 templates -- Template Deployment

$ sh0 templates list --category databases
NAME          CATEGORY     DESCRIPTION
postgresql    databases    PostgreSQL 16 with persistent storage
mysql         databases    MySQL 8 with persistent storage
redis         databases    Redis 7 in-memory data store
...

$ sh0 templates deploy postgresql --app-name my-db --var POSTGRES_PASSWORD=secret Deploying template: postgresql Created app: my-db (running) ```

Browse and deploy from the 119 built-in templates. The --var KEY=VALUE flag overrides template variables -- without it, secrets are auto-generated.

App Name Resolution

One design decision worth calling out: how the CLI resolves app references. Users can pass either an app ID (a UUID) or an app name:

pub async fn resolve_app(&self, name_or_id: &str) -> Result<AppInfo> {
    // Try as ID first (fast, exact match)
    if let Ok(app) = self.get::<AppInfo>(&format!("/api/v1/apps/{}", name_or_id)).await {
        return Ok(app);
    }
    // Fallback: search by name
    let apps: Vec<AppInfo> = self.get_paginated("/api/v1/apps", 1, 200).await?;
    apps.into_iter()
        .find(|a| a.name == name_or_id)
        .ok_or_else(|| anyhow!("App '{}' not found", name_or_id))
}

Try the input as an ID first (a single API call). If that fails, search by name across all apps. This means sh0 deploy my-api and sh0 deploy 550e8400-e29b-41d4-a716-446655440000 both work, and the common case (name) takes at most two API calls.

Terminal Formatting

The output.rs module provides formatting helpers that make the CLI output readable without being noisy:

pub fn format_status(status: &str) -> ColoredString {
    match status {
        "running" => status.green(),
        "building" | "deploying" | "cloning" => status.yellow(),
        "failed" => status.red(),
        "stopped" | "inactive" => status.dimmed(),
        _ => status.normal(),
    }
}

pub fn format_time_ago(timestamp: &str) -> String { let dt = DateTime::parse_from_rfc3339(timestamp).ok(); match dt { Some(dt) => { let duration = Utc::now() - dt.with_timezone(&Utc); if duration.num_seconds() < 60 { format!("{}s ago", duration.num_seconds()) } else if duration.num_minutes() < 60 { format!("{}m ago", duration.num_minutes()) } else if duration.num_hours() < 24 { format!("{}h ago", duration.num_hours()) } else { format!("{}d ago", duration.num_days()) } } None => timestamp.to_string(), } } ```

The print_table function auto-calculates column widths from the data, so tables always fit the content without hardcoded widths. Status values are color-coded. Timestamps are relative ("5m ago" instead of "2026-03-12T14:32:01Z"). The --json flag on most commands bypasses all formatting and outputs raw JSON, which is what you want when piping to jq or parsing in a script.

CLI as Testing Infrastructure

Beyond its role as a user-facing tool, the CLI became critical testing infrastructure during development. Every new API endpoint got tested with the CLI before the dashboard was built. The deploy pipeline was debugged with sh0 deploy --wait. WebSocket log streaming was validated with sh0 logs --follow. Environment variable encryption was tested with sh0 env set and sh0 env list --reveal.

Building the CLI on day one -- before the dashboard, before the templates, before the scaling system -- was one of the best decisions of the project. It gave us a fast, scriptable interface to every feature as it was built, and it caught API design problems that we would not have noticed if we had only tested through HTTP clients like curl or Postman.

The CLI also embodies a principle we care about: a PaaS should be operable entirely from the terminal. Not every user wants a dashboard. Some users live in tmux, deploy from CI, and manage their infrastructure through scripts. The CLI ensures they are first-class citizens, not second-class users of a dashboard-first product.

---

This is Part 33 of the "How We Built sh0.dev" series. Next up: license enforcement -- how we implemented a 3-tier freemium system in Rust, gated features across 10 handler files, and made pricing decisions for African markets.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles