diff --git a/CHANGELOG.md b/CHANGELOG.md index 06a5134..1cba225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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* diff --git a/STORY.md b/STORY.md index fe6ddf3..e28cfc6 100644 --- a/STORY.md +++ b/STORY.md @@ -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. diff --git a/src/app/data/commands/dateFormat.ts b/src/app/data/commands/dateFormat.ts new file mode 100644 index 0000000..cb55685 --- /dev/null +++ b/src/app/data/commands/dateFormat.ts @@ -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'); +} diff --git a/src/app/data/commands/env.ts b/src/app/data/commands/env.ts index 1d6d60e..077d3a4 100644 --- a/src/app/data/commands/env.ts +++ b/src/app/data/commands/env.ts @@ -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 } { if (args.length === 0) { const lines = Object.entries(state.envVars).map(([k, v]) => ({ @@ -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 { diff --git a/src/app/data/commands/git.ts b/src/app/data/commands/git.ts index ac2a339..286d041 100644 --- a/src/app/data/commands/git.ts +++ b/src/app/data/commands/git.ts @@ -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() ?? ''; @@ -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) }; @@ -548,20 +568,32 @@ export function handleGit(newState: TerminalState, args: string[], env: Terminal if (!ref) { return { lines: [{ text: "Usage: git cherry-pick \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 ──────────────────────────────────────────────────────────── @@ -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, }; diff --git a/src/app/data/commands/network.ts b/src/app/data/commands/network.ts index 0f7aac6..2b093b3 100644 --- a/src/app/data/commands/network.ts +++ b/src/app/data/commands/network.ts @@ -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, }; @@ -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', ]); diff --git a/src/app/data/commands/shellVars.ts b/src/app/data/commands/shellVars.ts index 60c8be2..a8d8bd7 100644 --- a/src/app/data/commands/shellVars.ts +++ b/src/app/data/commands/shellVars.ts @@ -6,12 +6,23 @@ export const UNIX_DEFAULT_PATH = '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin'; /** What `$env:PATH` shows on a fresh Windows session (the lessons print this value). */ export const WINDOWS_DEFAULT_PATH = 'C:\\Windows\\System32;C:\\Windows;C:\\Program Files\\Git\\bin'; +/** Variables every Windows session defines (`$env:USERNAME`, `$env:USERPROFILE`…). */ +const WINDOWS_SESSION_VARS: Record = { + USERNAME: 'user', + USERPROFILE: 'C:\\Users\\user', + COMPUTERNAME: 'TERMINAL-LAB', + OS: 'Windows_NT', +}; + /** * The variables as the given shell shows them. The session keeps one set of - * variables; an untouched PATH reads as a Windows PATH under PowerShell. + * variables; under PowerShell an untouched PATH reads as a Windows PATH and the + * Windows session variables are present. A variable the learner set wins. * Never written back to the state: switching environment keeps each view. */ export function varsForEnv(vars: Record, env: TerminalEnv): Record { - if (env === 'windows' && vars.PATH === UNIX_DEFAULT_PATH) return { ...vars, PATH: WINDOWS_DEFAULT_PATH }; - return vars; + if (env !== 'windows') return vars; + const shown = { ...WINDOWS_SESSION_VARS, ...vars }; + if (vars.PATH === UNIX_DEFAULT_PATH) shown.PATH = WINDOWS_DEFAULT_PATH; + return shown; } diff --git a/src/app/data/commands/sshKeygen.ts b/src/app/data/commands/sshKeygen.ts new file mode 100644 index 0000000..3729859 --- /dev/null +++ b/src/app/data/commands/sshKeygen.ts @@ -0,0 +1,289 @@ +/** + * `ssh-keygen` as OpenSSH 10 prints it when the learner presses Enter at each + * prompt (default file, empty passphrase). The key material is pseudo-random + * but shaped like the real thing: the public key blob has the real header, the + * fingerprint is the SHA-256 of that blob as OpenSSH computes it, and the + * randomart is drawn from those bytes with the same "drunken bishop" walk. + */ + +import type { OutputLine } from './types'; + +interface KeyType { + /** Name in the public key line (`ssh-ed25519 AAAA…`). */ + algo: string; + /** Label in the randomart header (`ED25519 256`). */ + label: (bits: number) => string; + defaultBits: number; + /** Key material after the algorithm name in the public blob. */ + blobTail: (bits: number, random: () => number) => number[]; +} + +const u32 = (n: number) => [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255]; +const str = (s: string) => [...u32(s.length), ...Array.from(s, (c) => c.charCodeAt(0))]; +const bytes = (n: number, random: () => number) => Array.from({ length: n }, () => Math.floor(random() * 256)); + +const KEY_TYPES: Record = { + ed25519: { + algo: 'ssh-ed25519', + label: () => 'ED25519 256', + defaultBits: 256, + blobTail: (_bits, r) => [...u32(32), ...bytes(32, r)], + }, + rsa: { + algo: 'ssh-rsa', + label: (bits) => `RSA ${bits}`, + defaultBits: 3072, + // Exponent 65537, then a modulus of `bits` bits (with its leading zero byte). + blobTail: (bits, r) => { + const size = Math.ceil(bits / 8); + return [...u32(3), 1, 0, 1, ...u32(size + 1), 0, 128 | Math.floor(r() * 128), ...bytes(size - 1, r)]; + }, + }, + ecdsa: { + algo: 'ecdsa-sha2-nistp256', + label: () => 'ECDSA 256', + defaultBits: 256, + blobTail: (_bits, r) => [...str('nistp256'), ...u32(65), 4, ...bytes(64, r)], + }, +}; + +/** Deterministic generator (mulberry32) seeded from text, so a test sees stable output. */ +function seeded(seed: string): () => number { + let h = 1779033703; + for (let i = 0; i < seed.length; i++) h = Math.imul(h ^ seed.charCodeAt(i), 3432918353); + return () => { + h = (h + 0x6d2b79f5) | 0; + let t = Math.imul(h ^ (h >>> 15), 1 | h); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +function base64(data: number[], pad = true): string { + let out = ''; + for (let i = 0; i < data.length; i += 3) { + const [a, b = 0, c = 0] = data.slice(i, i + 3); + const n = (a << 16) | (b << 8) | c; + const left = data.length - i; + out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63]; + out += left > 1 ? B64[(n >> 6) & 63] : pad ? '=' : ''; + out += left > 2 ? B64[n & 63] : pad ? '=' : ''; + } + return out; +} + +/** Bytes of a base64 text, or null when it is not base64. */ +function fromBase64(text: string): number[] | null { + const clean = text.replace(/=+$/, ''); + if (!/^[A-Za-z0-9+/]*$/.test(clean)) return null; + const out: number[] = []; + let buffer = 0, bits = 0; + for (const c of clean) { + buffer = (buffer << 6) | B64.indexOf(c); + bits += 6; + if (bits >= 8) { + bits -= 8; + out.push((buffer >> bits) & 255); + } + } + return out; +} + +const SHA256_K = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +/** + * SHA-256 (FIPS 180-4), synchronous so the terminal stays synchronous. It only + * computes the fingerprint shown to the learner, never anything secret. + */ +export function sha256(data: number[]): number[] { + const h = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]; + const bitLength = data.length * 8; + const msg = [...data, 0x80]; + while (msg.length % 64 !== 56) msg.push(0); + msg.push(...u32(Math.floor(bitLength / 2 ** 32)), ...u32(bitLength >>> 0)); + const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n)); + const w = new Array(64); + for (let off = 0; off < msg.length; off += 64) { + for (let i = 0; i < 16; i++) { + const j = off + i * 4; + w[i] = (msg[j] << 24) | (msg[j + 1] << 16) | (msg[j + 2] << 8) | msg[j + 3]; + } + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0; + } + let [a, b, c, d, e, f, g, k] = h; + for (let i = 0; i < 64; i++) { + const t1 = (k + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + SHA256_K[i] + w[i]) | 0; + const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0; + k = g; g = f; f = e; e = (d + t1) | 0; + d = c; c = b; b = a; a = (t1 + t2) | 0; + } + [a, b, c, d, e, f, g, k].forEach((v, i) => { h[i] = (h[i] + v) | 0; }); + } + return h.flatMap((v) => u32(v >>> 0)); +} + +/** The randomart box ssh-keygen draws from a fingerprint (OpenSSH `fingerprint_randomart`). */ +export function randomart(fingerprint: number[], label: string): string[] { + const W = 17, H = 9; + const symbols = ' .o+=*BOX@%&#/^SE'; + const field = Array.from({ length: W }, () => new Array(H).fill(0)); + let x = W >> 1, y = H >> 1; + for (const byte of fingerprint) { + let input = byte; + for (let step = 0; step < 4; step++) { + x += input & 1 ? 1 : -1; + y += input & 2 ? 1 : -1; + x = Math.max(0, Math.min(x, W - 1)); + y = Math.max(0, Math.min(y, H - 1)); + if (field[x][y] < symbols.length - 3) field[x][y]++; + input >>= 2; + } + } + field[W >> 1][H >> 1] = symbols.length - 2; + field[x][y] = symbols.length - 1; + const title = `[${label}]`; + const top = '+' + title.padStart(Math.floor((W - title.length) / 2) + title.length, '-').padEnd(W, '-') + '+'; + const rows = Array.from({ length: H }, (_, row) => '|' + Array.from({ length: W }, (_, col) => symbols[field[col][row]]).join('') + '|'); + return [top, ...rows, '+----[SHA256]-----+']; +} + +/** Options that take a value, from OpenSSH's getopt string. */ +const WITH_VALUE = 'abCDEfFgGIJjKmMnNOPrRsStTVwYzZ'; + +export type KeygenOptions = { opts: Record } | { missing: string }; + +/** Reads the options the way getopt does: `-lf file`, `-t rsa`, `-trsa`, `-N ""`. */ +export function keygenOptions(args: string[]): KeygenOptions { + const opts: Record = {}; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (!a.startsWith('-') || a.length < 2) continue; + for (let j = 1; j < a.length; j++) { + const c = a[j]; + if (WITH_VALUE.includes(c)) { + const rest = a.slice(j + 1); + if (rest) opts[c] = rest; + else if (i + 1 < args.length) opts[c] = args[++i]; + else return { missing: c }; + break; + } + opts[c] = true; + } + } + return { opts }; +} + +/** + * `ssh-keygen -l` on a public key line: `256 SHA256:… comment (ED25519)`, + * or null when the text is not a public key. + */ +export function fingerprintLine(publicKey: string): string | null { + const [algo, data, ...rest] = publicKey.trim().split(/\s+/); + const labels: Record = { 'ssh-ed25519': 'ED25519', 'ssh-rsa': 'RSA', 'ecdsa-sha2-nistp256': 'ECDSA' }; + const label = Object.prototype.hasOwnProperty.call(labels, algo) ? labels[algo] : undefined; + const blob = data ? fromBase64(data) : null; + if (!label || !blob || blob.length < 12) return null; + let bits = 256; + if (label === 'RSA') { + // Skip the name and the exponent; the modulus length gives the key size. + const readLen = (at: number) => ((blob[at] << 24) | (blob[at + 1] << 16) | (blob[at + 2] << 8) | blob[at + 3]) >>> 0; + const expAt = 4 + readLen(0); + const modAt = expAt + 4 + readLen(expAt); + const modLen = readLen(modAt); + bits = (modLen - (blob[modAt + 4] === 0 ? 1 : 0)) * 8; + } + const comment = rest.join(' ') || 'no comment'; + return `${bits} SHA256:${base64(sha256(blob), false)} ${comment} (${label})`; +} + +export interface KeygenRequest { + opts: Record; + /** Where the key goes by default, as the shell prints it (`/home/user/.ssh`, `C:\Users\user/.ssh`). */ + sshDirShown: string; + /** `user@hostname`, the default comment. */ + defaultComment: string; + sshDirExists: boolean; + keyExists: boolean; + /** The key path (or its `.pub`) is a directory: OpenSSH asks to overwrite, then cannot write. */ + keyIsDirectory?: boolean; + /** The folder of `-f dir/key` does not exist: OpenSSH fails when it saves. */ + folderMissing?: boolean; +} + +export type KeygenResult = + | { error: OutputLine[]; status: number } + | { lines: OutputLine[]; keyType: string; keyPathShown: string; privateKey: string; publicKey: string }; + +/** The key type `-t` asks for (ed25519 by default, as in OpenSSH 9.5 and later). */ +export function keygenType(opts: Record): string { + return (typeof opts.t === 'string' ? opts.t : 'ed25519').toLowerCase(); +} + +export function sshKeygen(req: KeygenRequest): KeygenResult { + const { opts } = req; + const value = (flag: string) => (typeof opts[flag] === 'string' ? (opts[flag] as string) : undefined); + const typeName = keygenType(opts); + // Own properties only: `-t constructor` must not find Object.prototype.constructor. + const type = Object.prototype.hasOwnProperty.call(KEY_TYPES, typeName) ? KEY_TYPES[typeName] : undefined; + if (!type) return { error: [{ text: `unknown key type ${typeName}`, type: 'error' }], status: 255 }; + const bits = typeName === 'rsa' ? Number(value('b') ?? type.defaultBits) : type.defaultBits; + if (typeName === 'rsa') { + // OpenSSH's bounds and wording; the upper bound also keeps the fake modulus small. + if (!Number.isInteger(bits) || bits < 1024) { + return { error: [{ text: 'Invalid RSA key length: minimum is 1024 bits', type: 'error' }], status: 255 }; + } + if (bits > 16384) { + return { error: [{ text: 'Invalid RSA key length: maximum is 16384 bits', type: 'error' }], status: 255 }; + } + } + const comment = value('C') ?? req.defaultComment; + const fileShown = value('f'); + const keyPath = fileShown ?? `${req.sshDirShown}/id_${typeName}`; + + const random = seeded(`${typeName}|${bits}|${comment}|${keyPath}`); + const blob = [...str(type.algo), ...type.blobTail(bits, random)]; + const fingerprint = sha256(blob); + const publicKey = `${type.algo} ${base64(blob)} ${comment}`; + const privateBody = base64([...Array.from('openssh-key-v1', (c) => c.charCodeAt(0)), 0, ...blob, ...bytes(blob.length + 64, random)]); + const privateKey = ['-----BEGIN OPENSSH PRIVATE KEY-----', ...(privateBody.match(/.{1,70}/g) ?? []), '-----END OPENSSH PRIVATE KEY-----'].join('\n'); + + const out = (text: string): OutputLine => ({ text, type: 'output' }); + const lines: OutputLine[] = [out(`Generating public/private ${typeName} key pair.`)]; + if (fileShown === undefined) { + lines.push(out(`Enter file in which to save the key (${keyPath}): `)); + if (!req.sshDirExists) lines.push(out(`Created directory '${req.sshDirShown}'.`)); + } + if (req.keyExists || req.keyIsDirectory) lines.push(out(`${keyPath} already exists.`), out('Overwrite (y/n)? y')); + if (req.keyIsDirectory) { + return { error: [...lines, { text: `Saving key "${keyPath}" failed: Is a directory`, type: 'error' }], status: 1 }; + } + if (opts.N === undefined) { + lines.push(out(`Enter passphrase for "${keyPath}" (empty for no passphrase): `), out('Enter same passphrase again: ')); + } + if (req.folderMissing) { + return { error: [...lines, { text: `Saving key "${keyPath}" failed: No such file or directory`, type: 'error' }], status: 1 }; + } + lines.push( + out(`Your identification has been saved in ${keyPath}`), + out(`Your public key has been saved in ${keyPath}.pub`), + out('The key fingerprint is:'), + out(`SHA256:${base64(fingerprint, false)} ${comment}`), + out("The key's randomart image is:"), + ...randomart(fingerprint, type.label(bits)).map(out), + ); + return { lines, keyType: typeName, keyPathShown: keyPath, privateKey, publicKey }; +} diff --git a/src/app/data/commands/windows.ts b/src/app/data/commands/windows.ts index 0f9988a..93f4d27 100644 --- a/src/app/data/commands/windows.ts +++ b/src/app/data/commands/windows.ts @@ -1,5 +1,5 @@ import type { TerminalState, TerminalEnv, CommandOutput, OutputLine, DirectoryNode } from './types'; -import { varsForEnv } from './shellVars'; +import { dotnetDateFormat, psDefaultDate, strftime } from './dateFormat'; export interface WindowsCmdDeps { cmdPwd: (state: TerminalState, env: TerminalEnv) => OutputLine[]; @@ -11,9 +11,91 @@ export interface WindowsCmdDeps { cmdCp: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode }; cmdMv: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode; newCwd?: string[] }; cmdRm: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode }; - cmdEcho: (args: string[], envVars?: Record) => OutputLine[]; + cmdEcho: (args: string[]) => OutputLine[]; cmdGrep: (state: TerminalState, args: string[]) => OutputLine[]; cmdEnv: (state: TerminalState) => OutputLine[]; + /** Content of a file, or why it cannot be read. */ + readFile: (state: TerminalState, path: string) => { content: string } | { error: 'missing' | 'directory' }; + /** Writes (or appends to) a file; `errors` explains a refusal. */ + writeFile: (state: TerminalState, path: string, content: string, append: boolean, cmdlet?: string) => { root: DirectoryNode; errors: OutputLine[] }; + /** Absolute Windows form of a path, as PowerShell messages show it (`C:\Users\user\documents\x.txt`). */ + winPath: (state: TerminalState, path: string) => string; +} + +/** Parameters Get-Content understands here, and whether each takes a value. */ +const GET_CONTENT_PARAMS: Record = { + '-path': true, '-literalpath': true, '-totalcount': true, '-head': true, '-first': true, + '-tail': true, '-last': true, '-encoding': true, '-raw': false, '-wait': false, +}; + +/** Get-Content (and its aliases cat, type, gc) with the parameters and messages of PowerShell 7. */ +function getContent(state: TerminalState, args: string[], deps: WindowsCmdDeps): OutputLine[] { + const fail = (text: string): OutputLine[] => [{ text, type: 'error' }]; + let file: string | undefined; + let head: number | undefined; + let tail: number | undefined; + for (let i = 0; i < args.length; i++) { + const name = args[i].toLowerCase(); + if (!name.startsWith('-')) { + file ??= args[i]; + continue; + } + if (!(name in GET_CONTENT_PARAMS)) { + return fail(`Get-Content: A parameter cannot be found that matches parameter name '${args[i].slice(1)}'.`); + } + if (!GET_CONTENT_PARAMS[name]) continue; + const value = args[++i]; + if (name === '-path' || name === '-literalpath') file = value; + else if (['-totalcount', '-head', '-first'].includes(name)) head = parseInt(value ?? '', 10); + else if (name === '-tail' || name === '-last') tail = parseInt(value ?? '', 10); + } + if (!file) return fail('Get-Content: Cannot process command because of one or more missing mandatory parameters: Path.'); + const read = deps.readFile(state, file); + if ('error' in read) { + return fail(read.error === 'missing' + ? `Get-Content: Cannot find path '${deps.winPath(state, file)}' because it does not exist.` + : `Get-Content: Unable to get content because it is a directory: '${deps.winPath(state, file)}'. Please use 'Get-ChildItem' instead.`); + } + // PowerShell returns the lines without a final empty one for a trailing newline. + let lines = read.content.split('\n'); + if (read.content.endsWith('\n')) lines.pop(); + if (head !== undefined && !Number.isNaN(head)) lines = lines.slice(0, Math.max(0, head)); + if (tail !== undefined && !Number.isNaN(tail)) lines = tail > 0 ? lines.slice(-tail) : []; + return lines.map((text) => ({ text, type: 'output' as const })); +} + +/** Set-Content / Add-Content: `Set-Content file 'text'` or `-Path file -Value text`. */ +function setContent(state: TerminalState, cmdlet: string, args: string[], append: boolean, deps: WindowsCmdDeps): CommandOutput { + const named = (names: string[]) => { + const i = args.findIndex((a) => names.includes(a.toLowerCase())); + return i >= 0 ? args[i + 1] : undefined; + }; + // Values of named parameters are not positional; switches (-Force, -NoNewline) take no value. + const takesValue = ['-path', '-literalpath', '-value', '-encoding']; + const positional = args.filter((a, i) => !a.startsWith('-') && !(i > 0 && takesValue.includes(args[i - 1].toLowerCase()))); + const file = named(['-path', '-literalpath']) ?? positional.shift(); + const value = named(['-value']) ?? positional.shift() ?? ''; + if (!file) { + return { lines: [{ text: `${cmdlet}: Cannot process command because of one or more missing mandatory parameters: Path.`, type: 'error' }], newState: state }; + } + // Files here hold their text without a final newline; appending adds the line break. + const { root, errors } = deps.writeFile(state, file, value, append, cmdlet); + return { lines: errors, newState: errors.length ? state : { ...state, root } }; +} + +/** Get-History: the same Id / Duration / CommandLine table as PowerShell 7. */ +function getHistory(state: TerminalState): OutputLine[] { + // The command being run is not in PowerShell's history yet; our history already holds it. + const past = state.commandHistory.slice(0, -1); + if (!past.length) return []; + const row = (id: string, duration: string, line: string) => `${id.padStart(4)} ${duration.padStart(12)} ${line}`; + return [ + '', + row('Id', 'Duration', 'CommandLine'), + row('--', '--------', '-----------'), + ...past.map((c, i) => row(String(i + 1), '0.010', c)), + '', + ].map((text) => ({ text, type: 'output' as const })); } /** `-Recurse`, or any prefix PowerShell accepts for it (`-r`, `-rec`…). */ @@ -116,7 +198,66 @@ export function handleWindows( // ── cat equivalents ─────────────────────────────────────────────────────── case 'get-content': case 'gc': - return { lines: deps.cmdCat(newState, args), newState }; + return { lines: getContent(newState, args, deps), newState }; + + // (No `sc` alias: in PowerShell 7 `sc` runs sc.exe, the service tool.) + case 'set-content': + case 'add-content': { + if (env !== 'windows') return null; + const append = cmd === 'add-content'; + return setContent(newState, append ? 'Add-Content' : 'Set-Content', args, append, deps); + } + + // ── date, history, scheduled tasks ──────────────────────────────────────── + case 'get-date': { + if (env !== 'windows') return null; + const now = new Date(); + const format = psParam(args, '-format'); + const uformat = psParam(args, '-uformat'); + const text = format !== undefined ? dotnetDateFormat(now, format) : uformat !== undefined ? strftime(now, uformat) : psDefaultDate(now); + return { lines: [{ text, type: 'output' }], newState }; + } + + case 'get-history': + case 'ghy': + if (env !== 'windows') return null; + return { lines: getHistory(newState), newState }; + + case 'get-scheduledtask': { + if (env !== 'windows') return null; + const row = (path: string, name: string, state: string) => `${path.padEnd(47)}${name.padEnd(34)}${state}`; + return { + lines: [ + '', + row('TaskPath', 'TaskName', 'State'), + row('--------', '--------', '-----'), + row('\\', 'Sauvegarde du soir', 'Ready'), + row('\\Microsoft\\Windows\\Defrag\\', 'ScheduledDefrag', 'Ready'), + row('\\Microsoft\\Windows\\WindowsUpdate\\', 'Scheduled Start', 'Ready'), + '', + ].map((text) => ({ text, type: 'output' as const })), + newState, + }; + } + + case 'tasklist': { + if (env !== 'windows') return null; + const row = (image: string, pid: string, session: string, num: string, mem: string) => + `${image.padEnd(25)} ${pid.padStart(8)} ${session.padEnd(16)} ${num.padStart(11)} ${mem.padStart(12)}`; + return { + lines: [ + '', + row('Image Name', 'PID', 'Session Name', 'Session#', 'Mem Usage'), + row('='.repeat(25), '='.repeat(8), '='.repeat(16), '='.repeat(11), '='.repeat(12)), + row('System Idle Process', '0', 'Services', '0', '8 K'), + row('System', '4', 'Services', '0', '7,484 K'), + row('WindowsTerminal.exe', '1234', 'Console', '1', '8,192 K'), + row('node.exe', '2048', 'Console', '1', '4,096 K'), + row('pwsh.exe', '5678', 'Console', '1', '2,048 K'), + ].map((text) => ({ text, type: 'output' as const })), + newState, + }; + } // ── New-Item: file or directory ─────────────────────────────────────────── case 'new-item': @@ -179,7 +320,7 @@ export function handleWindows( // ── echo equivalents ────────────────────────────────────────────────────── case 'write-host': case 'write-output': - return { lines: deps.cmdEcho(args.filter((a) => !a.startsWith('-')), varsForEnv(newState.envVars, env)), newState }; + return { lines: deps.cmdEcho(args.filter((a) => !a.startsWith('-'))), newState }; // ── ps equivalents ──────────────────────────────────────────────────────── case 'get-process': @@ -214,12 +355,28 @@ export function handleWindows( if (patternIdx >= 0) { pattern = args[patternIdx + 1] ?? ''; filePath = pathIdx >= 0 ? args[pathIdx + 1] : (args.find((a, i) => !a.startsWith('-') && i !== patternIdx + 1) ?? ''); + } else if (pathIdx >= 0) { + // `Select-String -Path f.txt motif`: the pattern is the first word that is not the path. + filePath = args[pathIdx + 1] ?? ''; + pattern = args.find((a, i) => !a.startsWith('-') && i !== pathIdx + 1) ?? ''; } else { const nonFlags = args.filter((a) => !a.startsWith('-')); pattern = nonFlags[0] ?? ''; filePath = nonFlags[1] ?? ''; } - return { lines: deps.cmdGrep(newState, [pattern, filePath]), newState }; + if (filePath && 'error' in deps.readFile(newState, filePath)) { + return { lines: [{ text: `Select-String: Cannot find path '${deps.winPath(newState, filePath)}' because it does not exist.`, type: 'error' }], newState }; + } + // Case-insensitive unless -CaseSensitive; each match reads `::`, like PowerShell. + const caseSensitive = args.some((a) => a.toLowerCase() === '-casesensitive'); + const found = deps.cmdGrep(newState, [caseSensitive ? '-n' : '-in', pattern, filePath]); + const shown = filePath.replace(/\//g, '\\'); + return { + lines: found.map((l) => (l.type === 'output' ? { ...l, text: `${shown}:${l.text}` } : l)), + newState, + // No match is not a failure for a cmdlet ($? stays True); only grep returns 1. + status: found.some((l) => l.type === 'error') ? 1 : 0, + }; } // ── clear equivalents ───────────────────────────────────────────────────── diff --git a/src/app/data/curriculum.ts b/src/app/data/curriculum.ts index acdddd0..6f893d1 100644 --- a/src/app/data/curriculum.ts +++ b/src/app/data/curriculum.ts @@ -332,11 +332,11 @@ export const curriculum: Module[] = [ exercise: { instruction: 'Affichez le manuel de `ls` pour explorer toutes ses options : tapez `man ls`.', instructionByEnv: { - windows: 'En PowerShell, l\'équivalent du manuel est `Get-Help`. Dans ce simulateur, tapez `man ls` pour voir le principe (le `Get-Help` natif arrive bientôt).', + windows: 'En PowerShell, l\'équivalent du manuel est `Get-Help`. Affichez l\'aide de la commande qui liste un dossier : tapez `Get-Help Get-ChildItem`.', }, hint: 'Tapez "man ls" — `man` (manual) suivi du nom de la commande.', hintByEnv: { - windows: 'Tapez "man ls" ici ; en PowerShell réel ce serait "Get-Help ls".', + windows: 'Tapez "Get-Help Get-ChildItem" (ou "Get-Help ls" : ls est un raccourci de Get-ChildItem).', }, validate: validateCommandAnatomy, successMessage: 'Parfait ! Vous savez maintenant lire et explorer n\'importe quelle commande : nom, options (courtes ou longues), arguments.', @@ -1605,12 +1605,10 @@ export const curriculum: Module[] = [ { type: 'code', content: '$ export GREETING=Hello\n$ echo $GREETING\nHello', - label: 'Créer et lire une variable (Linux/macOS)', - }, - { - type: 'code', - content: 'PS> $env:GREETING = "Hello"\nPS> echo $env:GREETING\nHello', - label: 'PowerShell', + contentByEnv: { + windows: 'PS> $env:GREETING = "Hello"\nPS> echo $env:GREETING\nHello', + }, + label: 'Créer et lire une variable', }, { type: 'info', @@ -1838,12 +1836,15 @@ export const curriculum: Module[] = [ { type: 'code', content: '$ crontab -l # Lister les tâches planifiées\n$ crontab -e # Éditer (ouvre vi/nano)\n$ crontab -r # Supprimer toutes les tâches', + contentByEnv: { + windows: 'PS> Get-ScheduledTask # Lister les tâches planifiées', + }, label: 'Commandes crontab', }, { type: 'info', content: - 'Sur Windows, l\'équivalent de cron est le "Planificateur de tâches" (Task Scheduler), accessible via `taskschd.msc` ou PowerShell avec `Register-ScheduledTask`.', + 'Sur Windows, l\'équivalent de cron est le "Planificateur de tâches" (Task Scheduler), accessible via `taskschd.msc`, ou en PowerShell : `Get-ScheduledTask` liste les tâches, `Register-ScheduledTask` en crée une.', }, { type: 'tip', @@ -1854,11 +1855,11 @@ export const curriculum: Module[] = [ exercise: { instruction: 'Listez les tâches planifiées avec `crontab -l`.', instructionByEnv: { - windows: 'Listez les tâches planifiées PowerShell avec `Get-ScheduledTask` (simulé — tapez `crontab -l` pour voir un exemple).', + windows: 'Listez les tâches planifiées de Windows avec `Get-ScheduledTask`.', }, hint: 'Tapez: crontab -l', hintByEnv: { - windows: 'Tapez: crontab -l (pour voir un exemple de tâches planifiées)', + windows: 'Tapez: Get-ScheduledTask', }, validate: validateCron, successMessage: 'Parfait ! Vous savez maintenant lister vos tâches planifiées.', @@ -2103,11 +2104,17 @@ export const curriculum: Module[] = [ { type: 'code', content: '# Connexion de base\n$ ssh user@serveur.example.com\n\n# Connexion sur un port non-standard\n$ ssh -p 2222 user@serveur.example.com\n\n# Connexion avec une clé spécifique\n$ ssh -i ~/.ssh/ma-cle user@serveur.example.com', - label: 'Connexion SSH (Linux/macOS/Windows)', + contentByEnv: { + windows: '# Connexion de base\nPS> ssh user@serveur.example.com\n\n# Connexion sur un port non-standard\nPS> ssh -p 2222 user@serveur.example.com\n\n# Connexion avec une clé spécifique\nPS> ssh -i $env:USERPROFILE\\.ssh\\ma-cle user@serveur.example.com', + }, + label: 'Connexion SSH', }, { type: 'code', - content: '# Générer une paire de clés ED25519 (recommandé)\n$ ssh-keygen -t ed25519 -C "mon@email.com"\n\nGenerating public/private ed25519 key pair.\nEnter file (.ssh/id_ed25519): [Entrée]\nEnter passphrase: [optionnel]\n\nYour public key has been saved in ~/.ssh/id_ed25519.pub\n\n# Copier la clé publique sur un serveur\n$ ssh-copy-id user@serveur.example.com', + content: '# Générer une paire de clés ED25519 (recommandé)\n# À chaque question, Entrée accepte la valeur proposée\n$ ssh-keygen -t ed25519 -C "mon@email.com"\nGenerating public/private ed25519 key pair.\nEnter file in which to save the key (/home/user/.ssh/id_ed25519):\nEnter passphrase for "/home/user/.ssh/id_ed25519" (empty for no passphrase):\nEnter same passphrase again:\nYour identification has been saved in /home/user/.ssh/id_ed25519\nYour public key has been saved in /home/user/.ssh/id_ed25519.pub\n…\n\n# Copier la clé publique sur un serveur\n$ ssh-copy-id user@serveur.example.com', + contentByEnv: { + windows: '# Générer une paire de clés ED25519 (recommandé)\n# À chaque question, Entrée accepte la valeur proposée\nPS> ssh-keygen -t ed25519 -C "mon@email.com"\nGenerating public/private ed25519 key pair.\n…\n\n# ssh-copy-id n\'existe pas sous Windows : envoyez la clé publique avec ssh\nPS> Get-Content $env:USERPROFILE\\.ssh\\id_ed25519.pub | ssh user@serveur.example.com "cat >> ~/.ssh/authorized_keys"', + }, label: 'Générer des clés SSH', }, { @@ -2115,7 +2122,7 @@ export const curriculum: Module[] = [ content: 'SSH utilise une paire de clés : une **clé privée** (à ne jamais partager) et une **clé publique** (à déposer sur les serveurs). L\'authentification se fait sans mot de passe — bien plus sécurisé.', contentByEnv: { - windows: 'OpenSSH est intégré à Windows 10+ (version 1803). Les commandes `ssh`, `ssh-keygen`, `ssh-copy-id` fonctionnent dans PowerShell et CMD. Les clés sont stockées dans `%USERPROFILE%\\.ssh\\`.', + windows: 'OpenSSH est intégré à Windows 10+ (version 1803) : `ssh` et `ssh-keygen` fonctionnent dans PowerShell et CMD, et les clés sont stockées dans `%USERPROFILE%\\.ssh\\`. En revanche, `ssh-copy-id` n\'est pas fourni sous Windows. Pour déposer votre clé publique sur un serveur : `Get-Content $env:USERPROFILE\\.ssh\\id_ed25519.pub | ssh user@serveur "cat >> ~/.ssh/authorized_keys"`.', }, }, { diff --git a/src/app/data/terminalEngine.ts b/src/app/data/terminalEngine.ts index 82d57dd..6097be2 100644 --- a/src/app/data/terminalEngine.ts +++ b/src/app/data/terminalEngine.ts @@ -5,12 +5,14 @@ export type { TerminalEnv, FileNode, DirectoryNode, FSNode, GitCommit, GitState, // ─── Command module handlers ────────────────────────────────────────────────── import { handleGit } from './commands/git'; import { handleNetwork } from './commands/network'; +import { fingerprintLine, keygenOptions, keygenType, sshKeygen } from './commands/sshKeygen'; import { handleAiHelp } from './commands/ai'; import { cmdEnv, handleEnv } from './commands/env'; import { handleWindows } from './commands/windows'; import { parseCommandLine, isPlainCommand, isNullDevice } from './commands/shellSyntax'; import type { Stage, Fd } from './commands/shellSyntax'; import { UNIX_DEFAULT_PATH, varsForEnv } from './commands/shellVars'; +import { strftime } from './commands/dateFormat'; import type { WindowsCmdDeps } from './commands/windows'; // ─── Initial Filesystem ─────────────────────────────────────────────────────── @@ -300,26 +302,65 @@ function deepCloneRoot(root: DirectoryNode): DirectoryNode { // ─── Argument Parser ────────────────────────────────────────────────────────── -function parseArgs(input: string): string[] { +/** + * Splits a command line into words, removing quotes. With `expand`, variables + * are replaced as the shell does it, outside single quotes only: `$env:NAME` + * (any case), `$HOME` and `$PWD` under PowerShell; `$NAME`, `${NAME}`, `$1`…`$9`, + * a leading `~` and `\$` (a literal dollar) under bash and zsh. An unset bash + * variable expands to nothing. + */ +function parseArgs(input: string, expand?: { vars: Record; env: TerminalEnv }): string[] { const result: string[] = []; let current = ''; + // `""` is an empty argument (`ssh-keygen -N ""`); an unquoted empty expansion is not. + let quoted = false; let inQuote = false; let quoteChar = ''; + const windows = expand?.env === 'windows'; + const variable = windows + // `$HOME` and `$PWD` are PowerShell automatic variables: USERPROFILE and the current folder. + ? /^\$(?:env:([A-Za-z_][A-Za-z0-9_]*)|(home|pwd)(?![A-Za-z0-9_:]))/i + // `$1`…`$9` are the (empty) positional parameters of an interactive shell. + : /^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*)|([1-9]))/; for (let i = 0; i < input.length; i++) { const ch = input[i]; + const literal = inQuote && quoteChar === "'"; + if (expand && !windows && !literal && ch === '\\' && input[i + 1] === '$') { + current += '$'; + i++; + continue; + } + // `~` at the start of an unquoted word is the home folder (`echo ~/documents`). + if (expand && !windows && !inQuote && ch === '~' && !current && !quoted && /^(?:$|[/\s])/.test(input.slice(i + 1, i + 2))) { + current += capValue(Object.prototype.hasOwnProperty.call(expand.vars, 'HOME') ? expand.vars.HOME : '/home/user'); + continue; + } + if (expand && ch === '$' && !literal) { + const m = variable.exec(input.slice(i)); + if (m) { + const name = windows + ? (m[2] ? (m[2].toLowerCase() === 'home' ? 'USERPROFILE' : 'PWD') : m[1]) + : m[1] ?? m[2] ?? m[3]; + const key = windows ? envKey(expand.vars, name) : name; + current += Object.prototype.hasOwnProperty.call(expand.vars, key) ? capValue(expand.vars[key]) : ''; + i += m[0].length - 1; + continue; + } + } if (inQuote) { if (ch === quoteChar) inQuote = false; else current += ch; } else if (ch === '"' || ch === "'") { inQuote = true; + quoted = true; quoteChar = ch; } else if (ch === ' ' || ch === '\t') { - if (current) { result.push(current); current = ''; } + if (current || quoted) { result.push(current); current = ''; quoted = false; } } else { current += ch; } } - if (current) result.push(current); + if (current || quoted) result.push(current); return result; } @@ -491,16 +532,30 @@ function cmdTouch(state: TerminalState, args: string[]): { lines: OutputLine[]; } function cmdCat(state: TerminalState, args: string[]): OutputLine[] { - if (!args.length) return [{ text: 'cat: missing file operand', type: 'error' }]; + // `-n` numbers the lines like GNU cat: width 6, a tab, and the count runs on across files. + const options = args.filter((a) => /^-[^-]/.test(a)); + const files = args.filter((a) => !/^-[^-]/.test(a)); + const unknown = options.join('').replace(/-/g, '').replace(/n/g, ''); + if (unknown) { + return [ + { text: `cat: invalid option -- '${unknown[0]}'`, type: 'error' }, + { text: "Try 'cat --help' for more information.", type: 'error' }, + ]; + } + const numbered = options.length > 0; + if (!files.length) return [{ text: 'cat: missing file operand', type: 'error' }]; const lines: OutputLine[] = []; - for (const p of args) { + let lineNo = 0; + for (const p of files) { const node = getNode(state.root, resolvePath(state, p)); if (!node) { lines.push({ text: `cat: ${p}: No such file or directory`, type: 'error' }); } else if (node.type === 'directory') { lines.push({ text: `cat: ${p}: Is a directory`, type: 'error' }); } else { - node.content.split('\n').forEach((line) => lines.push({ text: line, type: 'output' })); + node.content.split('\n').forEach((line) => + lines.push({ text: numbered ? `${String(++lineNo).padStart(6)}\t${line}` : line, type: 'output' }), + ); } } return lines; @@ -508,20 +563,33 @@ function cmdCat(state: TerminalState, args: string[]): OutputLine[] { const MAX_ENV_VAR_LENGTH = 1024; -function cmdEcho(args: string[], envVars?: Record): OutputLine[] { - const text = args.join(' '); - if (!envVars) return [{ text, type: 'output' }]; - // Interpolate $env:VAR (PowerShell) and $VAR (bash) from envVars - // Values are capped at MAX_ENV_VAR_LENGTH to prevent terminal flooding [H3] - const safeVal = (name: string) => { - if (!Object.prototype.hasOwnProperty.call(envVars, name)) return ''; - const v = envVars[name]; - return v.length > MAX_ENV_VAR_LENGTH ? v.slice(0, MAX_ENV_VAR_LENGTH) + '…' : v; - }; - const expanded = text - .replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/g, (_, name) => safeVal(name)) - .replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, name) => safeVal(name)); - return [{ text: expanded, type: 'output' }]; +/** Variables a command line expands: the session's, and PWD for the current folder. */ +function expansionVars(state: TerminalState, env: TerminalEnv): { vars: Record; env: TerminalEnv } { + const shown = varsForEnv(state.envVars, env); + const pwd = env === 'windows' ? displayPathForEnv(state.cwd, env) : '/' + state.cwd.join('/'); + return { vars: { PWD: pwd, ...shown }, env }; +} + +/** A variable value, cut at MAX_ENV_VAR_LENGTH so a huge variable cannot flood the terminal [H3]. */ +function capValue(v: string): string { + return v.length > MAX_ENV_VAR_LENGTH ? v.slice(0, MAX_ENV_VAR_LENGTH) + '…' : v; +} + +/** The stored name of a PowerShell `$env:` variable, matched without regard to case. */ +function envKey(vars: Record, name: string): string { + return Object.keys(vars).find((k) => k.toLowerCase() === name.toLowerCase()) ?? name; +} + +/** + * Variables are already expanded by parseArgs, which leaves `'$HOME'` literal + * like the shell does. Each word is capped at MAX_ENV_VAR_LENGTH so a huge + * variable cannot flood the terminal [H3]. + */ +function cmdEcho(args: string[]): OutputLine[] { + const text = args + .map((a) => (a.length > MAX_ENV_VAR_LENGTH ? a.slice(0, MAX_ENV_VAR_LENGTH) + '…' : a)) + .join(' '); + return [{ text, type: 'output' }]; } function cmdRm(state: TerminalState, args: string[]): { lines: OutputLine[]; newRoot?: DirectoryNode } { @@ -731,28 +799,45 @@ function cmdGrep(state: TerminalState, args: string[]): OutputLine[] { const rest = args.filter((a) => !a.startsWith('-')); if (rest.length < 2) return [{ text: 'grep: usage: grep [OPTIONS] PATTERN FILE', type: 'error' }]; - const pattern = rest[0]; - const filePath = rest[1]; + const [pattern, ...paths] = rest; const showLineNumbers = flags.some((f) => f.includes('n')); const ignoreCase = flags.some((f) => f.includes('i')); + const recursive = flags.some((f) => /^-[^-]*[rR]/.test(f) || f === '--recursive'); - const node = getNode(state.root, resolvePath(state, filePath)); - if (!node) return [{ text: `grep: ${filePath}: No such file or directory`, type: 'error' }]; - if (node.type === 'directory') return [{ text: `grep: ${filePath}: Is a directory`, type: 'error' }]; - - const lines = node.content.split('\n'); const regexResult = buildGrepRegex(pattern, ignoreCase ? 'i' : ''); if (!regexResult.ok) return [regexResult.error]; const regex = regexResult.regex; - const matches = lines - .map((line, i) => ({ line, i })) - .filter(({ line }) => regex.test(line)); - if (!matches.length) return []; - return matches.map(({ line, i }) => ({ - text: showLineNumbers ? `${i + 1}:${line}` : line, - type: 'output' as const, - })); + // Files to search, as grep names them: a directory given with -r expands to its + // files, depth first in name order, each shown as `//`. + const targets: { name: string; content: string }[] = []; + const errors: OutputLine[] = []; + const walk = (dir: DirectoryNode, shown: string) => { + for (const [name, child] of Object.entries(dir.children).sort(([a], [b]) => a.localeCompare(b))) { + const childName = `${shown.replace(/\/$/, '')}/${name}`; + if (child.type === 'directory') walk(child, childName); + else targets.push({ name: childName, content: child.content }); + } + }; + for (const p of paths) { + const node = getNode(state.root, resolvePath(state, p)); + if (!node) errors.push({ text: `grep: ${p}: No such file or directory`, type: 'error' }); + else if (node.type === 'file') targets.push({ name: p, content: node.content }); + else if (recursive) walk(node, p); + else errors.push({ text: `grep: ${p}: Is a directory`, type: 'error' }); + } + + // Like grep: the file name prefixes each match as soon as several files can match. + const withName = recursive || paths.length > 1; + const out: OutputLine[] = []; + for (const { name, content } of targets) { + content.split('\n').forEach((line, i) => { + if (!regex.test(line)) return; + const prefix = `${withName ? `${name}:` : ''}${showLineNumbers ? `${i + 1}:` : ''}`; + out.push({ text: `${prefix}${line}`, type: 'output' }); + }); + } + return [...out, ...errors]; } function cmdHeadTail(state: TerminalState, args: string[], cmd: 'head' | 'tail'): OutputLine[] { @@ -1109,20 +1194,22 @@ interface PipelineResult { lines: OutputLine[]; newState: TerminalState; ok: boo const outLines = (texts: string[]): OutputLine[] => texts.map((text) => ({ text, type: 'output' as const })); -function openFailure(typed: string, reason: string, env: TerminalEnv): OutputLine { +function openFailure(typed: string, reason: string, env: TerminalEnv, cmdlet = 'Out-File'): OutputLine { return env === 'windows' - ? { text: `Out-File: Could not find a part of the path '${typed}'.`, type: 'error' } + ? { text: `${cmdlet}: Could not find a part of the path '${typed}'.`, type: 'error' } : { text: `bash: ${typed}: ${reason}`, type: 'error' }; } /** Checks a redirection target the way the shell opens it: before the command runs. */ -function checkWritable(state: TerminalState, typed: string, env: TerminalEnv): { path: string[] } | { error: OutputLine } { +function checkWritable(state: TerminalState, typed: string, env: TerminalEnv, cmdlet?: string): { path: string[] } | { error: OutputLine } { const path = resolvePath(state, typed); + // PowerShell names the full path in its message; bash repeats what was typed. + const shown = env === 'windows' ? displayPathForEnv(path, env) : typed; const parent = getNode(state.root, path.slice(0, -1)); if (!parent || parent.type !== 'directory' || path.length === 0) { - return { error: openFailure(typed, 'No such file or directory', env) }; + return { error: openFailure(shown, 'No such file or directory', env, cmdlet) }; } - if (getNode(state.root, path)?.type === 'directory') return { error: openFailure(typed, 'Is a directory', env) }; + if (getNode(state.root, path)?.type === 'directory') return { error: openFailure(shown, 'Is a directory', env, cmdlet) }; return { path }; } @@ -1138,12 +1225,72 @@ function writeFileAt(root: DirectoryNode, path: string[], content: string, appen return newRoot; } +/** + * `ssh-keygen`: writes the key pair (private 600, public 644), creating + * `~/.ssh` (700) when it is missing, like OpenSSH does for the default path. + * `-l` prints the fingerprint of an existing key; other modes are not simulated. + */ +function cmdSshKeygen(state: TerminalState, args: string[], env: TerminalEnv): CommandOutput { + const parsed = keygenOptions(args); + if ('missing' in parsed) { + return { lines: [{ text: `ssh-keygen: option requires an argument -- ${parsed.missing}`, type: 'error' }], newState: state, status: 255 }; + } + const { opts } = parsed; + const fileArg = typeof opts.f === 'string' ? opts.f : undefined; + const sshDir = ['home', 'user', '.ssh']; + const sshDirShown = env === 'windows' ? `${displayPathForEnv(['home', 'user'], env)}/.ssh` : '/' + sshDir.join('/'); + + if (opts.l) { + // A private key is read through its .pub, as OpenSSH finds the public half. + const typed = fileArg ?? `${sshDirShown}/id_ed25519`; + const path = fileArg !== undefined ? resolvePath(state, fileArg) : [...sshDir, 'id_ed25519']; + let node = getNode(state.root, path); + if (node?.type === 'file' && node.content.startsWith('-----BEGIN')) { + node = getNode(state.root, [...path.slice(0, -1), `${path[path.length - 1]}.pub`]); + } + if (!node) return { lines: [{ text: `ssh-keygen: ${typed}: No such file or directory`, type: 'error' }], newState: state, status: 255 }; + const line = node.type === 'file' ? fingerprintLine(node.content) : null; + return line + ? { lines: [{ text: line, type: 'output' }], newState: state } + : { lines: [{ text: `${typed} is not a public key file.`, type: 'error' }], newState: state, status: 255 }; + } + const otherMode = ['y', 'p', 'c', 'e', 'i', 'R', 'F', 'H', 'D', 'k', 'Q', 's', 'Y', 'A'].find((m) => opts[m] !== undefined); + if (otherMode) { + return { lines: [{ text: `ssh-keygen -${otherMode} n'est pas simulé dans ce terminal : essayez-le dans le terminal de votre ordinateur.`, type: 'info' }], newState: state }; + } + + const wanted = fileArg !== undefined ? resolvePath(state, fileArg) : [...sshDir, `id_${keygenType(opts)}`]; + const wantedPub = [...wanted.slice(0, -1), `${wanted[wanted.length - 1] ?? ''}.pub`]; + // The default folder is created on the way; a folder named with -f must exist. + const folder = fileArg !== undefined ? getNode(state.root, wanted.slice(0, -1)) : getNode(state.root, ['home', 'user']); + const result = sshKeygen({ + opts, + sshDirShown, + defaultComment: `${state.user}@${state.hostname}`, + sshDirExists: getNode(state.root, sshDir)?.type === 'directory', + keyExists: getNode(state.root, wanted)?.type === 'file', + keyIsDirectory: wanted.length === 0 || [wanted, wantedPub].some((p) => getNode(state.root, p)?.type === 'directory'), + folderMissing: folder?.type !== 'directory', + }); + if ('error' in result) return { lines: result.error, newState: state, status: result.status }; + + const root = deepCloneRoot(state.root); + if (fileArg === undefined && getNode(root, sshDir)?.type !== 'directory') { + (getNode(root, ['home', 'user']) as DirectoryNode).children['.ssh'] = makeDir({}, 'drwx------'); + } + const parent = getNode(root, wanted.slice(0, -1)) as DirectoryNode; + const name = wanted[wanted.length - 1]; + parent.children[name] = makeFile(result.privateKey, '-rw-------'); + parent.children[`${name}.pub`] = makeFile(result.publicKey, '-rw-r--r--'); + return { lines: result.lines, newState: { ...state, root } }; +} + /** `tee`, `Tee-Object`, `Out-File`… : write the piped text to each file. */ -function writeFromPipe(state: TerminalState, files: string[], text: string, append: boolean, env: TerminalEnv): { root: DirectoryNode; errors: OutputLine[] } { +function writeFromPipe(state: TerminalState, files: string[], text: string, append: boolean, env: TerminalEnv, cmdlet?: string): { root: DirectoryNode; errors: OutputLine[] } { let root = state.root; const errors: OutputLine[] = []; for (const f of files) { - const target = checkWritable({ ...state, root }, f, env); + const target = checkWritable({ ...state, root }, f, env, cmdlet); if ('error' in target) errors.push(target.error); else root = writeFileAt(root, target.path, text, append); } @@ -1181,7 +1328,7 @@ function lineCount(args: string[], fallback: number): number { * Commands that do not read it run as usual; unknown readers pass the text through. */ function runFilter(state: TerminalState, text: string, stdin: string, env: TerminalEnv): CommandOutput { - const parts = parseArgs(text); + const parts = parseArgs(text, expansionVars(state, env)); const cmd = (parts[0] ?? '').toLowerCase(); const args = parts.slice(1); const flags = args.filter((a) => a.startsWith('-')); @@ -1984,6 +2131,14 @@ function splitSingleQuoted(line: string): string[] { return line.split("'"); } +/** PowerShell aliases whose cmdlet behaves differently from the Unix command of the same name. */ +const PS_ALIASES: Record = { + cat: 'get-content', + type: 'get-content', + history: 'get-history', + h: 'get-history', +}; + /** The command name as typed (not lower-cased): that is what a real shell repeats. */ function commandNotFound(typed: string, state: TerminalState): CommandOutput { return { lines: [{ text: `${typed}: commande introuvable. Tapez 'help' pour la liste des commandes.`, type: 'error' }], newState: state }; @@ -1993,27 +2148,33 @@ function commandNotFound(typed: string, state: TerminalState): CommandOutput { function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): CommandOutput { let newState = state; - // Handle PowerShell $env: variable assignment ($env:VAR = "value") - const psEnvSet = trimmed.match(/^\$env:([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/); + // PowerShell $env: assignment: `$env:VAR = "value"`, or `+=` to append (`$env:Path += ";C:\outils"`). + // Like PowerShell, an assignment prints nothing. + const psEnvSet = trimmed.match(/^\$env:([A-Za-z_][A-Za-z0-9_]*)\s*(\+?=)\s*(.*)$/); if (psEnvSet) { - const [, varName, rawValue] = psEnvSet; + const [, varName, operator, rawValue] = psEnvSet; // 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 } }, - }; + : unquoted.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/g, (_, ref: string) => { + const k = envKey(shown, ref); + return Object.prototype.hasOwnProperty.call(shown, k) ? shown[k] : ''; + }); + // Environment variable names are case-insensitive on Windows: `$env:Path` is PATH. + const existing = envKey(shown, varName); + const before = Object.prototype.hasOwnProperty.call(shown, existing) ? shown[existing] : ''; + const value = capValue(operator === '+=' ? before + varValue : varValue); + return { lines: [], newState: { ...newState, envVars: { ...newState.envVars, [existing]: value } } }; } // Handle PowerShell standalone $env:VAR read const psEnvGet = trimmed.match(/^\$env:([A-Za-z_][A-Za-z0-9_]*)$/); if (psEnvGet) { const [, varName] = psEnvGet; - const value = varsForEnv(newState.envVars, env)[varName]; + const shown = varsForEnv(newState.envVars, env); + const value = shown[envKey(shown, varName)]; return { lines: value !== undefined ? [{ text: value, type: 'output' }] @@ -2030,15 +2191,16 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com // `(Get-Content file).Count` — number of lines, the PowerShell `wc -l`. const psCount = trimmed.match(/^\(\s*(?:get-content|gc|cat)\s+(.+?)\s*\)\.count$/i); if (psCount) { - const target = parseArgs(psCount[1]).map((p) => (/^\$profile$/i.test(p) ? PS_PROFILE_PATH : p)); + const target = parseArgs(psCount[1], expansionVars(newState, env)) + .map((p) => (/^\$profile$/i.test(p) ? PS_PROFILE_PATH : p)); const out = cmdCat(newState, target); const errors = out.filter((l) => l.type === 'error'); return { lines: errors.length ? errors : [{ text: String(out.length), type: 'output' }], newState }; } } - // Parse command - let parts = parseArgs(trimmed); + // Parse command; variables expand here, once, for every command (`cd $HOME`). + let parts = parseArgs(trimmed, expansionVars(newState, env)); const cmd = parts[0]?.toLowerCase(); if (env === 'windows') { // `$PROFILE` as an argument: a readable path for echo, the file itself otherwise. @@ -2059,8 +2221,24 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com const winDeps: WindowsCmdDeps = { cmdPwd, cmdCd, cmdLs, cmdCat, cmdMkdir, cmdTouch, cmdCp, cmdMv, cmdRm, cmdEcho, cmdGrep, cmdEnv, + readFile: (s, p) => { + const node = getNode(s.root, resolvePath(s, p)); + if (!node) return { error: 'missing' }; + return node.type === 'directory' ? { error: 'directory' } : { content: node.content }; + }, + writeFile: (s, p, content, append, cmdlet) => writeFromPipe(s, [p], content, append, env, cmdlet), + winPath: (s, p) => displayPathForEnv(resolvePath(s, p), 'windows'), }; + // Under PowerShell these names are aliases of cmdlets: same parameters, same messages. + if (env === 'windows') { + const cmdlet = PS_ALIASES[cmd]; + if (cmdlet) { + const result = handleWindows(cmdlet, args, newState, env, winDeps); + if (result) return result; + } + } + switch (cmd) { case 'pwd': return { lines: cmdPwd(newState, env), newState }; @@ -2090,7 +2268,7 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com return { lines: cmdCat(newState, args), newState }; case 'echo': - return { lines: cmdEcho(args, varsForEnv(newState.envVars, env)), newState }; + return { lines: cmdEcho(args), newState }; // ── Environment & scripts → commands/env.ts ─────────────────────────────── case 'export': @@ -2203,8 +2381,11 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com case 'hostname': return { lines: [{ text: newState.hostname, type: 'output' }], newState }; - case 'date': - return { lines: [{ text: new Date().toLocaleString('fr-FR', { dateStyle: 'full', timeStyle: 'medium' }), type: 'output' }], newState }; + case 'date': { + // `date +"%Y-%m-%d"` formats; without a format, the C-locale default of GNU and BSD date. + const format = args.find((a) => a.startsWith('+')); + return { lines: [{ text: strftime(new Date(), format ? format.slice(1) : '%a %b %e %H:%M:%S %Z %Y'), type: 'output' }], newState }; + } case 'uname': if (env === 'macos') { @@ -2293,6 +2474,26 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com return { lines: [{ text: `man: pas de page de manuel pour '${args[0]}'`, type: 'error' }], newState }; } + case 'get-help': { + if (env !== 'windows') return commandNotFound(parts[0], newState); + const name = args.find((a) => !a.startsWith('-')); + if (!name) return { lines: getHelpText(env).split('\n').map((t) => ({ text: t, type: 'output' as const })), newState }; + const entry = CMD_HELP[CMD_HELP_ALIASES[name.toLowerCase()] ?? name.toLowerCase()]; + if (!entry) { + return { lines: [{ text: `Get-Help: Get-Help could not find ${name} in a help file in this session.`, type: 'error' }], newState }; + } + // PowerShell's layout: NAME, SYNOPSIS (one sentence), then EXAMPLES with -Examples. + // The name as typed: the simulator does not know every cmdlet's official casing. + // The help entries carry a bash syntax line (`ls [-la]`), so it is not shown here. + const lines = ['NAME', ` ${name}`, '', 'SYNOPSIS', ` ${entry.description}`, '']; + if (args.some((a) => a.toLowerCase() === '-examples')) { + lines.push('EXAMPLES', ...(entry.examples?.windows ?? entry.examples?.linux ?? []).map((e) => ` ${e}`)); + } else { + lines.push('REMARKS', ` Aide abrégée du simulateur. Pour des exemples, tapez « Get-Help ${name} -Examples ».`); + } + return { lines: lines.map((text) => ({ text, type: 'output' as const })), newState }; + } + case 'exit': case 'logout': return { lines: [{ text: 'logout', type: 'output' }], newState }; @@ -2332,6 +2533,9 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com // ── Network & SSH (Module 8) → commands/network.ts ────────────────────── + case 'ssh-keygen': + return cmdSshKeygen(newState, args, env); + case 'ping': case 'curl': case 'wget': @@ -2341,7 +2545,7 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com case 'dig': case 'resolve-dnsname': case 'ssh': - case 'ssh-keygen': + case 'ssh-copy-id': case 'scp': return handleNetwork(cmd, args, newState, env); diff --git a/src/app/data/validators.ts b/src/app/data/validators.ts index 251e5cd..983d9de 100644 --- a/src/app/data/validators.ts +++ b/src/app/data/validators.ts @@ -227,7 +227,11 @@ export const validateScripts: ValidateFn = (cmd, env) => { return c === './script.sh' || c === 'bash script.sh'; }; -export const validateCron: ValidateFn = (cmd) => cmd.trim().toLowerCase() === 'crontab -l'; +// PowerShell has no crontab: Windows lists its scheduled tasks with Get-ScheduledTask. +export const validateCron: ValidateFn = (cmd, env) => { + const c = cmd.trim().toLowerCase(); + return env === 'windows' ? /^get-scheduledtask(\s.*)?$/.test(c) : c === 'crontab -l'; +}; export const validatePing: ValidateFn = (cmd) => { // Accept `ping ` plus the options the lesson teaches across OSes: diff --git a/src/test/commandReferenceGaps.ts b/src/test/commandReferenceGaps.ts index 952491c..8fa0e9f 100644 --- a/src/test/commandReferenceGaps.ts +++ b/src/test/commandReferenceGaps.ts @@ -4,47 +4,10 @@ * delete the line. Commands listed in NOT_SIMULATED (commandExamples.ts) are * exempt, since the reference already tells the learner they are not simulated. * - * Causes, by family: - * - PowerShell cmdlets and parameters not simulated yet: Get-Date, Get-History, - * Get-Help, Set-Content, Get-ScheduledTask, tasklist, Get-Content -TotalCount / -Tail, - * `$env:Path +=`, `$env:USERNAME`. - * - Unix options not simulated yet: `cat -n`, `grep -r` on a directory, ssh-copy-id. - * - Git: `git commit -am` loses its message (engine bug), `git rebase -i` prints its - * warning as an error, cherry-pick examples name commits the practice repository does not have. + * Empty since 26 September 2026: the 32 engine gaps the first replay found + * (cat -n, grep -r, Get-Date, Get-Help, Set-Content, git commit -am…) are fixed. + * A new entry needs a planned engine fix, never a way to silence a new failure. * * Key: ` [] `. */ -export const KNOWN_REFERENCE_GAPS = new Set([ - "cat [linux] cat -n documents/notes.txt", - "cat [macos] cat -n documents/notes.txt", - "head_tail [windows] Get-Content documents\\notes.txt -TotalCount 3", - "head_tail [windows] Get-Content documents\\notes.txt -Tail 2", - "grep [linux] grep -rn bash documents", - "grep [macos] grep -rn bash documents", - "date [windows] Get-Date", - "date [windows] Get-Date -Format \"yyyy-MM-dd HH:mm\"", - "history [windows] Get-History", - "man [windows] Get-Help Get-ChildItem", - "man [windows] Get-Help Get-ChildItem -Examples", - "ps [windows] tasklist", - "top_htop [windows] tasklist", - "export [windows] $env:Path += \";C:\\outils\"", - "env [windows] $env:USERNAME", - "run_script [windows] Set-Content bonjour.ps1 'Write-Output \"Bonjour\"'", - "run_script [windows] .\\bonjour.ps1", - "crontab [windows] Get-ScheduledTask", - "ssh [linux] ssh-copy-id user@serveur.example.com", - "ssh [macos] ssh-copy-id user@serveur.example.com", - "git_commit [linux] git commit -am \"fix: corrige le lien du menu\"", - "git_commit [macos] git commit -am \"fix: corrige le lien du menu\"", - "git_commit [windows] git commit -am \"fix: corrige le lien du menu\"", - "git_rebase [linux] git rebase -i HEAD~3", - "git_rebase [macos] git rebase -i HEAD~3", - "git_rebase [windows] git rebase -i HEAD~3", - "git_cherry_pick [linux] git cherry-pick a1b2c3d", - "git_cherry_pick [linux] git cherry-pick a1b2c3d..e4f5a6b", - "git_cherry_pick [macos] git cherry-pick a1b2c3d", - "git_cherry_pick [macos] git cherry-pick a1b2c3d..e4f5a6b", - "git_cherry_pick [windows] git cherry-pick a1b2c3d", - "git_cherry_pick [windows] git cherry-pick a1b2c3d..e4f5a6b", -]); +export const KNOWN_REFERENCE_GAPS = new Set([]); diff --git a/src/test/commandReferenceReplay.ts b/src/test/commandReferenceReplay.ts index a434df9..92fce0d 100644 --- a/src/test/commandReferenceReplay.ts +++ b/src/test/commandReferenceReplay.ts @@ -13,15 +13,26 @@ import type { EnvironmentId } from '../app/types/curriculum'; export const REFERENCE_ENVS = ['linux', 'macos', 'windows'] as const; export type ReferenceEnv = (typeof REFERENCE_ENVS)[number]; -/** Git examples run inside a repository with a commit, an `origin` remote and a feature/login branch. */ +/** + * Git examples run in the repository they describe: an `origin` remote, a + * feature/login branch, and a history deep enough for `git rebase -i HEAD~3` + * that holds the commits the cherry-pick examples name (a1b2c3d, e4f5a6b). + */ const GIT_CATEGORIES = new Set(['git', 'github-collaboration']); +const HISTORY = [ + { hash: 'e4f5a6b', message: 'fix: corrige le formulaire de contact' }, + { hash: 'c7d8e9f', message: 'docs: complète le README' }, + { hash: 'a1b2c3d', message: 'feat: ajoute la page contact' }, +]; + function startState(categoryId: string): TerminalState { const base = createInitialState(); if (!GIT_CATEGORIES.has(categoryId)) return base; const repo = gitRepoWithRemote.apply(base); if (!repo.git) throw new Error('gitRepoWithRemote must create a repository'); - return { ...repo, git: { ...repo.git, branches: [...repo.git.branches, 'feature/login'] } }; + const commits = [...HISTORY.map((c) => ({ ...c, author: 'user', date: '2026-01-20' })), ...repo.git.commits]; + return { ...repo, git: { ...repo.git, commits, branches: [...repo.git.branches, 'feature/login'] } }; } export interface ReplayedExample { diff --git a/src/test/curriculumEnvAwareness.test.ts b/src/test/curriculumEnvAwareness.test.ts index b0488c9..1757686 100644 --- a/src/test/curriculumEnvAwareness.test.ts +++ b/src/test/curriculumEnvAwareness.test.ts @@ -878,10 +878,11 @@ describe('curriculum — spot-checks per lesson × env', () => { }); describe('variables/cron', () => { - it('all 3 envs: crontab -l passes (env-agnostic by design — windows shows example)', () => { + it('linux/macos: crontab -l passes; windows: Get-ScheduledTask passes, crontab does not exist', () => { expect(ex('variables', 'cron').validate('crontab -l', 'linux')).toBe(true); expect(ex('variables', 'cron').validate('crontab -l', 'macos')).toBe(true); - expect(ex('variables', 'cron').validate('crontab -l', 'windows')).toBe(true); + expect(ex('variables', 'cron').validate('Get-ScheduledTask', 'windows')).toBe(true); + expect(ex('variables', 'cron').validate('crontab -l', 'windows')).toBe(false); }); it('crontab -e and other variants do not pass (exercise is specific)', () => { expect(ex('variables', 'cron').validate('crontab -e', 'linux')).toBe(false); diff --git a/src/test/dateFormat.test.ts b/src/test/dateFormat.test.ts new file mode 100644 index 0000000..6888de1 --- /dev/null +++ b/src/test/dateFormat.test.ts @@ -0,0 +1,43 @@ +/** + * `date +FORMAT` and `Get-Date -Format`. Every expected value was printed by the + * real tools for the same local date (26 September 2026): GNU date with + * LC_ALL=C, and .NET DateTime.ToString with the en-US culture in PowerShell 7.6. + */ +import { describe, it, expect } from 'vitest'; +import { strftime, dotnetDateFormat, psDefaultDate } from '../app/data/commands/dateFormat'; + +const evening = new Date(2026, 8, 26, 21, 4, 5); +const morning = new Date(2026, 0, 5, 7, 8, 9); + +describe('day of year across a daylight-saving change', () => { + it('counts calendar days: 30 March 2026 at 00:30 is day 089', () => { + // Belgium moved to summer time on 29 March 2026; a local-time division lost that hour. + expect(strftime(new Date(2026, 2, 30, 0, 30), '%j')).toBe('089'); + }); +}); + +describe('strftime (date +FORMAT)', () => { + it('matches GNU date for the common conversions', () => { + expect(strftime(evening, '%Y-%m-%d %H:%M:%S|%a %A %b %B|%e|%j|%y|%F %T')).toBe( + '2026-09-26 21:04:05|Sat Saturday Sep September|26|269|26|2026-09-26 21:04:05', + ); + expect(strftime(morning, '%e|%j|%a %b')).toBe(' 5|005|Mon Jan'); + }); + + it('keeps %% and unknown conversions as written', () => { + expect(strftime(evening, '100%% %Q')).toBe('100% %Q'); + }); +}); + +describe('dotnetDateFormat (Get-Date -Format)', () => { + it('matches .NET en-US for custom formats', () => { + expect(dotnetDateFormat(evening, 'yyyy-MM-dd HH:mm')).toBe('2026-09-26 21:04'); + expect(dotnetDateFormat(evening, 'ddd MMM yy H:m:s')).toBe('Sat Sep 26 21:4:5'); + expect(dotnetDateFormat(evening, "yyyy'T'HH")).toBe('2026T21'); + }); + + it('prints the en-US default of Get-Date', () => { + expect(psDefaultDate(evening)).toBe('Saturday, September 26, 2026 9:04:05 PM'); + expect(psDefaultDate(morning)).toBe('Monday, January 5, 2026 7:08:09 AM'); + }); +}); diff --git a/src/test/lessonSolutions.ts b/src/test/lessonSolutions.ts index 48088d3..c04ac51 100644 --- a/src/test/lessonSolutions.ts +++ b/src/test/lessonSolutions.ts @@ -18,7 +18,7 @@ export const LESSON_SOLUTIONS: Record = { 'navigation/pwd': { all: ['pwd'], windows: ['Get-Location'] }, 'navigation/ls': { all: ['ls'], windows: ['Get-ChildItem'] }, 'navigation/ls-la': { all: ['ls -la'], windows: ['Get-ChildItem -Force'] }, - 'navigation/command-anatomy': { all: ['man ls'] }, + 'navigation/command-anatomy': { all: ['man ls'], windows: ['Get-Help Get-ChildItem'] }, 'navigation/cd': { all: ['cd documents'], windows: ['Set-Location documents'] }, // ── fichiers ── 'fichiers/mkdir': { all: ['mkdir test'], windows: ['New-Item -ItemType Directory -Name test'] }, @@ -81,7 +81,7 @@ export const LESSON_SOLUTIONS: Record = { 'variables/shell-config': { linux: ['cat ~/.bashrc'], macos: ['cat ~/.zshrc'], windows: ['cat $PROFILE'] }, 'variables/dotenv': { all: ['cd projets', 'cat .env'], windows: ['cd projets', 'Get-Content .env'] }, 'variables/scripts': { all: ['cd projets', './script.sh'], windows: ['cd projets', '.\\script.sh'] }, - 'variables/cron': { all: ['crontab -l'] }, + 'variables/cron': { all: ['crontab -l'], windows: ['Get-ScheduledTask'] }, // ── réseau ── 'reseau/ping': { all: ['ping google.com'] }, 'reseau/curl': { all: ['curl https://api.github.com'] }, diff --git a/src/test/lessonTheoryGaps.ts b/src/test/lessonTheoryGaps.ts index 470c7b2..2bfc13f 100644 --- a/src/test/lessonTheoryGaps.ts +++ b/src/test/lessonTheoryGaps.ts @@ -81,11 +81,6 @@ export const KNOWN_THEORY_GAPS = new Set([ // reseau/curl "reseau/curl b1 [linux] curl -X POST https://api.example.com/data \\", "reseau/curl b1 [macos] curl -X POST https://api.example.com/data \\", - // reseau/ssh - "reseau/ssh b2 [linux] ssh-keygen -t ed25519 -C \"mon@email.com\"", - "reseau/ssh b2 [linux] ssh-copy-id user@serveur.example.com", - "reseau/ssh b2 [macos] ssh-keygen -t ed25519 -C \"mon@email.com\"", - "reseau/ssh b2 [macos] ssh-copy-id user@serveur.example.com", // git/git-config "git/git-config b1 [linux] git config --list", "git/git-config b1 [macos] git config --list", @@ -208,4 +203,4 @@ export const KNOWN_THEORY_GAPS = new Set([ ]); /** Lessons whose code blocks show bash to a Windows learner (no Windows variant). */ -export const BASH_SHOWN_ON_WINDOWS_MAX = 37; +export const BASH_SHOWN_ON_WINDOWS_MAX = 34; diff --git a/src/test/terminalEngine.test.ts b/src/test/terminalEngine.test.ts index 202901b..49663af 100644 --- a/src/test/terminalEngine.test.ts +++ b/src/test/terminalEngine.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { processCommand, getTabCompletions, createInitialState, displayPathForEnv } from '../app/data/terminalEngine'; import type { TerminalState } from '../app/data/terminalEngine'; +import { fingerprintLine, randomart, sha256 } from '../app/data/commands/sshKeygen'; // ─── Fixtures ───────────────────────────────────────────────────────────────── @@ -827,7 +828,9 @@ describe('PowerShell $env: variable handling', () => { const state = makeState(); const result = processCommand(state, '$env:GREETING = "Hello"', 'windows'); expect(result.newState.envVars['GREETING']).toBe('Hello'); - expect(result.lines[0].type).toBe('success'); + // Real PowerShell 7 prints nothing for an assignment (0 bytes, checked 26/09/2026); + // the simulator used to print a success line the real shell never shows. + expect(result.lines).toEqual([]); }); it('$env:PATH reads the PATH variable', () => { @@ -1212,30 +1215,118 @@ describe('ssh', () => { }); }); +// Expected values from OpenSSH 10.5 (ssh-keygen, run in a sandbox on 29 September 2026). describe('ssh-keygen', () => { - it('generates ed25519 key pair', () => { - const result = processCommand(makeState(), 'ssh-keygen -t ed25519', 'linux'); - const text = result.lines.map((l) => l.text).join('\n'); - expect(text).toContain('ed25519'); - expect(text).toContain('.pub'); + const text = (r: { lines: { text: string }[] }) => r.lines.map((l) => l.text); + + it('generates ed25519 by default and writes the key pair in a new ~/.ssh', () => { + const r = processCommand(createInitialState(), 'ssh-keygen', 'linux'); + expect(text(r).slice(0, 7)).toEqual([ + 'Generating public/private ed25519 key pair.', + 'Enter file in which to save the key (/home/user/.ssh/id_ed25519): ', + "Created directory '/home/user/.ssh'.", + 'Enter passphrase for "/home/user/.ssh/id_ed25519" (empty for no passphrase): ', + 'Enter same passphrase again: ', + 'Your identification has been saved in /home/user/.ssh/id_ed25519', + 'Your public key has been saved in /home/user/.ssh/id_ed25519.pub', + ]); + expect(text(r)[7]).toBe('The key fingerprint is:'); + expect(text(r)[8]).toMatch(/^SHA256:[A-Za-z0-9+/]{43} user@terminal-lab$/); + expect(text(r)[10]).toBe('+--[ED25519 256]--+'); + expect(text(r)[text(r).length - 1]).toBe('+----[SHA256]-----+'); + const ls = text(processCommand(r.newState, 'ls -la ~/.ssh', 'linux')).join('\n'); + expect(ls).toMatch(/-rw------- .* id_ed25519\n/); + expect(ls).toMatch(/-rw-r--r-- .* id_ed25519\.pub/); + }); + + it('puts the -C comment at the end of the public key, with the real ed25519 header', () => { + const s = processCommand(createInitialState(), 'ssh-keygen -t ed25519 -C "moi@exemple.com"', 'linux').newState; + const pub = text(processCommand(s, 'cat ~/.ssh/id_ed25519.pub', 'linux')); + expect(pub).toHaveLength(1); + expect(pub[0]).toMatch(/^ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI[A-Za-z0-9+/]{43} moi@exemple\.com$/); + const key = text(processCommand(s, 'cat ~/.ssh/id_ed25519', 'linux')); + expect(key[0]).toBe('-----BEGIN OPENSSH PRIVATE KEY-----'); + expect(key[key.length - 1]).toBe('-----END OPENSSH PRIVATE KEY-----'); + }); + + it('does not recreate ~/.ssh and asks before overwriting an existing key', () => { + const s = processCommand(createInitialState(), 'ssh-keygen', 'linux').newState; + const again = text(processCommand(s, 'ssh-keygen', 'linux')); + expect(again.join('\n')).not.toContain('Created directory'); + expect(again).toContain('/home/user/.ssh/id_ed25519 already exists.'); + }); + + it('draws the randomart header for rsa and ecdsa', () => { + expect(text(processCommand(createInitialState(), 'ssh-keygen -t rsa', 'linux'))).toContain('+---[RSA 3072]----+'); + expect(text(processCommand(createInitialState(), 'ssh-keygen -t ecdsa', 'linux'))).toContain('+---[ECDSA 256]---+'); + }); + + it('skips the prompts that -f and -N answer, and fails when the folder is missing', () => { + const ok = text(processCommand(createInitialState(), 'ssh-keygen -f cle -N ""', 'linux')); + expect(ok.slice(0, 3)).toEqual([ + 'Generating public/private ed25519 key pair.', + 'Your identification has been saved in cle', + 'Your public key has been saved in cle.pub', + ]); + const r = processCommand(createInitialState(), 'ssh-keygen -f nulle/part/cle -N ""', 'linux'); + expect(text(r)).toEqual(['Generating public/private ed25519 key pair.', 'Saving key "nulle/part/cle" failed: No such file or directory']); + expect(r.status).toBe(1); }); - it('reports both private and public key paths', () => { - const result = processCommand(makeState(), 'ssh-keygen -t ed25519', 'linux'); - const successes = result.lines.filter((l) => l.type === 'success'); - expect(successes.length).toBeGreaterThanOrEqual(2); + // Robustness, not fidelity: the lessons always have a home folder. + it('fails cleanly when there is no home folder', () => { + const r = processCommand(makeState(), 'ssh-keygen -N ""', 'linux'); + expect(r.lines[r.lines.length - 1].text).toBe('Saving key "/home/user/.ssh/id_ed25519" failed: No such file or directory'); + expect(r.status).toBe(1); }); - it('uses rsa as default key type when -t is omitted', () => { - const result = processCommand(makeState(), 'ssh-keygen', 'linux'); - const text = result.lines.map((l) => l.text).join('\n'); - expect(text).toContain('rsa'); + it('rejects an unknown key type with status 255', () => { + const r = processCommand(createInitialState(), 'ssh-keygen -t foo', 'linux'); + expect(text(r)).toEqual(['unknown key type foo']); + expect(r.status).toBe(255); }); - it('works on windows env', () => { - const result = processCommand(makeState(), 'ssh-keygen -t ed25519', 'windows'); - const text = result.lines.map((l) => l.text).join('\n'); - expect(text).toContain('ed25519'); + it('writes under C:\\Users\\user\\.ssh on Windows, readable with $env:USERPROFILE', () => { + const r = processCommand(createInitialState(), 'ssh-keygen', 'windows'); + expect(text(r)[1]).toBe('Enter file in which to save the key (C:\\Users\\user/.ssh/id_ed25519): '); + const pub = text(processCommand(r.newState, 'Get-Content $env:USERPROFILE\\.ssh\\id_ed25519.pub', 'windows')); + expect(pub[0]).toMatch(/^ssh-ed25519 AAAA/); + }); +}); + +describe('ssh-keygen fingerprints', () => { + it('computes the SHA-256 fingerprint OpenSSH printed for real keys', () => { + // Throwaway keys made by OpenSSH 10.5 in a sandbox; `ssh-keygen -lf` printed these lines. + expect(fingerprintLine('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOhycLPlOl7K1dDP8tI8UIrXGAgLUqt6VqYxhJHD8aP/ moi@exemple.com')) + .toBe('256 SHA256:g9Z14JyNSnPMymEmRjYmBkQEOKHyRJ35HCgji2CdeJU moi@exemple.com (ED25519)'); + expect(fingerprintLine('ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBAW2x7icM6dWkJDcPIR1SN1BVH5lfMKMyshwt2hy4ceHsQnZ2DFcubEU9lyu1vZPaxAeN6qMELpyb73hOa+GDF0= thier@Extractor')) + .toBe('256 SHA256:1JR7ARFLcDIftBLDgOB3IiJ3LYkm1YOD8mb659xfs6o thier@Extractor (ECDSA)'); + expect(fingerprintLine('bonjour')).toBeNull(); + }); + + it('sha256 matches the FIPS 180-4 test vector', () => { + const hex = (b: number[]) => b.map((x) => x.toString(16).padStart(2, '0')).join(''); + expect(hex(sha256(Array.from('abc', (c) => c.charCodeAt(0))))).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); + }); +}); + +describe('ssh-keygen randomart', () => { + it('matches the art OpenSSH drew for a real fingerprint', () => { + // `ssh-keygen -t ed25519` printed this fingerprint and art (OpenSSH 10.5). + const fp = Array.from(atob('g9Z14JyNSnPMymEmRjYmBkQEOKHyRJ35HCgji2CdeJU='), (c) => c.charCodeAt(0)); + expect(randomart(fp, 'ED25519 256')).toEqual([ + '+--[ED25519 256]--+', + '|+*Oo==* . |', + '|*+o==E.. = = |', + '|*+oo oo.* X o |', + '|+o .oO B . |', + '| . o S |', + '| . . |', + '| |', + '| |', + '| |', + '+----[SHA256]-----+', + ]); }); }); @@ -1994,10 +2085,22 @@ describe('git', () => { expect(r.lines[0].text).toContain('empty commit message'); }); - it('git commit with no staged files returns info', () => { + // Expected values from git in a sandbox (29 September 2026): both cases exit 1. + it('git commit with nothing staged in a new repository prints the initial-commit status', () => { const s = makeState({ git: { initialized: true, branch: 'main', branches: ['main'], stagedFiles: [], commits: [], remotes: {} } }); const r = processCommand(s, 'git commit -m "empty"'); - expect(r.lines[0].text).toContain('nothing to commit'); + expect(r.lines.map((l) => l.text)).toEqual([ + 'On branch main', '', 'Initial commit', '', 'nothing to commit (create/copy files and use "git add" to track)', + ]); + expect(r.status).toBe(1); + }); + + it('git commit with nothing staged after a commit says the tree is clean', () => { + const commit = { hash: 'a1b2c3d', message: 'first', author: 'user', date: '2026-09-29' }; + const s = makeState({ git: { initialized: true, branch: 'main', branches: ['main'], stagedFiles: [], commits: [commit], remotes: {} } }); + const r = processCommand(s, 'git commit -m "empty"'); + expect(r.lines.map((l) => [l.text, l.type])).toEqual([['On branch main', 'output'], ['nothing to commit, working tree clean', 'output']]); + expect(r.status).toBe(1); }); // ── git log ─────────────────────────────────────────────────────────────────── @@ -2260,10 +2363,25 @@ describe('git', () => { }); it('git rebase -i shows interactive rebase guidance + shared-branch warning', () => { - const s = processCommand(makeState(), 'git init').newState; + // HEAD~3 needs three commits below HEAD: four commits in all. + let s = processCommand(makeState(), 'git init').newState; + for (const f of ['a', 'b', 'c', 'd']) { + s = processCommand(s, `git add ${f}`).newState; + s = processCommand(s, `git commit -m "${f}"`).newState; + } const r = processCommand(s, 'git rebase -i HEAD~3'); expect(r.lines.some((l) => l.text.toLowerCase().includes('interactif'))).toBe(true); - expect(r.lines.some((l) => l.text.includes('partagée'))).toBe(true); + // The warning is advice, not a failure: no red line. + expect(r.lines.some((l) => l.text.includes('partagée') && l.type !== 'error')).toBe(true); + expect(r.lines.some((l) => l.type === 'error')).toBe(false); + }); + + it('git rebase -i HEAD~N beyond the history is refused, as git does', () => { + // Real git 2.x, with 0 or 1 commit: "fatal: invalid upstream 'HEAD~3'" (exit 128). + let s = processCommand(makeState(), 'git init').newState; + expect(processCommand(s, 'git rebase -i HEAD~3').lines).toEqual([{ text: "fatal: invalid upstream 'HEAD~3'", type: 'error' }]); + s = processCommand(processCommand(s, 'git add a').newState, 'git commit -m "a"').newState; + expect(processCommand(s, 'git rebase -i HEAD~3').lines[0].text).toBe("fatal: invalid upstream 'HEAD~3'"); }); it('git rebase invalid upstream returns error', () => { @@ -2833,3 +2951,248 @@ describe('theory ↔ terminal: engine fidelity', () => { expect(out(createInitialState(), 'ping google.com')).toMatch(/^PING google\.com/); }); }); + +// ── Simulator gaps found by the /app/reference replay (26 September 2026) ──── +// Expected values come from the real shells, run in a sandbox with the same +// files: GNU bash 5.3 (Git Bash), PowerShell 7.6, git 2.x. Never from the engine. +describe('reference replay gaps — engine matches the real shells', () => { + const run = (cmds: string[], env: 'linux' | 'windows' = 'linux') => { + let s = createInitialState(); + let last = processCommand(s, cmds[0], env); + s = last.newState; + for (const c of cmds.slice(1)) { + last = processCommand(s, c, env); + s = last.newState; + } + return last; + }; + const text = (r: { lines: { text: string }[] }) => r.lines.map((l) => l.text).join('\n'); + const errors = (r: { lines: { type: string }[] }) => r.lines.filter((l) => l.type === 'error'); + + it('cat -n numbers lines like GNU cat (width 6, then a tab)', () => { + const r = run(['cat -n documents/notes.txt']); + expect(r.lines[0].text).toBe(' 1\tMes notes importantes'); + expect(r.lines[5].text).toBe(' 6\tFin du fichier'); + expect(errors(r)).toEqual([]); + }); + + it('cat rejects an unknown option with the GNU message', () => { + expect(text(run(['cat -z documents/notes.txt']))).toBe("cat: invalid option -- 'z'\nTry 'cat --help' for more information."); + }); + + it('grep -r searches a directory and prefixes each match with the file', () => { + expect(text(run(['grep -rn bash documents']))).toBe('documents/notes.txt:3:1. Apprendre les commandes bash'); + expect(text(run(['grep -r notes documents']))).toBe('documents/notes.txt:Mes notes importantes'); + }); + + it('grep without -r still refuses a directory', () => { + expect(text(run(['grep bash documents']))).toBe('grep: documents: Is a directory'); + }); + + it('git commit reads the message of -am, -mMSG and --message=MSG', () => { + for (const commit of ['git commit -am "fix: x"', 'git commit -m"fix: x"', 'git commit --message="fix: x"', 'git commit --message "fix: x"']) { + const r = run(['git init', 'git add README.md', commit]); + expect(r.lines[0].text, commit).toMatch(/^\[main [0-9a-f]{7}\] fix: x$/); + } + }); + + it('git cherry-pick A..B picks the commits after A up to B, oldest first', () => { + let s = run(['git init']).newState; + for (const m of ['one', 'two', 'three']) { + s = processCommand(s, `git add ${m}`).newState; + s = processCommand(s, `git commit -m "${m}"`).newState; + } + const [three, two, one] = s.git!.commits.map((c) => c.hash); + expect(two).toBeDefined(); + const r = processCommand(s, `git cherry-pick ${one}..${three}`); + expect(r.lines.filter((l) => l.type === 'success').map((l) => l.text.replace(/^\[main [0-9a-f]{7}\] /, ''))).toEqual(['two', 'three']); + }); + + // Values from GNU bash 5 and PowerShell 7: variables expand in every command, never inside single quotes. + it('expands variables in the arguments of any command, not only echo', () => { + expect(text(run(["echo '$HOME'"]))).toBe('$HOME'); + expect(text(run(['echo "$HOME et ${USER}"']))).toBe('/home/user et user'); + expect(text(run(['echo $NOPE fin']))).toBe('fin'); + expect(run(['cd /tmp', 'cd $HOME']).newState.cwd).toEqual(['home', 'user']); + expect(text(run(["export X='$HOME'", 'echo $X']))).toBe('$HOME'); + expect(text(run(['export PATH=$PATH:/opt/bin', 'echo $PATH']))).toBe('/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/bin'); + expect(text(run(['echo $HOME | grep $USER']))).toBe('/home/user'); + expect(text(run(["echo '$env:USERNAME'"], 'windows'))).toBe('$env:USERNAME'); + expect(run(['cd documents', 'cd $env:USERPROFILE'], 'windows').newState.cwd).toEqual(['home', 'user']); + expect(text(run(['Get-Content $env:USERPROFILE\\documents\\notes.txt'], 'windows'))).toBe(text(run(['cat documents/notes.txt']))); + }); + + // Values from OpenSSH 10.5, PowerShell 7 and GNU bash (29 September 2026). + it('ssh-keygen refuses odd options the way OpenSSH does, without crashing', () => { + const lines = (cmd: string) => run([cmd]).lines.map((l) => l.text); + expect(lines('ssh-keygen -t constructor')).toEqual(['unknown key type constructor']); + expect(run(['ssh-keygen -t constructor']).status).toBe(255); + expect(lines('ssh-keygen -t rsa -b 20000')).toEqual(['Invalid RSA key length: maximum is 16384 bits']); + expect(lines('ssh-keygen -t rsa -b 1000')).toEqual(['Invalid RSA key length: minimum is 1024 bits']); + expect(lines('ssh-keygen -t')).toEqual(['ssh-keygen: option requires an argument -- t']); + // -N "" is an empty passphrase, not a missing one: the passphrase prompts are skipped. + expect(lines('ssh-keygen -N ""').join('\n')).not.toContain('Enter passphrase'); + }); + + it('ssh-keygen -f on a folder asks to overwrite and leaves the folder alone', () => { + const r = run(['ssh-keygen -f documents -N ""']); + // "documents already exists." + "Overwrite (y/n)?" are OpenSSH's; the failure follows its `Saving key "%s" failed: %s`. + expect(r.lines.map((l) => l.text)).toEqual([ + 'Generating public/private ed25519 key pair.', + 'documents already exists.', + 'Overwrite (y/n)? y', + 'Saving key "documents" failed: Is a directory', + ]); + expect(r.status).toBe(1); + expect(text(processCommand(r.newState, 'ls documents'))).toContain('notes.txt'); + }); + + it('$env:Path = "$env:Path;..." keeps the old PATH, whatever the case of the name', () => { + expect(text(run(['$env:Path = "$env:Path;C:\\outils"', '$env:PATH'], 'windows'))) + .toBe('C:\\Windows\\System32;C:\\Windows;C:\\Program Files\\Git\\bin;C:\\outils'); + }); + + it('a variable cannot grow without bound [H3]', () => { + const cmds = [`export A=${'x'.repeat(600)}`, 'export A=$A$A', 'export A=$A$A', 'export A=$A$A']; + const s = run(cmds).newState; + expect(s.envVars.A.length).toBeLessThanOrEqual(1024); + expect(text(processCommand(s, 'echo $A')).length).toBeLessThanOrEqual(1025); + }); + + it('keeps an empty quoted word, like bash ("" is an argument)', () => { + expect(text(run(['echo "" fin']))).toBe(' fin'); + }); + + it('Select-String -Path file pattern, and Set-Content -Force file text', () => { + const r = run(['Set-Content -Force liste.txt bonjour', 'Get-Content liste.txt'], 'windows'); + expect(text(r)).toBe('bonjour'); + expect(text(run(['Select-String -Path documents\\notes.txt apprendre'], 'windows'))).toBe('documents\\notes.txt:3:1. Apprendre les commandes bash'); + }); + + it('Set-Content into a missing folder names the cmdlet and the full path', () => { + expect(text(run(['Set-Content nulle\\x.txt a'], 'windows'))).toBe("Set-Content: Could not find a part of the path 'C:\\Users\\user\\nulle\\x.txt'."); + }); + + // Values from GNU bash 5.3 and PowerShell 7.6 (terminal-fidelity-auditor, 29 September 2026). + it('bash: \$ is a literal dollar, ~ starts a home path, $1 is empty, $PWD is the folder', () => { + expect(text(run(['echo \\$HOME "\\$HOME"']))).toBe('$HOME $HOME'); + expect(text(run(['echo ~ ~/documents "~" a~']))).toBe('/home/user /home/user/documents ~ a~'); + expect(text(run(['echo $1 fin']))).toBe('fin'); + expect(text(run(['cd documents', 'echo $PWD']))).toBe('/home/user/documents'); + }); + + it('PowerShell: $HOME and $PWD are automatic variables', () => { + expect(text(run(['echo $HOME'], 'windows'))).toBe('C:\\Users\\user'); + expect(text(run(['cd documents', 'echo $PWD'], 'windows'))).toBe('C:\\Users\\user\\documents'); + expect(run(['cd documents', 'cd $HOME'], 'windows').newState.cwd).toEqual(['home', 'user']); + }); + + it('ssh-keygen -l prints the fingerprint the key was generated with', () => { + const made = run(['ssh-keygen -N ""']); + const shown = made.lines.map((l) => l.text).find((t) => t.startsWith('SHA256:')); + expect(shown).toBeDefined(); + const [fp, comment] = (shown ?? '').split(' '); + expect(text(processCommand(made.newState, 'ssh-keygen -lf ~/.ssh/id_ed25519.pub'))).toBe(`256 ${fp} ${comment} (ED25519)`); + // A private key is read through its .pub, as OpenSSH does. + expect(text(processCommand(made.newState, 'ssh-keygen -l -f ~/.ssh/id_ed25519'))).toBe(`256 ${fp} ${comment} (ED25519)`); + const missing = run(['ssh-keygen -lf nope.pub']); + expect(text(missing)).toBe('ssh-keygen: nope.pub: No such file or directory'); + expect(missing.status).toBe(255); + expect(text(run(['ssh-keygen -lf documents/notes.txt']))).toBe('documents/notes.txt is not a public key file.'); + }); + + it('ssh-keygen modes that are not simulated say so instead of making a key', () => { + const r = run(['ssh-keygen -y -f x']); + expect(r.lines.map((l) => l.type)).toEqual(['info']); + expect(r.newState.root).toEqual(createInitialState().root); + }); + + it('ssh-copy-id reports the key it added on Linux, and does not exist on Windows', () => { + const unix = run(['ssh-copy-id user@serveur.example.com']); + expect(errors(unix)).toEqual([]); + expect(text(unix)).toContain('Number of key(s) added: 1'); + expect(text(unix)).toContain('/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'); + expect(errors(run(['ssh-copy-id user@serveur.example.com'], 'windows'))).toHaveLength(1); + }); + + it('date +FORMAT applies the format', () => { + expect(text(run(['date +"%Y-%m-%d"']))).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it('Get-Content -TotalCount / -Head and -Tail keep the first or last lines', () => { + expect(text(run(['Get-Content documents\\notes.txt -TotalCount 3'], 'windows'))).toBe('Mes notes importantes\nTâches du jour:\n1. Apprendre les commandes bash'); + expect(text(run(['Get-Content documents\\notes.txt -Head 2'], 'windows'))).toBe('Mes notes importantes\nTâches du jour:'); + expect(text(run(['Get-Content documents\\notes.txt -Tail 2'], 'windows'))).toBe('3. Maîtriser les permissions\nFin du fichier'); + }); + + it('Get-Content (and cat under PowerShell) report a missing file like PowerShell', () => { + const expected = "Get-Content: Cannot find path 'C:\\Users\\user\\documents\\absent.txt' because it does not exist."; + expect(text(run(['Get-Content documents\\absent.txt'], 'windows'))).toBe(expected); + expect(text(run(['cat documents\\absent.txt'], 'windows'))).toBe(expected); + }); + + it('Get-Content rejects an unknown parameter like PowerShell', () => { + expect(text(run(['Get-Content documents\\notes.txt -n'], 'windows'))).toBe("Get-Content: A parameter cannot be found that matches parameter name 'n'."); + }); + + it('Set-Content writes a script that .\\script.ps1 then runs', () => { + const r = run(['Set-Content bonjour.ps1 \'Write-Output "Bonjour"\'', '.\\bonjour.ps1'], 'windows'); + expect(text(r)).toBe('Bonjour'); + expect(text(run(['Set-Content bonjour.ps1 \'Write-Output "Bonjour"\'', 'Get-Content bonjour.ps1'], 'windows'))).toBe('Write-Output "Bonjour"'); + }); + + it('Add-Content appends a line', () => { + const r = run(['Set-Content liste.txt un', 'Add-Content liste.txt deux', 'Get-Content liste.txt'], 'windows'); + expect(text(r)).toBe('un\ndeux'); + }); + + it('Select-String ignores case and prefixes ::, like PowerShell', () => { + expect(text(run(['Select-String -Pattern apprendre -Path documents\\notes.txt'], 'windows'))).toBe('documents\\notes.txt:3:1. Apprendre les commandes bash'); + expect(text(run(['Select-String -Pattern apprendre -Path documents\\notes.txt -CaseSensitive'], 'windows'))).toBe(''); + }); + + it('Select-String without a match succeeds ($? stays True in PowerShell 7), unlike grep', () => { + expect(run(['Select-String -Pattern zzz -Path documents\\notes.txt'], 'windows').status ?? 0).toBe(0); + expect(run(['grep zzz documents/notes.txt']).status).toBe(1); + }); + + it('$env:X += appends, names ignore case, and Windows session variables exist', () => { + const r = run(['$env:Path += ";C:\\outils"', '$env:PATH'], 'windows'); + expect(text(r)).toBe('C:\\Windows\\System32;C:\\Windows;C:\\Program Files\\Git\\bin;C:\\outils'); + expect(text(run(['$env:USERNAME'], 'windows'))).toBe('user'); + expect(text(run(['echo $env:userprofile'], 'windows'))).toBe('C:\\Users\\user'); + }); + + it('Get-History lists earlier commands in the PowerShell 7 table, without itself', () => { + const r = run(['ls', 'cd documents', 'Get-History'], 'windows'); + expect(r.lines.map((l) => l.text)).toEqual([ + '', + ' Id Duration CommandLine', + ' -- -------- -----------', + ' 1 0.010 ls', + ' 2 0.010 cd documents', + '', + ]); + }); + + it('Get-Date formats with -Format (.NET) and -UFormat (strftime)', () => { + expect(text(run(['Get-Date -Format "yyyy-MM-dd HH:mm"'], 'windows'))).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); + expect(text(run(['Get-Date -UFormat "%Y"'], 'windows'))).toMatch(/^\d{4}$/); + expect(text(run(['Get-Date'], 'windows'))).toMatch(/^[A-Z][a-z]+day, [A-Z][a-z]+ \d{1,2}, \d{4} \d{1,2}:\d{2}:\d{2} [AP]M$/); + }); + + it('Get-Help shows NAME and SYNOPSIS, and EXAMPLES with -Examples', () => { + const r = run(['Get-Help Get-ChildItem'], 'windows'); + expect(r.lines.slice(0, 4).map((l) => l.text)).toEqual(['NAME', ' Get-ChildItem', '', 'SYNOPSIS']); + // The synopsis is a sentence, never the bash syntax line of the help entry. + expect(text(r)).not.toContain('ls [-la]'); + expect(text(run(['Get-Help Get-ChildItem -Examples'], 'windows'))).toContain('EXAMPLES'); + expect(errors(run(['Get-Help Nimporte'], 'windows'))).toHaveLength(1); + expect(errors(run(['Get-Help ls']))).toHaveLength(1); // not a bash command + }); + + it('tasklist and Get-ScheduledTask print their Windows tables', () => { + expect(run(['tasklist'], 'windows').lines[1].text).toMatch(/^Image Name\s+PID Session Name\s+Session#\s+Mem Usage$/); + expect(run(['Get-ScheduledTask'], 'windows').lines[1].text).toMatch(/^TaskPath\s+TaskName\s+State$/); + }); +}); diff --git a/src/test/validators.test.ts b/src/test/validators.test.ts index a80547f..880cb87 100644 --- a/src/test/validators.test.ts +++ b/src/test/validators.test.ts @@ -342,8 +342,16 @@ describe('validateShellConfig', () => { }); describe('validateCron', () => { - it('accepts "crontab -l"', () => expect(validateCron('crontab -l')).toBe(true)); - it('rejects "crontab -e"', () => expect(validateCron('crontab -e')).toBe(false)); + it('accepts "crontab -l" on linux and macos', () => { + expect(validateCron('crontab -l', linux)).toBe(true); + expect(validateCron('crontab -l', macos)).toBe(true); + }); + it('rejects "crontab -e"', () => expect(validateCron('crontab -e', linux)).toBe(false)); + it('accepts "Get-ScheduledTask" on windows, with or without parameters', () => { + expect(validateCron('Get-ScheduledTask', win)).toBe(true); + expect(validateCron('get-scheduledtask -TaskName Sauvegarde', win)).toBe(true); + }); + it('rejects "crontab -l" on windows (PowerShell has no crontab)', () => expect(validateCron('crontab -l', win)).toBe(false)); }); // ── Réseau ────────────────────────────────────────────────────────────────────