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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@

---

## 🧪 29 septembre 2026 — Chaque exemple de la Référence fonctionne dans le terminal

*Moteur du terminal · exemples de la page Référence en erreur : 32 → 0 · écarts de la théorie : 160 → 158 · blocs bash montrés sous Windows : 37 → 34*

La livraison précédente laissait **32 exemples** de la page Référence en erreur, parce que le simulateur ne connaissait pas certaines commandes. Il les connaît maintenant, et chaque sortie a été comparée à celle d'un vrai bash, d'un vrai PowerShell 7, du vrai Git et du vrai OpenSSH.

- **Les commandes et options qui manquaient.** Le simulateur connaît maintenant `cat -n`, `grep -r` sur un dossier et `date +FORMAT`. Côté PowerShell, il ajoute `Get-Content -TotalCount/-Tail`, `Set-Content`, `Add-Content`, `Get-Date -Format`, `Get-History`, `Get-Help`, `tasklist` et `Get-ScheduledTask`. `Select-String` ignore les majuscules, comme le vrai PowerShell.
- **Les variables fonctionnent partout.** `cd $HOME`, `ls $HOME` ou `Get-Content $env:USERPROFILE\.ssh\id_ed25519.pub` remplacent la variable par sa valeur, comme un vrai shell. Avant, seul `echo` le faisait. Entre guillemets simples, `'$HOME'` reste écrit tel quel, tout comme `\$HOME`. `~` désigne le dossier personnel, et `$HOME` fonctionne aussi dans PowerShell.
- **`ssh-keygen` crée de vraies clés.** La commande écrit `~/.ssh/id_ed25519` (lisible par vous seul) et `id_ed25519.pub`. On peut ensuite lire la clé publique avec `cat` et l'envoyer avec `ssh-copy-id`. Par défaut, la clé est de type ed25519, comme dans OpenSSH depuis la version 9.5. L'empreinte est calculée comme dans OpenSSH (SHA-256 de la clé publique), et `ssh-keygen -l` la réaffiche. Le petit dessin (randomart) suit le même calcul. Sur des clés réelles, empreinte et dessin sont identiques au caractère près.
- **Un bogue Git corrigé.** `git commit -am "message"` perdait son message. Un `git commit` sans rien à valider affiche maintenant le même message que Git, et il échoue comme lui.
- **Les leçons Windows ne font plus taper de commandes Linux.** Dans « Anatomie d'une commande », l'élève Windows tape `Get-Help Get-ChildItem`. Dans « cron », il tape `Get-ScheduledTask`, car `crontab` n'existe pas dans PowerShell. La leçon SSH dit que `ssh-copy-id` n'est pas fourni sous Windows et donne la commande de remplacement.
- **Des sorties corrigées d'après le vrai shell.** `Select-String` sans résultat ne signale plus d'échec, et `Get-Help` n'affiche plus la syntaxe bash. La leçon SSH montrait un faux dialogue (`Enter file (.ssh/id_ed25519): [Entrée]`) : elle montre maintenant les vraies questions d'OpenSSH.

---

## 📖 26 septembre 2026 — Une page Référence qui explique, et dont chaque exemple fonctionne

*Page `/app/reference` · 76 commandes · nouveau test permanent `commandReference` · exemples en erreur dans le terminal : 224 → 32*
Expand Down
12 changes: 12 additions & 0 deletions STORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ Ce projet a été construit avec l'aide de Claude — l'IA d'Anthropic, des mod

---

## Le simulateur contre la réalité (29 septembre 2026)

La livraison précédente s'était arrêtée sur une liste honnête : 32 exemples de la page Référence échouaient encore, parce que notre terminal ne connaissait pas certaines commandes. Thierry m'a confié la suite sans condition : « s'il y a des lacunes à corriger sur le simulateur, tu les améliores ». La liste est vide.

La règle ne change pas : ce qu'un test attend vient d'un vrai shell, jamais de notre moteur. Pour `ssh-keygen`, j'ai généré une vraie clé dans un dossier jetable et relevé chaque phrase qu'OpenSSH affiche. Le petit dessin qui accompagne la clé, le « randomart », n'est pas décoratif : OpenSSH le calcule à partir de l'empreinte de la clé. J'ai écrit le même calcul et je lui ai donné l'empreinte réelle. Il a produit exactement le même dessin. C'est ce dessin-là que le test attend.

Le vrai shell a aussi corrigé mes propres certitudes. Notre ancien test affirmait que `ssh-keygen` crée une clé RSA par défaut. C'était vrai il y a quelques années ; depuis OpenSSH 9.5, c'est ed25519. Le test était faux, pas le shell. Autre surprise : une fois les variables remplacées partout comme dans un vrai bash, une leçon s'est mise à échouer. Elle montrait un bloc PowerShell aux élèves Linux, et bash l'aurait lu de travers. Désormais, chaque élève ne voit plus que la version de son propre shell.

Un agent a ensuite comparé le simulateur au vrai shell, commande par commande. Il a trouvé ce que nos tests ne pouvaient pas voir : `Select-String` signalait un échec quand il ne trouvait rien, et `Get-Help` affichait une syntaxe bash à un élève PowerShell. Ces écarts sont corrigés. D'autres sont notés pour la suite : les branches Git du simulateur ne séparent pas encore les fichiers, et `Get-ChildItem` n'affiche pas encore son tableau habituel.

---

## Une référence qu'on peut recopier (26 septembre 2026)

La page Référence ressemblait à un aide-mémoire sérieux : syntaxe, exemples, erreurs courantes, liens officiels. Je l'ai traitée comme les leçons, en rejouant chaque exemple dans notre terminal. Près de la moitié échouait. Personne n'avait menti : `cat notes.txt` est un bon exemple dans l'absolu. Mais l'élève qui le recopie dans une leçon n'a pas de `notes.txt` à cet endroit, et il reçoit une erreur rouge sur la page même censée l'aider.
Expand Down
97 changes: 97 additions & 0 deletions src/app/data/commands/dateFormat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Date formatting as the real tools do it, in the C / en-US locale the rest of
* the simulator uses: `date +FORMAT` (strftime, GNU and BSD date) and
* `Get-Date -Format` (.NET custom format strings).
*/

const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];

const pad = (n: number, width = 2, fill = '0') => String(n).padStart(width, fill);

/**
* Short time zone name of the learner's machine as the browser gives it: CEST in
* Europe, but often an offset such as GMT-4 elsewhere (no locale has every
* abbreviation), UTC when unknown.
*/
function zoneName(d: Date): string {
const part = new Intl.DateTimeFormat('en-GB', { timeZoneName: 'short' }).formatToParts(d).find((p) => p.type === 'timeZoneName');
return part?.value ?? 'UTC';
}

function dayOfYear(d: Date): number {
// Calendar days in UTC, so a daylight-saving change cannot shift the count.
return (Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) - Date.UTC(d.getFullYear(), 0, 1)) / 86_400_000 + 1;
}

/** strftime subset: the conversions a learner meets (%Y %m %d %H %M %S %a %A %b %B %e %j %y %Z %F %T %s %%). */
export function strftime(d: Date, format: string): string {
return format.replace(/%([a-zA-Z%])/g, (whole, c: string) => {
switch (c) {
case 'Y': return String(d.getFullYear());
case 'y': return pad(d.getFullYear() % 100);
case 'm': return pad(d.getMonth() + 1);
case 'd': return pad(d.getDate());
case 'e': return pad(d.getDate(), 2, ' ');
case 'H': return pad(d.getHours());
case 'M': return pad(d.getMinutes());
case 'S': return pad(d.getSeconds());
case 'a': return DAYS[d.getDay()].slice(0, 3);
case 'A': return DAYS[d.getDay()];
case 'b': return MONTHS[d.getMonth()].slice(0, 3);
case 'B': return MONTHS[d.getMonth()];
case 'j': return pad(dayOfYear(d), 3);
case 'Z': return zoneName(d);
case 'F': return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
case 'T': return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
case 's': return String(Math.floor(d.getTime() / 1000));
case '%': return '%';
default: return whole;
}
});
}

/**
* .NET custom date format (Get-Date -Format "yyyy-MM-dd HH:mm"). Tokens are the
* longest run of one letter; text in quotes and other characters pass through.
*/
export function dotnetDateFormat(d: Date, format: string): string {
const hours12 = d.getHours() % 12 || 12;
const token = (t: string): string => {
const n = t.length;
switch (t[0]) {
case 'y': return n <= 2 ? pad(d.getFullYear() % 100, n) : pad(d.getFullYear(), n);
case 'M': return n === 1 ? String(d.getMonth() + 1) : n === 2 ? pad(d.getMonth() + 1) : n === 3 ? MONTHS[d.getMonth()].slice(0, 3) : MONTHS[d.getMonth()];
case 'd': return n === 1 ? String(d.getDate()) : n === 2 ? pad(d.getDate()) : n === 3 ? DAYS[d.getDay()].slice(0, 3) : DAYS[d.getDay()];
case 'H': return n === 1 ? String(d.getHours()) : pad(d.getHours());
case 'h': return n === 1 ? String(hours12) : pad(hours12);
case 'm': return n === 1 ? String(d.getMinutes()) : pad(d.getMinutes());
case 's': return n === 1 ? String(d.getSeconds()) : pad(d.getSeconds());
case 't': return n === 1 ? (d.getHours() < 12 ? 'A' : 'P') : d.getHours() < 12 ? 'AM' : 'PM';
default: return t;
}
};
let out = '';
for (let i = 0; i < format.length; ) {
const c = format[i];
if (c === "'" || c === '"') {
const end = format.indexOf(c, i + 1);
out += format.slice(i + 1, end === -1 ? undefined : end);
i = end === -1 ? format.length : end + 1;
} else if ('yMdHhmst'.includes(c)) {
let j = i;
while (format[j] === c) j++;
out += token(format.slice(i, j));
i = j;
} else {
out += c;
i++;
}
}
return out;
}

/** What `Get-Date` prints without a format, in the en-US culture: "Saturday, September 26, 2026 9:24:25 PM". */
export function psDefaultDate(d: Date): string {
return dotnetDateFormat(d, 'dddd, MMMM d, yyyy h:mm:ss tt');
}
12 changes: 7 additions & 5 deletions src/app/data/commands/env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { TerminalState, TerminalEnv, CommandOutput, OutputLine } from './types';

/** Longest value a variable keeps (same bound as the terminal's anti-flooding cap). */
const MAX_ENV_VAR_LENGTH = 1024;

export function cmdExport(args: string[], state: TerminalState): { lines: OutputLine[]; envVars: Record<string, string> } {
if (args.length === 0) {
const lines = Object.entries(state.envVars).map(([k, v]) => ({
Expand All @@ -16,11 +19,10 @@ export function cmdExport(args: string[], state: TerminalState): { lines: Output
lines.push({ text: '', type: 'output' });
} else {
const name = arg.slice(0, eqIdx);
// Bash expands the value when it is assigned: `export PATH=$PATH:/opt/bin`.
const value = arg
.slice(eqIdx + 1)
.replace(/^["']|["']$/g, '')
.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (_, ref: string) => newEnv[ref] ?? '');
// The value arrives expanded and unquoted (`export PATH=$PATH:/opt/bin`), like bash hands it over.
const raw = arg.slice(eqIdx + 1);
// Capped [H3]: `export A=$A$A` repeated must not grow without bound.
const value = raw.length > MAX_ENV_VAR_LENGTH ? raw.slice(0, MAX_ENV_VAR_LENGTH) : raw;
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
lines.push({ text: `export: '${name}': not a valid identifier`, type: 'error' });
} else {
Expand Down
73 changes: 56 additions & 17 deletions src/app/data/commands/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ function makeHash(): string {
return Array.from({ length: 7 }, () => '0123456789abcdef'[Math.floor(Math.random() * 16)]).join('');
}

/**
* The message of `git commit`, in every form git accepts: `-m msg`, `-mmsg`,
* `-am msg` (short options bundled, m last), `--message=msg`, `--message msg`.
*/
function commitMessage(args: string[]): string {
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--message') return args[i + 1] ?? '';
if (a.startsWith('--message=')) return a.slice('--message='.length);
if (/^-[a-zA-Z]*m/.test(a)) {
const attached = a.slice(a.indexOf('m') + 1);
return attached || (args[i + 1] ?? '');
}
}
return '';
}

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

Expand Down Expand Up @@ -139,13 +156,16 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal
const repoErr = requireRepo();
if (repoErr) return { lines: [repoErr], newState };
const g = newState.git!;
const mIdx = args.indexOf('-m');
const message = mIdx >= 0 ? (args[mIdx + 1] ?? '') : '';
const message = commitMessage(args);
if (!message) {
return { lines: [{ text: 'Abort: empty commit message.\nUsage: git commit -m "your message"', type: 'error' }], newState };
}
if (g.stagedFiles.length === 0) {
return { lines: [{ text: 'nothing to commit, working tree clean', type: 'info' }], newState };
// Git prints the short status and fails (exit 1) when nothing is staged.
const text = g.commits.length > 0
? [`On branch ${g.branch}`, 'nothing to commit, working tree clean']
: [`On branch ${g.branch}`, '', 'Initial commit', '', 'nothing to commit (create/copy files and use "git add" to track)'];
return { lines: text.map((t) => ({ text: t, type: 'output' as const })), newState, status: 1 };
}
const hash = makeHash();
const commit: GitCommit = { hash, message, author: newState.user, date: new Date().toISOString().slice(0, 10) };
Expand Down Expand Up @@ -548,20 +568,32 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal
if (!ref) {
return { lines: [{ text: "Usage: git cherry-pick <commit>\nHint: récupère le hash via 'git log --oneline'.", type: 'error' }], newState };
}
const picked = g.commits.find((c) => c.hash.startsWith(ref));
if (!picked) {
return { lines: [{ text: `fatal: bad revision '${ref}'`, type: 'error' }], newState };
// `A..B` picks the commits after A up to B (A itself excluded), oldest first.
const find = (r: string) => g.commits.findIndex((c) => c.hash.startsWith(r));
const range = ref.match(/^([^.]+)\.\.([^.]+)$/);
let picked: GitCommit[];
if (range) {
const from = find(range[1]);
const to = find(range[2]);
if (from < 0 || to < 0) return { lines: [{ text: `fatal: bad revision '${ref}'`, type: 'error' }], newState };
// `commits` is newest first: B sits before A in the list.
picked = g.commits.slice(to, from).reverse();
} else {
const one = find(ref);
if (one < 0) return { lines: [{ text: `fatal: bad revision '${ref}'`, type: 'error' }], newState };
picked = [g.commits[one]];
}
const hash = makeHash();
const commit: GitCommit = { hash, message: picked.message, author: newState.user, date: new Date().toISOString().slice(0, 10) };
newState = { ...newState, git: { ...g, commits: [commit, ...g.commits] } };
return {
lines: [
{ text: `[${g.branch} ${hash}] ${picked.message}`, type: 'success' },
{ text: ` (cherry-pické depuis ${picked.hash} — le commit est ré-appliqué sur ${g.branch})`, type: 'info' },
],
newState,
};
if (!picked.length) return { lines: [{ text: 'fatal: empty commit set passed', type: 'error' }], newState };
const lines: OutputLine[] = [];
let commits = g.commits;
for (const source of picked) {
const hash = makeHash();
commits = [{ hash, message: source.message, author: newState.user, date: new Date().toISOString().slice(0, 10) }, ...commits];
lines.push({ text: `[${g.branch} ${hash}] ${source.message}`, type: 'success' });
lines.push({ text: ` (cherry-pické depuis ${source.hash} — le commit est ré-appliqué sur ${g.branch})`, type: 'info' });
}
newState = { ...newState, git: { ...g, commits } };
return { lines, newState };
}

// ── git rebase ────────────────────────────────────────────────────────────
Expand All @@ -571,11 +603,18 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal
const g = newState.git!;
const interactive = args.includes('-i') || args.includes('--interactive');
if (interactive) {
// `HEAD~N` needs N commits below HEAD, as in git: otherwise "invalid upstream".
const upstream = args.find((a) => !a.startsWith('-') && a !== sub) ?? '';
const back = upstream.match(/^HEAD~(\d+)$/);
if (back && Number(back[1]) >= g.commits.length) {
return { lines: [{ text: `fatal: invalid upstream '${upstream}'`, type: 'error' }], newState };
}
return {
lines: [
{ text: 'Rebase interactif (simulé) — réécriture de l\'historique.', type: 'info' },
{ text: 'En réel : un éditeur s\'ouvre avec pick/reword/squash/drop pour chaque commit.', type: 'output' },
{ text: '⚠️ Ne JAMAIS réécrire un historique déjà poussé sur une branche partagée.', type: 'error' },
// A warning, not an error: nothing failed.
{ text: '⚠️ Ne JAMAIS réécrire un historique déjà poussé sur une branche partagée.', type: 'info' },
],
newState,
};
Expand Down
28 changes: 17 additions & 11 deletions src/app/data/commands/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,18 +149,24 @@ export function handleNetwork(cmd: string, args: string[], newState: TerminalSta
};
}

case 'ssh-keygen': {
const tIdx = args.indexOf('-t');
const keyType = tIdx >= 0 ? (args[tIdx + 1] ?? 'rsa') : 'rsa';
case 'ssh-copy-id': {
// A shell script shipped with OpenSSH on Linux and macOS, not with Windows' OpenSSH.
if (env === 'windows') {
return { lines: [{ text: "ssh-copy-id: The term 'ssh-copy-id' is not recognized as a name of a cmdlet, function, script file, or executable program.", type: 'error' }], newState };
}
const target = args.find((a) => !a.startsWith('-') && a.includes('@')) ?? args.find((a) => !a.startsWith('-'));
if (!target) return { lines: [{ text: 'Usage: /usr/bin/ssh-copy-id [-i [identity_file]] [-p port] [user@]hostname', type: 'error' }], newState };
return {
lines: [
{ text: `Generating public/private ${keyType} key pair.`, type: 'output' },
{ text: `Enter file in which to save the key (/home/user/.ssh/id_${keyType}): (simulé)`, type: 'output' },
{ text: `Your identification has been saved in /home/user/.ssh/id_${keyType}`, type: 'success' },
{ text: `Your public key has been saved in /home/user/.ssh/id_${keyType}.pub`, type: 'success' },
{ text: `+--[${keyType.toUpperCase()}]--+`, type: 'output' },
{ text: '| .o+. |', type: 'output' },
{ text: '+----[SHA256]-----+', type: 'output' },
{ text: '/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/home/user/.ssh/id_ed25519.pub"', type: 'output' },
{ text: '/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed', type: 'output' },
{ text: '/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys', type: 'output' },
{ text: `${target}'s password: (simulé)`, type: 'output' },
{ text: '', type: 'output' },
{ text: 'Number of key(s) added: 1', type: 'success' },
{ text: '', type: 'output' },
{ text: `Now try logging into the machine, with: "ssh '${target}'"`, type: 'output' },
{ text: 'and check to make sure that only the key(s) you wanted were added.', type: 'output' },
],
newState,
};
Expand All @@ -179,5 +185,5 @@ export function handleNetwork(cmd: string, args: string[], newState: TerminalSta

export const NETWORK_COMMANDS = new Set([
'ping', 'curl', 'wget', 'invoke-webrequest', 'iwr',
'nslookup', 'dig', 'resolve-dnsname', 'ssh', 'ssh-keygen', 'scp',
'nslookup', 'dig', 'resolve-dnsname', 'ssh', 'ssh-keygen', 'ssh-copy-id', 'scp',
]);
Loading
Loading