sh0 avait déjà un CLI. Dix commandes, construites dès le premier jour, reproduisant chaque action du tableau de bord. Déployer, consulter les logs, gérer les variables d'environnement, vérifier l'état de santé, se connecter en SSH aux conteneurs. Mais il y avait un vide qu'aucune de ces commandes ne comblait.
Un développeur clone un dépôt. Il écrit du code. Il veut le voir en ligne. À ce moment-là, il ne devrait pas avoir à ouvrir un navigateur, naviguer vers un tableau de bord, créer une application, configurer un dépôt Git, attendre un webhook, puis déclencher un build. Il devrait taper une seule commande et obtenir une URL.
Cette commande, c'est sh0 push.
$ sh0 push
Pushing my-app
Detected nodejs (Next.js) -- 85/100 health
42 files (2.3 MB) packaged
Uploading OK 0.8s
Building OK 32.4s
[ok] Live in 35.3s
-> https://my-app.sh0.appSix lignes de sortie. Zéro configuration. Du répertoire local à l'URL live en 35 secondes.
Cet article explique chaque couche de l'implémentation -- de l'empaquetage des fichiers au polling de déploiement -- ainsi que les décisions de sécurité qui ont façonné le design final.
Le problème : trop d'étapes entre le code et l'URL
Avant sh0 push, déployer sur sh0 nécessitait cinq étapes :
- Créer une application dans le tableau de bord
- Connecter un dépôt Git
- Configurer les paramètres de build
- Pousser vers Git
- Attendre que le webhook déclenche un build
C'est acceptable pour les workflows de production. C'est terrible pour le moment où un développeur pense « je veux voir ça en ligne ». Ce moment exige de l'immédiateté. Chaque étape supplémentaire est de la friction, et la friction tue l'adoption.
Nous avons étudié ce que Vercel a fait avec vercel --prod, ce que Fly.io a fait avec fly deploy, et ce qu'Instapods a démontré avec instapods deploy my-app. Le schéma est toujours le même : détecter le projet, empaqueter les fichiers, les téléverser, builder sur le serveur, renvoyer une URL.
L'insight clé était que sh0 possédait déjà 90 % de l'infrastructure côté serveur. Le endpoint d'upload existait. La détection de stack existait. Le pipeline de build existait. La création automatique de domaine existait. Ce qui manquait, c'était la colle côté CLI -- une seule commande orchestrant le flux complet.
Étape 1 : détection de stack (réutiliser l'existant)
Le système de build de sh0 inclut déjà un détecteur de stack qui reconnaît 19 stacks technologiques en examinant les fichiers du projet :
rustlet stack_result = detect_stack(&project_path, ".").await;
if let Some(ref stack) = stack_result {
let health = check_health(&project_path, ".").await;
print_step(&format!(
"Detected {} ({}) -- {}/100 health",
stack.stack_type, stack.framework, health.score
));
}Le détecteur lit package.json, Cargo.toml, requirements.txt, go.mod, composer.json et des dizaines d'autres marqueurs de projet. Il renvoie le type de stack, le framework, le gestionnaire de paquets et le port par défaut. Le vérificateur de santé exécute ensuite 34 règles sur le projet -- vérifiant la présence de Dockerfiles, de .dockerignore, la configuration des variables d'environnement et les signaux de maturité pour la production.
Les deux appels sont encapsulés dans .ok() pour que push fonctionne même lorsque la détection échoue. Un projet sans stack reconnaissable peut quand même être poussé -- le serveur se rabat sur la détection basée sur le Dockerfile.
Étape 2 : empaquetage des fichiers dans un ZIP
C'est ici que les décisions de sécurité commencent à compter. Le CLI crée une archive ZIP en mémoire du répertoire du projet, mais il doit exclure les fichiers qui ne devraient jamais quitter la machine du développeur.
La hiérarchie d'exclusion comporte trois niveaux :
.sh0ignore-- Exclusions spécifiques au projet (priorité la plus haute).dockerignore-- Convention Docker (repli).gitignore-- Convention Git (dernier recours)- Motifs toujours exclus -- 21 motifs codés en dur, exclus quoi qu'il arrive
La liste des motifs toujours exclus a fait l'objet de la première découverte critique de l'audit. Voici ce que nous livrons :
rustpub(crate) const ALWAYS_EXCLUDE: &[&str] = &[
".git", "node_modules", ".next", ".nuxt", ".output",
"target", "__pycache__", ".venv", "venv", ".tox",
"dist", "build", ".svelte-kit", ".turbo", ".cache",
".DS_Store", "*.pyc", "*.pyo", ".sh0",
".env*", // Critique : wildcard, pas des entrées individuelles
".idea", ".vscode",
];L'implémentation originale listait .env, .env.local, .env.production, .env.development comme entrées séparées. L'auditeur a immédiatement signalé ceci : .env.staging, .env.test, .env.custom-anything passeraient à travers. La correction a été un seul motif wildcard .env* qui attrape toutes les variantes.
Gardes de taille
Après la correction .env*, le deuxième audit a ajouté des limites de ressources côté client :
rustconst MAX_ARCHIVE_SIZE: u64 = 500 * 1024 * 1024; // 500 Mo
const MAX_FILE_COUNT: u64 = 50_000;
// Pendant la création du ZIP :
cumulative_size += content.len() as u64;
file_count += 1;
if cumulative_size > MAX_ARCHIVE_SIZE {
anyhow::bail!("Archive exceeds 500 MB limit");
}
if file_count > MAX_FILE_COUNT {
anyhow::bail!("Archive exceeds 50,000 file limit");
}Sans ces gardes, un développeur pourrait accidentellement essayer de pousser un répertoire contenant des artefacts de build ou des fichiers de données, consommant toute la mémoire disponible pendant la création du ZIP. Le serveur valide déjà la taille d'upload, mais la détecter côté client évite une mauvaise expérience.
Étape 3 : le client d'upload
Téléverser une archive ZIP n'est pas la même chose que faire un appel API JSON. Le client HTTP par défaut a un timeout de 30 secondes -- suffisant pour les requêtes API, insuffisant pour téléverser une archive de 200 Mo sur une connexion lente.
rustpub fn upload_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()
.context("Failed to build upload HTTP client")
}L'implémentation originale avalait les erreurs du builder et se rabattait sur un client non configuré avec un timeout de 30 secondes. Cela a été signalé comme Important lors du premier audit : un développeur téléversant un gros projet obtiendrait un timeout silencieux sans aucune indication de la raison. La correction a consisté à faire renvoyer Result<reqwest::Client> par upload_client(), forçant les appelants à gérer l'erreur explicitement.
Le téléversement lui-même utilise un POST multipart :
rustlet form = reqwest::multipart::Form::new()
.part("file", reqwest::multipart::Part::bytes(zip_data)
.file_name("source.zip")
.mime_str("application/zip")?
)
.text("name", app_name.clone())
.text("port", port.to_string());
// Nouvelle app vs re-push vers une app existante
let url = if let Some(app_id) = existing_app_id {
format!("{}/api/v1/apps/{}/upload", base_url, app_id)
} else {
format!("{}/api/v1/apps/upload", base_url)
};Deux endpoints, un pour créer une nouvelle application et un pour re-téléverser vers une application existante. Le endpoint de re-upload était du nouveau code côté serveur : il réutilise l'enregistrement d'application existant, crée un nouveau déploiement avec triggered_by: "cli-push", et inclut une garde contre les déploiements concurrents qui renvoie HTTP 409 si un build est déjà en cours.
Étape 4 : polling pour la fin du build
Après le téléversement, le serveur renvoie un identifiant de déploiement. Le CLI interroge le statut du build toutes les 1,5 secondes, diffusant les nouvelles lignes de log de manière incrémentale :
rustlet spinner = create_spinner("Building");
let mut last_log_len = 0;
loop {
let deployment = client.get_deployment(&deploy_id).await?;
// Diffuser les nouvelles lignes de log
if let Some(ref log) = deployment.build_log {
if log.len() > last_log_len {
let new_content = &log[last_log_len..];
for line in new_content.lines() {
update_phase_from_log(line, &spinner);
}
last_log_len = log.len();
}
}
match deployment.status.as_str() {
"running" => {
spinner.finish_with_message("OK");
break; // Succès
}
"failed" => {
spinner.finish_with_message("FAILED");
return Err(anyhow!("Deployment failed"));
}
_ => {} // Encore en cours de build, continuer le polling
}
tokio::time::sleep(Duration::from_millis(1500)).await;
}Le nettoyage du spinner a été une autre découverte de l'audit. Le code original ne nettoyait pas le spinner en cas d'erreurs réseau pendant le polling, laissant le terminal dans un état corrompu. La correction a été un bloc match explicite qui finalise le spinner sur chaque chemin de sortie.
Étape 5 : le fichier de lien
Lors d'un déploiement réussi, le CLI enregistre un fichier .sh0/link.json dans le répertoire du projet :
json{
"app_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"app_name": "my-app",
"server_url": "https://sh0.example.com"
}Ce fichier remplit la même fonction que le répertoire .vercel/ de Vercel : il lie un répertoire local à une application distante. La prochaine fois que le développeur exécute sh0 push, le CLI lit le fichier de lien et redéploie vers la même application au lieu d'en créer une nouvelle.
L'opération d'écriture est atomique : le CLI écrit dans un fichier temporaire puis appelle std::fs::rename, qui est atomique sur les systèmes POSIX. Cela empêche la corruption si le processus est interrompu pendant l'écriture.
Le problème du nom d'application
Dériver le nom d'application du nom de répertoire semble simple jusqu'à ce qu'on considère les cas limites. La fonction sanitize_app_name originale utilisait char::is_alphanumeric() pour filtrer les caractères -- mais is_alphanumeric() accepte l'Unicode. Un développeur dont le répertoire est nommé en caractères chinois ou arabes passerait la validation côté client, pour ensuite échouer avec une erreur de validation serveur confuse (le serveur exige des noms en ASCII uniquement).
L'audit de Round 2 a détecté cela :
rust// Avant (cassé) : accepte l'Unicode
name.chars()
.filter(|c| c.is_alphanumeric() || *c == '-')
.collect()
// Après (correct) : ASCII uniquement
name.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect()Une correction d'un seul caractère -- is_alphanumeric vers is_ascii_alphanumeric -- qui empêche toute une classe d'erreurs confuses pour les développeurs du monde entier.
Côté serveur : le endpoint de re-upload
Le nouveau endpoint POST /api/v1/apps/:id/upload gère le re-push vers des applications existantes. La partie la plus intéressante est la garde contre les déploiements concurrents :
rust// Vérifier les déploiements actifs
if Deployment::has_active_by_app_id(&conn, app_id)? {
return Err(ApiError::Conflict(
"A deployment is already in progress for this app".into()
));
}La requête has_active_by_app_id vérifie six statuts actifs : queued, building, pushing, starting, pulling, uploading. Si un déploiement se trouve dans l'un de ces états, le endpoint renvoie HTTP 409 Conflict au lieu de lancer un second build. Sans cette garde, deux commandes sh0 push rapides pourraient créer des déploiements concurrents qui interfèrent entre eux.
Le piège de l'exemption CSRF
L'API de sh0 utilise une protection CSRF sur les requêtes modifiant l'état. Les endpoints d'upload devaient en être exemptés (ils utilisent l'authentification par Bearer token depuis le CLI, pas les cookies du navigateur). L'exemption originale utilisait :
rustif path.contains("/upload") {
// Sauter la vérification CSRF
}C'était la découverte critique C-2 : toute route contenant « upload » n'importe où dans le chemin sauterait la vérification CSRF. Si un futur développeur ajoutait /api/v1/settings/upload-config, il contournerait silencieusement la protection CSRF. La correction a été une correspondance exacte des chemins :
rustif path == "/api/v1/apps/upload"
|| (path.starts_with("/api/v1/apps/") && path.ends_with("/upload")) {
// Sauter la vérification CSRF -- uniquement les endpoints d'upload
}Les résultats d'audit
La Phase 1 a traversé deux rounds d'audit indépendants :
Round 1 : 3 Critiques, 6 Importants, 5 Mineurs.
- Fuite de secrets .env* (Critique)
- Exemption CSRF trop large (Critique)
- process::exit(1) en contexte asynchrone (Critique)
- Le client d'upload avale les erreurs (Important)
- Pas de gardes de taille/nombre pour le ZIP (Important)
- Écriture non atomique du fichier de lien (Important)
- Pas de garde contre les déploiements concurrents (Important)
- Corruption du terminal par le spinner (Important)
- Chemins OpenAPI manquants (Important)
Round 2 : Vérification de toutes les 9 corrections du Round 1, 2 problèmes Importants supplémentaires découverts.
- Unicode dans sanitize_app_name (Important)
- Détection de ZIP vide utilisant la taille en octets au lieu du nombre de fichiers (Important)
Chaque découverte Critique et Importante a été corrigée. Le code est passé de 36 tests à 37, avec un nouveau test spécifiquement pour la correspondance wildcard .env*.
Pourquoi c'est important
sh0 push n'est pas techniquement complexe. C'est de la création de ZIP, du téléversement HTTP et du polling. N'importe quel développeur pourrait l'écrire en un week-end.
Ce qui le rend difficile, c'est d'obtenir les détails corrects. La fuite .env* aurait envoyé des secrets au serveur. L'exemption CSRF aurait affaibli la sécurité de chaque future route. Le nom d'application Unicode aurait produit des erreurs confuses pour les développeurs dans des pays à alphabet non latin. L'écriture non atomique du fichier de lien aurait corrompu l'état lors d'un Ctrl+C.
Ce sont les détails qui séparent un outil de déploiement d'un outil de déploiement de production. Et ils ont tous été détectés non pas par le développeur qui a écrit le code, mais par des auditeurs indépendants le relisant avec un regard neuf.
C'est pourquoi sh0 utilise une méthodologie d'audit multi-sessions : construire, auditer, auditer, approuver. Le constructeur optimise pour les fonctionnalités. Les auditeurs optimisent pour la correction. La méthodologie converge vers les deux.
Prochain dans la série : De 10 commandes à 30 : le sprint d'ergonomie développeur -- Quatre nouvelles commandes en une seule session : sh0 init, sh0 link, sh0 open et sh0 config.