Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,33 @@

---

## 🐚 24 septembre 2026 — Un vrai shell, et une théorie qui dit ce que le terminal répond (THI-353)

*PR #389 · #390 · #391 · 198/198 exercices sans ligne rouge (ordinateur et mobile) · nouveau test permanent `lessonTheory` : 754 commandes de théorie rejouées*

La livraison précédente avait réparé les exercices. Il restait deux écarts : le terminal ne comprenait pas la syntaxe du shell, et la théorie montrait parfois une sortie que le terminal n'affiche pas.

- **Une vraie couche shell (#389).** `;`, `&&` et `||` enchaînent les commandes. Les pipes acceptent autant d'étapes qu'on veut. Les redirections `>`, `>>`, `2>`, `2>&1`, `&>` et `<` fonctionnent pour toutes les commandes, et `/dev/null` ou `$null` avalent vraiment la sortie. `tee` écrit réellement son fichier. `grep` sans résultat fait échouer la commande qui suit un `&&`, comme dans un vrai shell. Résultat : **plus aucun exercice ne se valide avec une ligne rouge**, sur les 198 combinaisons leçon × environnement, vérifié dans un navigateur, sur ordinateur et en mobile.
- **Le terminal affiche ce qu'un vrai shell affiche (#390).**
- `pwd` donne le chemin complet, et `cd -` revient au dossier précédent.
- `export` comprend `$PATH`, et `wc` compte les vrais octets, accents compris.
- `ls` écrit un nom par ligne quand sa sortie part dans un pipe.
- `apt` refuse de s'exécuter sans `sudo`, et `sudo` demande le mot de passe une fois par session. `sudo whoami` répond `root`.
- `rm -rf /` est refusé, comme par GNU `rm`.
- **La théorie corrigée, et surveillée (#391).**
- Treize exemples montraient une sortie que le terminal n'affiche pas : un `grep -n` qui annonçait la ligne 5 au lieu de la 6, un `wc` aux mauvais comptes, un `ls -l` sans heure, un `ping` Windows au mauvais TTL.
- Deux leçons montraient à l'élève Windows une commande bash à côté de sa version PowerShell. Chaque élève ne voit plus que la sienne.
- Sous Windows, `git init` affiche désormais `C:/Users/user/…`, comme Git pour Windows.
- Un test rejoue maintenant chaque session de terminal montrée dans une leçon, depuis l'état de départ de la leçon, dans les trois environnements, et compare avec ce qu'affiche le terminal. 390 commandes correspondent déjà.

Ce qui reste, et c'est dit :

- **174 écarts restent listés dans le test**, et cette liste ne peut que rétrécir. La plupart viennent des leçons Git et GitHub avancées : l'état de départ de ces leçons n'a pas encore l'historique (commits, branches, conflits) que leur théorie montre. Le reste concerne des outils pas encore simulés : `gh`, les tâches en arrière-plan.
- **39 leçons montrent encore du bash à un élève Windows**, faute de variante PowerShell. Ce nombre est suivi par le même test et ne peut que baisser.
- **Un bug grave découvert en chemin, corrigé dans la livraison suivante** : `mv fichier .` et `cp fichier dossier` écrasaient le dossier de destination au lieu d'y placer le fichier.

---

## 🖥️ 24 septembre 2026 — Le terminal dit enfin ce que la leçon enseigne (THI-353)

*PR #385 · #386 · #387 · test permanent `lessonFidelity` · 2558 tests · exercices « validés malgré une erreur » : 44 → 3*
Expand Down
10 changes: 10 additions & 0 deletions STORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ Ce projet a été construit avec l'aide de Claude — l'IA d'Anthropic, des mod

---

## Des tests qui se comparaient à eux-mêmes (24 septembre 2026)

Après les exercices, Thierry m'a demandé de vérifier la théorie : « tester visuellement chaque exercice sur base de la théorie ». J'ai écrit un script qui rejoue chaque session de terminal affichée dans une leçon et compare avec ce que notre terminal répond. Les premiers écarts étaient attendus : un `grep -n` qui annonçait la ligne 5 d'un fichier dont le titre est à la ligne 6, un `wc` aux comptes faux.

Le plus instructif est venu de `ls`. Envoyé dans un pipe ou dans un fichier, un vrai `ls` écrit un nom par ligne ; le nôtre les mettait tous sur une seule. Nos tests passaient pourtant, et pour une raison gênante : ils calculaient la valeur attendue en appelant le moteur lui-même. Le test demandait au terminal « combien de lignes vas-tu écrire ? », puis vérifiait que le terminal écrivait bien ce nombre. Il ne pouvait pas échouer. Un attendu doit venir de l'extérieur de ce qu'on teste : un vrai shell, une documentation, ou le texte même de la leçon. C'est pour cela que le nouveau garde-fou compare le terminal au texte des leçons, et jamais le terminal à lui-même.

Ce même rejeu a trouvé le bug le plus grave de la journée, là où personne ne regardait. La leçon `mv` montre `mv documents/notes.txt .`, c'est-à-dire « ramène le fichier ici ». Dans notre simulateur, cette commande remplaçait le dossier personnel entier par le fichier : l'élève perdait toute son arborescence au deuxième module. `cp fichier dossier` faisait la même chose au dossier visé. Aucun exercice ne tapait cette commande, et aucun test ne l'essayait. Seule la théorie la montrait, et c'est en la rejouant qu'on l'a vue.

---

## Le bug était dans la police, et 44 exercices qui mentaient poliment (23-24 septembre 2026)

Le Grand Check-up de l'été avait posé un diagnostic que je n'aimais pas : pour décider qu'un exercice était réussi, Terminal Learning lisait la commande tapée — et jamais ce que le terminal répondait. J'ai rejoué la solution de chaque exercice, dans chaque environnement, et compté. Sur 198 cas, 44 se validaient alors que l'écran affichait une erreur en rouge. Un élève tapait `git status` comme on le lui demandait, lisait `fatal: not a git repository`, et recevait des félicitations juste en dessous. Un mensonge poli, mais un mensonge : l'outil disait « bravo » à quelqu'un qui venait de voir que ça ne marchait pas.
Expand Down
13 changes: 10 additions & 3 deletions src/app/data/commands/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ function displayPath(cwd: string[]): string {
return '/' + cwd.join('/');
}

/** Git for Windows writes `C:/Users/user/…` (drive letter, forward slashes). */
function gitPath(cwd: string[], env: TerminalEnv): string {
if (env !== 'windows') return displayPath(cwd);
const home = cwd[0] === 'home' && cwd[1] === 'user';
return (home ? ['C:', 'Users', 'user', ...cwd.slice(2)] : ['C:', ...cwd]).join('/');
}

function getNode(root: DirectoryNode, path: string[]): FSNode | null {
let current: FSNode = root;
for (const seg of path) {
Expand All @@ -21,7 +28,7 @@ function makeHash(): string {
return Array.from({ length: 7 }, () => '0123456789abcdef'[Math.floor(Math.random() * 16)]).join('');
}

export function handleGit(newState: TerminalState, args: string[], _env: TerminalEnv): CommandOutput {
export function handleGit(newState: TerminalState, args: string[], env: TerminalEnv): CommandOutput {
const sub = args[0]?.toLowerCase() ?? '';

const requireRepo = (): OutputLine | null => {
Expand All @@ -36,7 +43,7 @@ export function handleGit(newState: TerminalState, args: string[], _env: Termina
case 'init': {
if (newState.git?.initialized) {
return {
lines: [{ text: `Reinitialized existing Git repository in ${displayPath(newState.cwd)}/.git/`, type: 'info' }],
lines: [{ text: `Reinitialized existing Git repository in ${gitPath(newState.cwd, env)}/.git/`, type: 'info' }],
newState,
};
}
Expand All @@ -53,7 +60,7 @@ export function handleGit(newState: TerminalState, args: string[], _env: Termina
};
return {
lines: [
{ text: `Initialized empty Git repository in ${displayPath(newState.cwd)}/.git/`, type: 'success' },
{ text: `Initialized empty Git repository in ${gitPath(newState.cwd, env)}/.git/`, type: 'success' },
{ text: "Hint: Use 'git add <file>' to stage files, 'git commit -m' to record changes.", type: 'info' },
],
newState,
Expand Down
53 changes: 28 additions & 25 deletions src/app/data/curriculum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ export const curriculum: Module[] = [
},
{
type: 'code',
content: '$ ls -l\ntotal 3\ndrwxr-xr-x 2 user user 4096 Mar 30 documents\ndrwxr-xr-x 2 user user 4096 Mar 30 downloads\ndrwxr-xr-x 2 user user 4096 Mar 30 projets',
content: '$ ls -l\ntotal 3\ndrwxr-xr-x 2 user user 4096 Mar 30 10:00 documents\ndrwxr-xr-x 2 user user 4096 Mar 30 10:00 downloads\ndrwxr-xr-x 2 user user 4096 Mar 30 10:00 projets',
label: 'ls -l (format long)',
},
{
Expand All @@ -249,7 +249,7 @@ export const curriculum: Module[] = [
},
{
type: 'code',
content: '$ ls -a\n. .. .bashrc .profile documents downloads projets',
content: '$ ls -a\n. .. .bashrc .profile .zshrc documents downloads projets',
label: 'ls -a (fichiers cachés)',
},
{
Expand Down Expand Up @@ -730,7 +730,7 @@ export const curriculum: Module[] = [
},
{
type: 'code',
content: '$ grep -n "Section" documents/rapport.md\n5:## Section 1',
content: '$ grep -n "Section" documents/rapport.md\n6:## Section 1',
label: 'grep -n (avec numéros de lignes)',
},
{
Expand Down Expand Up @@ -769,12 +769,12 @@ export const curriculum: Module[] = [
},
{
type: 'code',
content: '$ wc documents/notes.txt\n 6 20 145 documents/notes.txt',
content: '$ wc documents/notes.txt\n 6 22 143 documents/notes.txt',
label: 'wc complet (lignes mots octets)',
},
{
type: 'code',
content: '$ wc -l documents/notes.txt\n6 documents/notes.txt\n\n$ wc -w documents/notes.txt\n20 documents/notes.txt\n\n$ wc -c documents/notes.txt\n145 documents/notes.txt',
content: '$ wc -l documents/notes.txt\n6 documents/notes.txt\n\n$ wc -w documents/notes.txt\n22 documents/notes.txt\n\n$ wc -c documents/notes.txt\n143 documents/notes.txt',
label: 'Options -l (lignes) -w (mots) -c (octets)',
},
{
Expand Down Expand Up @@ -820,7 +820,7 @@ export const curriculum: Module[] = [
},
{
type: 'code',
content: '$ ls -l\n-rw-r--r-- 1 user user 145 notes.txt\ndrwxr-xr-x 2 user user 4096 documents',
content: '$ ls -l\ntotal 3\ndrwxr-xr-x 2 user user 4096 Mar 30 10:00 documents\ndrwxr-xr-x 2 user user 4096 Mar 30 10:00 downloads\ndrwxr-xr-x 2 user user 4096 Mar 30 10:00 projets\n\n$ ls -l documents/notes.txt\n-rw-r--r-- 1 user user 143 Mar 30 10:00 documents/notes.txt',
label: 'Affichage des permissions',
},
{
Expand Down Expand Up @@ -868,7 +868,7 @@ export const curriculum: Module[] = [
},
{
type: 'code',
content: '$ chmod 755 projets/script.sh\n$ ls -l projets/script.sh\n-rwxr-xr-x 1 user user 56 script.sh',
content: '$ chmod 755 projets/script.sh\n$ ls -l projets/script.sh\n-rwxr-xr-x 1 user user 68 Mar 30 10:00 projets/script.sh',
label: 'Notation octale (Linux/macOS)',
contentByEnv: {
windows: 'PS> icacls documents\\notes.txt\nnotes.txt BUILTIN\\Administrators:(I)(F)\n NT AUTHORITY\\SYSTEM:(I)(F)\n user:(I)(M)\n\nPS> Set-ExecutionPolicy RemoteSigned\n# Autorise les scripts locaux non signés',
Expand Down Expand Up @@ -936,7 +936,7 @@ export const curriculum: Module[] = [
},
{
type: 'code',
content: '$ ls -l documents/notes.txt\n-rw-r--r-- 1 user user 145 notes.txt\n# ↑ ↑\n# proprio groupe\n\n$ sudo chown alice notes.txt\n$ sudo chown alice:devs notes.txt\n$ sudo chown :devs notes.txt # changer seulement le groupe',
content: '$ ls -l documents/notes.txt\n-rw-r--r-- 1 user user 143 Mar 30 10:00 documents/notes.txt\n# ↑ ↑\n# proprio groupe\n\n$ sudo chown alice notes.txt\n$ sudo chown alice:devs notes.txt\n$ sudo chown :devs notes.txt # changer seulement le groupe',
label: 'chown — changer propriétaire (Linux/macOS)',
contentByEnv: {
windows: 'PS> Get-Acl documents\\notes.txt | Select-Object Owner\nOwner\n-----\nDESKTOP-ABC\\user\n\nPS> takeown /f documents\\notes.txt\n# Prendre possession du fichier\n\nPS> icacls documents\\notes.txt /setowner "BUILTIN\\Administrators"',
Expand Down Expand Up @@ -1000,7 +1000,7 @@ export const curriculum: Module[] = [
},
{
type: 'code',
content: '$ sudo apt update\n[sudo] password for user: ****\nHit:1 http://archive.ubuntu.com/ubuntu jammy InRelease\n...\n\n$ sudo -i # ouvrir un shell root\n$ sudo -l # lister les commandes autorisées\n$ sudo !! # relancer la dernière commande en sudo',
content: '$ sudo apt update\n[sudo] password for user: ****\nHit:1 http://archive.ubuntu.com/ubuntu noble InRelease\n...\n\n$ sudo -i # ouvrir un shell root\n$ sudo -l # lister les commandes autorisées\n$ sudo !! # relancer la dernière commande en sudo',
label: 'sudo — exemples courants (Linux)',
contentByEnv: {
macos: '$ sudo brew services restart nginx\n[sudo] password for user: ****\n...\n\n$ sudo -i # ouvrir un shell root\n$ sudo dscacheutil -flushcache # vider le cache DNS\n$ sudo -l # lister les commandes autorisées',
Expand Down Expand Up @@ -1456,7 +1456,7 @@ export const curriculum: Module[] = [
},
{
type: 'code',
content: '$ ls fichier-inexistant\nls: cannot access \'fichier-inexistant\': No such file or directory\n\n$ ls fichier-inexistant 2> erreurs.log\n# L\'erreur va dans le fichier, rien ne s\'affiche\n\n$ ls documents/ 2> erreurs.log\ndocuments/notes.txt documents/rapport.md\n# stdout s\'affiche, stderr va dans le fichier',
content: '$ ls fichier-inexistant\nls: cannot access \'fichier-inexistant\': No such file or directory\n\n$ ls fichier-inexistant 2> erreurs.log\n# L\'erreur va dans le fichier, rien ne s\'affiche\n\n$ ls documents/ 2> erreurs.log\nnotes.txt rapport.md\n# stdout s\'affiche, stderr va dans le fichier',
label: '2> — rediriger stderr seul',
contentByEnv: {
windows: 'PS> Get-Item fichier-inexistant\nGet-Item : Cannot find path...\n\nPS> Get-Item fichier-inexistant 2> erreurs.txt\n# L\'erreur va dans le fichier\n\nPS> Get-Item documents 2> erreurs.txt\ndirectory: C:\\Users\\user\\documents',
Expand Down Expand Up @@ -1626,22 +1626,27 @@ export const curriculum: Module[] = [
{
type: 'code',
content: '$ echo $PATH\n/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
label: 'Afficher le PATH (Linux/macOS)',
},
{
type: 'code',
content: 'PS> echo $env:PATH\nC:\\Windows\\System32;C:\\Windows;C:\\Program Files\\Git\\bin',
label: 'Afficher le PATH (Windows)',
label: 'Afficher le PATH',
contentByEnv: {
windows: 'PS> echo $env:PATH\nC:\\Windows\\System32;C:\\Windows;C:\\Program Files\\Git\\bin',
},
},
{
type: 'code',
content: '$ export PATH=$PATH:/opt/myapp/bin\n$ echo $PATH\n/usr/local/bin:/usr/bin:/bin:/opt/myapp/bin',
content: '$ export PATH=$PATH:/opt/myapp/bin\n$ echo $PATH\n/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/myapp/bin',
label: 'Ajouter un répertoire au PATH',
contentByEnv: {
windows: 'PS> $env:PATH = "$env:PATH;C:\\outils"\nPS> $env:PATH\nC:\\Windows\\System32;C:\\Windows;C:\\Program Files\\Git\\bin;C:\\outils',
},
},
{
type: 'warning',
content:
'Modifier le PATH avec `export` dans le terminal est temporaire (jusqu\'à la fermeture). Pour le rendre permanent, ajoutez-le à votre fichier de config shell (`.bashrc`, `.zshrc`).',
contentByEnv: {
windows:
'Modifier `$env:PATH` dans PowerShell est temporaire (jusqu\'à la fermeture de la fenêtre). Pour le rendre permanent, ajoutez la même ligne à votre profil PowerShell (le fichier `$PROFILE`).',
},
},
],
exercise: {
Expand Down Expand Up @@ -1866,7 +1871,7 @@ export const curriculum: Module[] = [
windows: 'Exemple (Windows)',
},
contentByEnv: {
windows: 'PS> ping google.com\nPinging google.com [142.250.74.46] with 32 bytes of data:\nReply from 142.250.74.46: bytes=32 time=12ms TTL=54\nReply from 142.250.74.46: bytes=32 time=11ms TTL=54\n\nPing statistics for 142.250.74.46:\n Packets: Sent = 4, Received = 4, Lost = 0 (0% loss)',
windows: 'PS> ping google.com\nPinging google.com [142.250.74.46] with 32 bytes of data:\nReply from 142.250.74.46: bytes=32 time=12ms TTL=117\nReply from 142.250.74.46: bytes=32 time=11ms TTL=117\n\nPing statistics for 142.250.74.46:\n Packets: Sent = 4, Received = 4, Lost = 0 (0% loss)',
},
},
{
Expand Down Expand Up @@ -2180,13 +2185,11 @@ export const curriculum: Module[] = [
},
{
type: 'code',
content: '# Créer un nouveau dépôt dans le répertoire courant\n$ git init\nInitialized empty Git repository in /home/user/mon-projet/.git/\n\n# Créer un dépôt avec un nom de répertoire\n$ git init mon-projet\nInitialized empty Git repository in /home/user/mon-projet/.git/',
label: 'git init (Linux/macOS)',
},
{
type: 'code',
content: '# PowerShell — même commande, multiplateforme\nPS> git init\nInitialized empty Git repository in C:\\Users\\user\\mon-projet\\.git\\\n\nPS> git init mon-projet',
label: 'git init (Windows PowerShell)',
content: '# Créer un dépôt dans un nouveau dossier\n$ git init mon-projet\nInitialized empty Git repository in /home/user/mon-projet/.git/\n$ cd mon-projet\n\n# Relancer git init dans un dépôt existant ne casse rien\n$ git init\nReinitialized existing Git repository in /home/user/mon-projet/.git/',
label: 'git init',
contentByEnv: {
windows: '# Même commande sous PowerShell (Git écrit les chemins avec des /)\nPS> git init mon-projet\nInitialized empty Git repository in C:/Users/user/mon-projet/.git/\nPS> cd mon-projet\n\nPS> git init\nReinitialized existing Git repository in C:/Users/user/mon-projet/.git/',
},
},
{
type: 'info',
Expand Down
10 changes: 8 additions & 2 deletions src/app/data/terminalEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,8 @@ function formatLongEntry(name: string, node: FSNode): string {
const perm = node.permissions;
const owner = node.owner;
const group = node.group;
const size = node.type === 'file' ? String(node.size).padStart(6) : ' 4096';
// Bytes on disk, computed from the content (the stored `size` is not kept up to date on edits).
const size = node.type === 'file' ? String(textCounts(node.content).bytes).padStart(6) : ' 4096';
const date = 'Mar 30 10:00';
const links = node.type === 'directory' ? ' 2' : ' 1';
return `${perm}${links} ${owner} ${group} ${size} ${date} ${name}`;
Expand Down Expand Up @@ -1851,7 +1852,12 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com
const psEnvSet = trimmed.match(/^\$env:([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
if (psEnvSet) {
const [, varName, rawValue] = psEnvSet;
const varValue = rawValue.replace(/^["']|["']$/g, '');
// Double quotes expand `$env:X` (`"$env:PATH;C:\outils"`); single quotes keep it literal.
const shown = varsForEnv(newState.envVars, env);
const unquoted = rawValue.replace(/^["']|["']$/g, '');
const varValue = rawValue.startsWith("'")
? unquoted
: unquoted.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/g, (_, ref: string) => shown[ref] ?? '');
return {
lines: [{ text: `$env:${varName} défini à "${varValue}"`, type: 'success' }],
newState: { ...newState, envVars: { ...newState.envVars, [varName]: varValue } },
Expand Down
Loading
Loading