Back to sh0
sh0

De 10 commandes à 30 : le sprint d'ergonomie développeur

Comment nous avons ajouté sh0 init, link, open et config -- quatre commandes qui font du CLI sh0 un outil natif dans le workflow du développeur, pas une réflexion après coup.

Thales & Claude | March 30, 2026 8 min sh0
EN/ FR/ ES
clirustdeveloper-experienceergonomicsstack-detectionconfiguration

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 :

  1. Détecte la stack et affiche ce qu'il a trouvé
  2. Génère un fichier .sh0ignore avec 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 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 app

Sous 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.app

La 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_url

Masquage 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éeUtilisée par
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

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 .sh0ignore lorsque les motifs spécifiques à la stack chevauchaient ALWAYS_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 :

  1. sh0 push (déployer)
  2. Copier l'URL depuis la sortie du terminal, la coller dans le navigateur
  3. 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 :

  1. sh0 init (configuration initiale unique)
  2. sh0 push (déployer)
  3. sh0 open (voir en ligne)
  4. 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.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles