Après l'arrivée de sh0 push, le CLI pouvait déployer. Mais déployer n'est qu'une seule action. La journée d'un développeur implique des dizaines de petites interactions avec ses outils : initialiser un projet, lier un répertoire à une application existante, ouvrir une URL, vérifier la configuration. Ce ne sont pas des fonctionnalités. C'est de l'ergonomie. Et l'ergonomie fait la différence entre un outil que les développeurs tolèrent et un outil vers lequel ils se tournent instinctivement.
La Phase 2 a ajouté quatre commandes en une seule session. Aucune n'est techniquement impressionnante. Toutes rendent le CLI complet.
sh0 init -- Détecter et préparer
Chaque outil de déploiement a une commande init. Vercel a vercel init. Fly a fly launch. L'objectif est toujours le même : examiner le projet en cours, détecter ce qu'il est, et le préparer pour le déploiement.
sh0 init fait deux choses :
- Détecte la stack et affiche ce qu'il a trouvé
- Génère un fichier
.sh0ignoreavec des motifs adaptés à la stack
$ sh0 init
Detected stack: nodejs
Framework: Next.js
Package manager: npm
Default port: 3000
Created .sh0ignore (12 patterns)La détection de stack réutilise la même fonction detect_stack() que sh0 push appelle. Il n'y a pas de logique de détection séparée. Une seule fonction, une seule source de vérité.
Motifs d'exclusion adaptés à la stack
La partie intéressante est la génération du .sh0ignore. Un projet Node.js devrait exclure node_modules/, .next/, .turbo/. Un projet Rust devrait exclure target/. Un projet Python devrait exclure __pycache__/, .venv/, *.pyc. Un projet Go devrait exclure le binaire de sortie.
Le générateur commence avec les motifs toujours exclus (partagés avec sh0 push) puis ajoute les motifs spécifiques à la stack :
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![],
}
}L'audit a détecté une subtilité : certains motifs spécifiques à la stack étaient déjà dans la liste ALWAYS_EXCLUDE. Le motif .next, par exemple, apparaissait à la fois dans la liste des toujours exclus et dans la liste spécifique à Node.js. La correction a été de dédupliquer : le générateur n'ajoute que les motifs qui ne sont pas déjà dans la liste de base. Cela évite des fichiers .sh0ignore confus avec des entrées en double.
sh0 link -- Connecter un répertoire à une application existante
sh0 push crée de nouvelles applications. Mais qu'en est-il d'une application existante qui a été déployée via le tableau de bord ou via Git ? Le développeur veut pousser des mises à jour depuis son terminal sans créer un doublon.
sh0 link résout ce problème :
$ sh0 link my-existing-app
Linked to my-existing-app
-> https://my-existing-app.sh0.app
Next push will update this appSous le capot, il appelle client.resolve_app("my-existing-app"), qui cherche dans la liste d'applications du serveur par nom ou UUID. S'il la trouve, il écrit le même .sh0/link.json que sh0 push crée lors d'un déploiement réussi :
rustpub async fn run(client: &Sh0Client, app: &str, path: Option<&str>) -> Result<()> {
let project_path = resolve_path(path)?;
// Résoudre l'app par nom ou ID
let app_info = client.resolve_app(app).await?;
// Récupérer les domaines pour afficher l'URL principale
let domains = client.get_app_domains(&app_info.id).await?;
let primary = domains.iter().find(|d| d.primary);
// Écrire le fichier de lien (réutilise 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(())
}La décision de conception clé a été de réutiliser save_link() de push.rs au lieu d'écrire une implémentation séparée. Cela garantit que le format du fichier de lien est identique, qu'il soit créé par push ou link. Les deux fonctions ont été rendues pub(crate) pendant la Phase 2 pour permettre ce partage.
sh0 open -- Ouvrir l'URL dans un navigateur
C'est la commande la plus simple de tout le CLI. Elle lit le fichier de lien ou résout un argument d'application, récupère le domaine principal et l'ouvre dans le navigateur par défaut.
$ sh0 open
Opening https://my-app.sh0.appLa logique d'ouverture du navigateur est sensible à la plateforme :
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(())
}Deux plateformes, deux commandes. sh0 cible les serveurs Linux et les machines de développement macOS. Le support Windows n'est pas une priorité car la cible de déploiement est toujours Linux.
Sans argument d'application, sh0 open lit .sh0/link.json via push::read_link() -- la même fonction que sh0 push utilise pour détecter les re-pushs. Avec un argument, il résout l'application par nom ou ID via l'API. Dans les deux cas, il récupère la liste des domaines pour trouver l'URL principale.
C'est six lignes de code intéressant et soixante lignes de gestion d'erreurs. Ce ratio est typique des outils CLI.
sh0 config -- Gérer le fichier de configuration
Le CLI sh0 stocke sa configuration dans ~/.sh0/config.toml. La commande config fournit trois sous-commandes pour la gérer :
$ 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_urlMasquage du token
La sous-commande show masque le token, n'affichant que les 12 premiers caractères suivis de <em>*</em>*. La sous-commande get ne masque pas -- elle renvoie la valeur brute pour le scripting et le piping.
L'audit global a ensuite détecté un problème : sh0 config get token affichait le token brut sur stdout. C'est un problème de sécurité dans les terminaux partagés ou lorsque l'historique du shell est journalisé. La correction a été de masquer le token même en mode get :
rust"token" | "api_token" => {
// Toujours masquer les tokens, même en mode get
let masked = mask_token(&value);
println!("{}", masked);
}Un développeur qui a véritablement besoin du token brut peut lire le fichier TOML directement. Le CLI ne devrait pas faciliter l'exposition accidentelle d'identifiants.
Écritures atomiques
La sous-commande set écrit la configuration mise à jour de manière atomique : écriture dans un fichier temporaire, puis renommage. Sous Unix, elle définit également les permissions 0600 sur le fichier de configuration, garantissant que seul l'utilisateur courant peut lire le 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)?;C'est le même motif d'écriture atomique utilisé par save_link() dans la commande push. Quand un outil écrit des fichiers dont le développeur dépend, la corruption lors d'un Ctrl+C n'est pas acceptable.
Le motif de partage de code
La Phase 2 a créé un motif que les Phases 3 et 4 allaient suivre : les nouvelles commandes réutilisent l'infrastructure existante de push.rs et client.rs plutôt que de réimplémenter les fonctionnalités.
| Fonction partagée | Utilisée par |
|---|---|
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 |
Ce n'est pas une couche d'abstraction. Il n'y a pas de trait CliCommand ni de struct CommandContext. Chaque commande est un module autonome avec une fonction run(). Elles partagent du code en important des fonctions spécifiques, pas en héritant d'une classe de base.
Le résultat est que chaque fichier de commande est autonome et lisible isolément. Un développeur lisant link.rs voit exactement ce qu'il fait sans parcourir une hiérarchie d'abstraction. Le compromis est que certaines signatures de fonctions apparaissent dans plusieurs instructions use, mais c'est un coût qui vaut la peine d'être payé pour la clarté.
Résultats d'audit
La Phase 2 a traversé un seul round d'audit (l'implémentation était plus simple que la Phase 1) :
- 0 Critique
- 1 Important : motifs en double dans
.sh0ignorelorsque les motifs spécifiques à la stack chevauchaientALWAYS_EXCLUDE - 2 Mineurs (1 corrigé) : message « Could not detect stack » redondant lorsque la détection échoue
Le faible nombre de découvertes prouve que le processus d'audit de la Phase 1 a fonctionné. Les motifs établis en Phase 1 -- écritures atomiques, propagation d'erreurs, constantes partagées -- se sont naturellement propagés à la Phase 2.
La thèse de l'ergonomie
Aucune de ces quatre commandes n'est techniquement intéressante. sh0 init exécute un détecteur et écrit un fichier. sh0 link fait un appel API et écrit un fichier. sh0 open fait un appel API et lance un processus. sh0 config lit et écrit du TOML.
Mais ensemble, elles transforment l'expérience développeur. Avant la Phase 2, le workflow d'un développeur était :
sh0 push(déployer)- Copier l'URL depuis la sortie du terminal, la coller dans le navigateur
- Envie de redéployer un autre répertoire ? Supprimer
.sh0/link.json, trouver le nom de l'app, créer le fichier de lien manuellement
Après la Phase 2 :
sh0 init(configuration initiale unique)sh0 push(déployer)sh0 open(voir en ligne)sh0 link other-app(changer de cible)
Quatre commandes qui font chacune gagner 30 secondes. Sur une journée de développement, ce sont des minutes. Sur un mois, des heures. Sur la vie d'un projet, l'outil disparaît dans la mémoire musculaire. C'est ce que signifie l'ergonomie.
Prochain dans la série : Cycle de vie applicatif depuis le terminal -- Cinq commandes pour gérer les applications en cours d'exécution : restart, stop, start, delete et gestion des domaines.