Back to sh0
sh0

From 10 Commands to 30: The Developer Ergonomics Sprint

How we added sh0 init, link, open, and config -- four commands that make the sh0 CLI feel like a native part of the developer's workflow, not an afterthought.

Thales & Claude | March 27, 2026 8 min sh0
clirustdeveloper-experienceergonomicsstack-detectionconfiguration

After sh0 push landed, the CLI could deploy. But deploying is one action. A developer's day involves dozens of small interactions with their tools: initializing a project, linking a directory to an existing app, opening a URL, checking configuration. These are not features. They are ergonomics. And ergonomics is the difference between a tool that developers tolerate and a tool that developers reach for instinctively.

Phase 2 added four commands in a single session. None of them are technically impressive. All of them make the CLI feel complete.

sh0 init -- Detect and Prepare

Every deployment tool has an init command. Vercel has vercel init. Fly has fly launch. The purpose is always the same: look at the current project, detect what it is, and prepare it for deployment.

sh0 init does two things:

  1. Detects the stack and prints what it found
  2. Generates a .sh0ignore file with stack-aware patterns
$ sh0 init
  Detected stack: nodejs
  Framework: Next.js
  Package manager: npm
  Default port: 3000
  Created .sh0ignore (12 patterns)

The stack detection reuses the same detect_stack() function that sh0 push calls. There is no separate detection logic. One function, one source of truth.

Stack-Aware Ignore Patterns

The interesting part is the .sh0ignore generation. A Node.js project should exclude node_modules/, .next/, .turbo/. A Rust project should exclude target/. A Python project should exclude __pycache__/, .venv/, *.pyc. A Go project should exclude the binary output.

The generator starts with the always-excluded patterns (shared with sh0 push) and then appends stack-specific patterns:

rustfn stack_specific_patterns(stack_type: &str) -> Vec<&'static str> {
    match stack_type {
        "nodejs" => vec![".next", ".nuxt", ".output", ".turbo", ".cache"],
        "python" => vec!["*.egg-info", ".mypy_cache", ".pytest_cache", "htmlcov"],
        "rust"   => vec!["target"],
        "go"     => vec!["vendor"],
        "java"   => vec![".gradle", ".mvn", "*.class"],
        "php"    => vec!["vendor"],
        "ruby"   => vec![".bundle", "vendor/bundle"],
        "dotnet" => vec!["bin", "obj", "*.user"],
        _ => vec![],
    }
}

The audit caught a subtlety: some stack-specific patterns were already in the ALWAYS_EXCLUDE list. The .next pattern, for example, appeared in both the always-excluded list and the Node.js-specific list. The fix was to deduplicate: the generator only adds patterns that are not already in the base list. This prevents confusing .sh0ignore files with duplicate entries.

sh0 push creates new apps. But what about an existing app that was deployed through the dashboard or through Git? The developer wants to push updates to it from their terminal without creating a duplicate.

sh0 link solves this:

$ sh0 link my-existing-app
  Linked to my-existing-app
  -> https://my-existing-app.sh0.app
  Next push will update this app

Under the hood, it calls client.resolve_app("my-existing-app"), which searches the server's app list by name or UUID. If found, it writes the same .sh0/link.json that sh0 push creates on successful deployment:

rustpub async fn run(client: &Sh0Client, app: &str, path: Option<&str>) -> Result<()> {
    let project_path = resolve_path(path)?;

    // Resolve app by name or ID
    let app_info = client.resolve_app(app).await?;

    // Fetch domains to show the primary URL
    let domains = client.get_app_domains(&app_info.id).await?;
    let primary = domains.iter().find(|d| d.primary);

    // Write link file (reuses push::save_link)
    save_link(&project_path, &app_info.id, &app_info.name)?;

    print_success(&format!("Linked to {}", app_info.name));
    if let Some(domain) = primary {
        print_url(&format!("https://{}", domain.domain));
    }

    Ok(())
}

The key design decision was reusing save_link() from push.rs instead of writing a separate implementation. This guarantees that the link file format is identical whether created by push or link. Both functions were made pub(crate) during Phase 2 to enable this sharing.

sh0 open -- Open the URL in a Browser

This is the simplest command in the entire CLI. It reads the link file or resolves an app argument, fetches the primary domain, and opens it in the default browser.

$ sh0 open
  Opening https://my-app.sh0.app

The browser-opening logic is platform-aware:

rustfn open_url(url: &str) -> Result<()> {
    #[cfg(target_os = "macos")]
    {
        std::process::Command::new("open").arg(url).spawn()?;
    }
    #[cfg(target_os = "linux")]
    {
        std::process::Command::new("xdg-open").arg(url).spawn()?;
    }
    Ok(())
}

Two platforms, two commands. sh0 targets Linux servers and macOS development machines. Windows support is not a priority because the deployment target is always Linux.

Without an app argument, sh0 open reads .sh0/link.json via push::read_link() -- the same function that sh0 push uses to detect re-pushes. With an argument, it resolves the app by name or ID via the API. In both cases, it fetches the domain list to find the primary URL.

It is six lines of interesting code and sixty lines of error handling. That ratio is typical for CLI tools.

sh0 config -- Manage the Config File

The sh0 CLI stores its configuration in ~/.sh0/config.toml. The config command provides three subcommands to manage it:

$ sh0 config show
  Server: https://sh0.example.com
  Token:  sh0_a1b2c3d4****
  Config: /Users/dev/.sh0/config.toml

$ sh0 config get api_url
  https://sh0.example.com

$ sh0 config set api_url https://new-server.example.com
  Set api_url

Token Masking

The show subcommand masks the token, displaying only the first 12 characters followed by <em>*</em>*. The get subcommand does not mask -- it outputs the raw value for scripting and piping.

The global audit later caught a problem: sh0 config get token printed the raw token to stdout. This is a security concern in shared terminals or when shell history is logged. The fix was to mask the token even in get mode:

rust"token" | "api_token" => {
    // Always mask tokens, even in get mode
    let masked = mask_token(&value);
    println!("{}", masked);
}

A developer who genuinely needs the raw token can read the TOML file directly. The CLI should not make it easy to accidentally expose credentials.

Atomic Writes

The set subcommand writes the updated configuration atomically: write to a temporary file, then rename. On Unix, it also sets 0600 permissions on the config file, ensuring only the current user can read the token.

rustlet tmp_path = config_path.with_extension("toml.tmp");
std::fs::write(&tmp_path, toml::to_string_pretty(&config)?)?;

#[cfg(unix)]
{
    use std::os::unix::fs::PermissionsExt;
    std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600))?;
}

std::fs::rename(&tmp_path, &config_path)?;

This is the same atomic write pattern used by save_link() in the push command. When a tool writes files that the developer depends on, corruption on Ctrl+C is not acceptable.

The Code Sharing Pattern

Phase 2 created a pattern that Phase 3 and 4 would follow: new commands reuse existing infrastructure from push.rs and client.rs rather than reimplementing functionality.

Shared functionUsed by
save_link()push, link
read_link()push, open, watch
ALWAYS_EXCLUDEpush, init, watch
resolve_app()link, open, restart, stop, start, delete, domains
create_spinner()push, watch

This is not an abstraction layer. There is no trait CliCommand or CommandContext struct. Each command is a standalone module with a run() function. They share code by importing specific functions, not by inheriting from a base class.

The result is that each command file is self-contained and readable in isolation. A developer reading link.rs sees exactly what it does without tracing through an abstraction hierarchy. The trade-off is some function signatures appearing in multiple use statements, but that is a cost worth paying for clarity.

Audit Results

Phase 2 went through a single audit round (the implementation was simpler than Phase 1):

  • 0 Critical findings
  • 1 Important finding: duplicate patterns in .sh0ignore when stack-specific patterns overlapped with ALWAYS_EXCLUDE
  • 2 Minor findings (1 fixed): redundant "Could not detect stack" message when detection fails

The low finding count is evidence that the Phase 1 audit process worked. The patterns established in Phase 1 -- atomic writes, error propagation, shared constants -- carried forward into Phase 2 naturally.

The Ergonomics Thesis

None of these four commands is technically interesting. sh0 init runs a detector and writes a file. sh0 link makes an API call and writes a file. sh0 open makes an API call and spawns a process. sh0 config reads and writes TOML.

But together, they transform the developer experience. Before Phase 2, a developer's workflow was:

  1. sh0 push (deploy)
  2. Copy URL from terminal output, paste into browser
  3. Want to re-deploy a different directory? Delete .sh0/link.json, figure out the app name, create the link file manually

After Phase 2:

  1. sh0 init (one-time setup)
  2. sh0 push (deploy)
  3. sh0 open (see it live)
  4. sh0 link other-app (switch targets)

Four commands that each save 30 seconds. Over a day of development, that is minutes. Over a month, hours. Over the life of a project, the tool disappears into muscle memory. That is what ergonomics means.


Next in the series: App Lifecycle From the Terminal -- Five commands for managing running applications: restart, stop, start, delete, and domain management.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles