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:
- Detects the stack and prints what it found
- Generates a
.sh0ignorefile 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 link -- Connect a Directory to an Existing App
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 appUnder 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.appThe 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_urlToken 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 function | Used by |
|---|---|
save_link() | push, link |
read_link() | push, open, watch |
ALWAYS_EXCLUDE | push, 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
.sh0ignorewhen stack-specific patterns overlapped withALWAYS_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:
sh0 push(deploy)- Copy URL from terminal output, paste into browser
- Want to re-deploy a different directory? Delete
.sh0/link.json, figure out the app name, create the link file manually
After Phase 2:
sh0 init(one-time setup)sh0 push(deploy)sh0 open(see it live)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.