diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd8107..9692efa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ --- +## ✳️ 2 octobre 2026 — Les jokers `*`, `?` et `[...]` fonctionnent + +*Terminal Linux et macOS · comportement vérifié sur bash 5.2 (Ubuntu 24.04)* + +- **`ls *.md`, `rm documents/*.txt`, `cat documents/*`, `wc -l documents/*`** : le terminal remplace maintenant les jokers par les fichiers qui correspondent, comme bash. Avant, `*` restait écrit tel quel et la commande cherchait un fichier nommé « * ». +- Les règles sont celles de bash : les fichiers cachés ne sont pris que par un motif qui commence par un point, `*/` ne garde que les dossiers, `[!d]*` exclut, et un motif entre guillemets ou précédé de `\` reste écrit tel quel. Quand rien ne correspond, le mot reste inchangé : `ls *.txt` répond `cannot access '*.txt'`, comme sur un vrai système. +- **`ls` avec plusieurs fichiers ou dossiers** les affiche tous, comme GNU `ls` : d'abord les fichiers, puis chaque dossier sous son nom. Avant, seul le premier apparaissait. `ls -A`, `ls -r`, `ls -lF`, `--all` et `--` répondent aussi comme le vrai, et une erreur rend le code de sortie 2. +- **Les commandes qui reçoivent plusieurs fichiers les traitent tous** : `chmod +x documents/*`, `rm *` (un message par dossier refusé), `mkdir`, et `head` / `tail`, qui affichent chaque fichier sous un en-tête `==> nom <==`. `head -2` fonctionne, et `rm -f` se tait quand il n'y a rien à effacer. +- **Les jokers marchent aussi dans les redirections** : `echo hi > documents/*.md` écrit dans `rapport.md`, et `echo hi > *` répond `ambiguous redirect` comme bash. `cd d*` répond `too many arguments` quand deux dossiers correspondent. +- Les classes `[[:alpha:]]`, `[[:lower:]]`… sont reconnues entre crochets. +- Un audit de 573 commandes comparées au vrai bash a guidé ces corrections. +- Sous Windows, rien ne change : PowerShell laisse chaque cmdlet lire les jokers elle-même. + +--- + ## 🔎 2 octobre 2026 — Chercher des fichiers et enchaîner : `find`, `xargs`, `rmdir` *Phase 5d, lot 1 · sorties vérifiées sur Ubuntu 24.04 (findutils 4.9, coreutils 9.4, grep 3.11)* diff --git a/STORY.md b/STORY.md index 9f9b7a9..e4c9405 100644 --- a/STORY.md +++ b/STORY.md @@ -1295,6 +1295,16 @@ Une capture a aussi mal tourné. Une commande passée à WSL a été découpée Dernière découverte, la plus importante pour la suite : le terminal Linux ne remplace pas les jokers. `ls *.txt` ou `rm documents/*` échouent, alors que c'est l'un des premiers réflexes de bash. Ce sera la prochaine correction. +### Une étoile qui ne voulait rien dire (2 octobre 2026) + +Le dernier défaut révélé par `find` était le plus étonnant : depuis le début, le terminal ne connaissait pas les jokers. Un élève qui tapait `rm *.txt` recevait « No such file or directory », comme si un fichier s'appelait vraiment « * ». + +Dans bash, ce n'est pas la commande qui comprend l'étoile, c'est le shell. Avant même que `rm` démarre, bash remplace `*.txt` par la liste des fichiers qui correspondent. Le simulateur le fait maintenant au même endroit, au moment où il découpe la ligne en mots. Il retient pour chaque caractère s'il a été tapé librement ou protégé par des guillemets ou une barre oblique inverse, puisque `'*'` et `\*` doivent rester des étoiles. + +Les quarante captures faites sur le vrai bash ont fixé le reste : l'ordre des résultats, les fichiers cachés, `*/` pour les dossiers, et le mot laissé tel quel quand rien ne correspond. Elles ont aussi montré que `ls` n'affichait que le premier fichier quand on lui en donnait plusieurs, un défaut invisible tant que personne ne pouvait taper `ls documents/*`. + +L'auditeur de fidélité a ensuite rejoué 573 commandes contre le vrai bash. Le cœur des jokers tenait bon, mais l'étoile a révélé une famille entière de défauts cachés : `chmod`, `rm`, `mkdir`, `head` et `tail` ne traitaient que leur premier fichier. Personne ne l'avait vu, parce qu'avant les jokers, presque personne ne leur en donnait plusieurs. Une seule nouveauté a suffi à rendre visibles des écarts anciens : c'est pour cela que chaque PR repasse devant un vrai shell. + --- ## Épilogue ouvert diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c1831e8..6a84da5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,7 +2,7 @@ > **Decision (1 October 2026, @thierry):** Terminal Learning becomes **the reference for learning the terminal from scratch up to an intermediate level**, within what a faithful simulation can do. Job-specific tools (offensive security, network analysis, OSINT) are not simulated; the site points to the free platforms that already teach them. Ordered plan: [Phase 5d](#phase-5d--from-the-basics-to-an-intermediate-level--planned-1-october-2026). Delivery checklist: [`docs/processes/curriculum-delivery-checklist.md`](processes/curriculum-delivery-checklist.md). > -> **Last update:** 2 October 2026 — **Phase 5d, Lot 1, PR 2** (#418): `find`, `xargs`, `rmdir`, GNU `wc` widths, `grep -l`; reference now 81 commands. Next: globbing (`ls *.txt`). Before: **PR 1** (#417): `sort`, `uniq`, `cut` faithful to GNU coreutils 9.4 (captured on WSL's Ubuntu), `Sort-Object`, `Get-Unique`, `Group-Object`; reference now 79 commands. Before: **Lot 0b** (#416): `llms.txt`, `llms-full.txt` and the `index.html` metadata are checked against the curriculum by a test; 10 stale lesson titles fixed. Next: Lot 1 (`sort`, `uniq`, `cut`). +> **Last update:** 2 October 2026 — **Phase 5d, Lot 1, PR 2b** (#419): wildcards `*`, `?`, `[...]` expand as in bash; `ls` with several operands. Before: **PR 2** (#418): `find`, `xargs`, `rmdir`, GNU `wc` widths, `grep -l`; reference now 81 commands. Next: globbing (`ls *.txt`). Before: **PR 1** (#417): `sort`, `uniq`, `cut` faithful to GNU coreutils 9.4 (captured on WSL's Ubuntu), `Sort-Object`, `Get-Unique`, `Group-Object`; reference now 79 commands. Before: **Lot 0b** (#416): `llms.txt`, `llms-full.txt` and the `index.html` metadata are checked against the curriculum by a test; 10 stale lesson titles fixed. Next: Lot 1 (`sort`, `uniq`, `cut`). > > **Previous update:** 1 October 2026 CEST — 🩺 **Full check-up (P1 terminal fidelity, THI-353), continued**: #396 every reference example runs · #397 tables, blank lines and mobile keys · #398 the installed app clears iOS 26's edge blur · #399 git reads real files, outputs match Git 2.56 · **#400 real git branches** (commit graph, ids identical to real git, merges and real conflicts; two fidelity audits, two code reviews) · #401 the landing page only lists commands the terminal runs and the lessons teach, and its terminal preview prints what real shells print. Planned: a **customising the terminal** module (idea from @thierry, #402). **Multi-step exercises checked on the terminal state** (the conflict lesson now resolves a real conflict end to end; no more auto-advance, a « Suivant » button instead). **Git module as steps** (init, first commit, diff, branch, merge). **Remote repositories as in real git** (`push`, `pull`, `fetch`, `clone`, `remote`, branch tracking in `status` and `branch -vv`, #406). **A remote where a colleague pushes** (`fetch`/`pull` bring real commits, a push is rejected with "fetch first", `gh` says it is not simulated; theory gaps 146 → 120). **GitHub module as steps** (remote, push & pull, fetch, pull request, merge strategies, GitHub Actions; #410, theory gaps 120 → 90). **The top of the installed iPhone app is clear at last** (#411, confirmed on @thierry's iPhone). **#413 Windows fidelity** (`ls`/`Get-ChildItem` print PowerShell 7.6's real table, `rm`/`cp`/`mv` are the cmdlets with their parameters — `rm -rf` is refused —, PowerShell's error messages, reference page updated; bash shown on Windows 34 → 32). Next: the ~20 commands taught but not simulated yet (`find`, `sort`, `diff`, `tar`, `Where-Object`…) and the remaining Windows debt (`New-Item`'s table, case-insensitive paths). > @@ -170,7 +170,8 @@ Full rationale and scope: [`docs/strategy/2026-10-01-bases-vers-intermediaire.md - [ ] **Lot 1** — search and sort: `sort`, `uniq`, `cut`, `find`, `xargs`, `diff`, useful `wc`/`grep` options; PowerShell `Sort-Object`, `Where-Object`, `Select-Object` (3–4 PRs) - [x] PR 1 (#417): `sort`, `uniq`, `cut` on files and pipes (GNU coreutils 9.4, en_US.UTF-8), `Sort-Object`, `Get-Unique`, `Select-Object -Unique`, `Group-Object`; reference 76 → 79 commands - [x] PR 2 (#418): `find` (tests, `-exec`, `-delete`, documented NTFS order), `xargs`, `rmdir`, `wc` with GNU widths, `grep -v/-c/-l/-L` on files, bash backslash removal; reference 79 → 81 - - [ ] PR 2b: pathname globbing (`*`, `?`, `[...]`) in the Linux/macOS terminal · PR 3: `diff`, `Where-Object`, `Compare-Object` · PR 4: lesson(s) on searching, sorting, counting and cutting + - [x] PR 2b (#419): pathname globbing (`*`, `?`, `[...]`) in the Linux/macOS terminal, as bash 5.2 does it; `ls` with several operands as GNU `ls`, and `ls -d` + - [ ] PR 3: `diff`, `Where-Object`, `Compare-Object` · PR 4: lesson(s) on searching, sorting, counting and cutting - [ ] **Lot 2** — archives and disk: `tar`, `gzip`, `zip`/`unzip`, `Compress-Archive`, `du`, `df`, `which`/`type`/`Get-Command` (1–2 PRs) - [ ] **Lot 3** — `sed` and `awk`, the subset a beginner uses, with an honest note beyond it (1–2 PRs) - [ ] **Lot 4** — "Investigating logs" module: fictional auth and web server logs, step-by-step missions without the answer (2–3 PRs) diff --git a/docs/plan.md b/docs/plan.md index 8c789a7..45c6889 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -2,7 +2,7 @@ > **Décision du 1er octobre 2026 (@thierry)** : Terminal Learning devient **la référence pour apprendre le terminal de zéro jusqu'au niveau intermédiaire**, dans les limites d'une simulation fidèle. Les outils de métier (sécurité offensive, analyse réseau, OSINT) ne sont pas simulés : le site oriente vers les plateformes gratuites qui les enseignent. Plan ordonné : [`docs/strategy/2026-10-01-bases-vers-intermediaire.md`](strategy/2026-10-01-bases-vers-intermediaire.md) · procédure de livraison : [`docs/processes/curriculum-delivery-checklist.md`](processes/curriculum-delivery-checklist.md). > -> Dernière mise à jour : **2 octobre 2026** — **Phase 5d, lot 1, PR 2** (#418) : `find`, `xargs`, `rmdir`, `wc` aux largeurs GNU, `grep -l` ; Référence 81 commandes. Suite : les jokers (`ls *.txt`). Avant : **PR 1** (#417) : `sort`, `uniq`, `cut` fidèles à GNU coreutils 9.4 (capturés sur l'Ubuntu de WSL), `Sort-Object`, `Get-Unique`, `Group-Object` ; Référence 79 commandes. Avant : **lot 0b** (#416) : `llms.txt`, `llms-full.txt` et les métadonnées d'`index.html` sont comparés au curriculum par un test ; 10 titres de leçon périmés corrigés. Suite : lot 1 (`sort`, `uniq`, `cut`). +> Dernière mise à jour : **2 octobre 2026** — **Phase 5d, lot 1, PR 2b** (#419) : les jokers `*`, `?`, `[...]` sont développés comme dans bash ; `ls` avec plusieurs fichiers. Avant : **PR 2** (#418) : `find`, `xargs`, `rmdir`, `wc` aux largeurs GNU, `grep -l` ; Référence 81 commandes. Suite : les jokers (`ls *.txt`). Avant : **PR 1** (#417) : `sort`, `uniq`, `cut` fidèles à GNU coreutils 9.4 (capturés sur l'Ubuntu de WSL), `Sort-Object`, `Get-Unique`, `Group-Object` ; Référence 79 commandes. Avant : **lot 0b** (#416) : `llms.txt`, `llms-full.txt` et les métadonnées d'`index.html` sont comparés au curriculum par un test ; 10 titres de leçon périmés corrigés. Suite : lot 1 (`sort`, `uniq`, `cut`). > > Précédente mise à jour : **1er octobre 2026 CEST** — **🩺 Grand Check-up, P1 « fidélité du terminal » (THI-353), suite** : #396 exemples de la Référence tous exécutables · #397 tableaux, lignes vides et touches du clavier mobile · #398 haut de l'app installée dégagé du flou d'iOS 26 · #399 git lit les vrais fichiers, sorties identiques à Git 2.56 · **#400 vraies branches Git** (graphe de commits, identifiants identiques au vrai Git, fusions et vrais conflits ; deux audits de fidélité et deux revues de code) · #401 page d'accueil : ne promet plus que des commandes simulées · #402 module « Personnaliser son terminal » annoncé · **exercices en plusieurs étapes vérifiés sur l'état du terminal** (le conflit Git se résout de A à Z, plus de saut automatique : bouton « Suivant »). · **module Git en étapes** (init, premier commit, diff, branche, fusion) · **dépôts distants identiques au vrai Git** (`push`, `pull`, `fetch`, `clone`, `remote`, suivi de branche dans `status` et `branch -vv`, #406) · **dépôt distant où un collègue pousse** (`fetch`/`pull` ramènent de vrais commits, push refusé « fetch first », `gh` dit qu'il n'est pas simulé ; écarts théorie 146 → 120) · **module GitHub en étapes** (remote, push & pull, fetch, Pull Request, stratégies de merge, GitHub Actions ; #410, écarts théorie 120 → 90) · **haut de l'app installée sur iPhone enfin net** (#411, confirmé sur l'iPhone de @thierry). **#413 fidélité Windows** (`ls`/`Get-ChildItem` affichent le vrai tableau de PowerShell 7.6, `rm`/`cp`/`mv` sont les cmdlets avec leurs paramètres — `rm -rf` refusé —, erreurs de PowerShell, page Référence à jour ; bash montré sous Windows 34 → 32). Suite : les ~20 commandes enseignées mais pas encore simulées (`find`, `sort`, `diff`, `tar`, `Where-Object`…) et la dette Windows restante (tableau de `New-Item`, chemins insensibles à la casse). > Précédent : **26 septembre 2026 CEST** — **🩺 Grand Check-up, P1 « fidélité du terminal » (THI-353)** : #389 vraie couche shell (**198/198 exercices sans ligne rouge**) · #390 sorties d'un vrai shell · #391 rejeu permanent de la théorie · #392 `cp`/`mv` vers un dossier + chemins Windows natifs (écarts théorie **174 → 162**) · #393 agents d'audit remis à jour + `terminal-fidelity-auditor` · roadmap publique corrigée. Suite : messages d'erreur PowerShell, page Référence, écarts restants. @@ -256,7 +256,7 @@ Détail, périmètre et justification : [`docs/strategy/2026-10-01-bases-vers-in - [ ] **Lot 1** — chercher et trier : `sort`, `uniq`, `cut`, `find`, `xargs`, `diff` ; `Sort-Object`, `Where-Object`, `Select-Object` - [x] PR 1 (#417) : `sort`, `uniq`, `cut` sur fichiers et pipes (GNU coreutils 9.4, en_US.UTF-8), `Sort-Object`, `Get-Unique`, `Select-Object -Unique`, `Group-Object` ; Référence 76 → 79 commandes. Dette notée : `Get-Content a, b` (plusieurs fichiers) et `ForEach-Object` non simulés ; sous Windows, une commande inconnue garde le message pédagogique « commande introuvable » au lieu de celui de PowerShell ; **`--help` est faux pour toutes les autres commandes** (`ls --help` liste le dossier, `cat --help` cherche un fichier) : à traiter dans une PR dédiée (`sort`, `uniq` et `cut` affichent déjà une note) ; cas rares de `Group-Object` (`-AsString` sans `-AsHashTable`, `| Measure-Object` compte les lignes du tableau) - [x] PR 2 (#418) : `find` (tests, `-exec`, `-delete`, ordre NTFS documenté), `xargs`, `rmdir`, `wc` aux largeurs GNU, `grep -v/-c/-l/-L` sur fichiers, barre oblique inverse retirée comme bash ; Référence 79 → 81. Dette notée : **aucun joker n'est développé sous Linux** (`ls *.txt`, `rm documents/*`) → PR suivante ; `ls` avec plusieurs dossiers n'affiche que le premier ; largeur de la colonne taille de `ls -l` ; `wc` sous Windows (PowerShell n'en a pas). Relevé par l'audit de fidélité du 2 octobre : `$?` n'est pas développé (les codes de sortie, désormais justes, restent invisibles) ; `printf`, `echo -e` et `echo -n` ; options de `grep` `-q`, `-o`, `-h`, `-H`, `-m`, `-s`, `-F`, l'alternance `\|`, et `-l`/`-L` sur l'entrée standard (PR `grep` dédiée, utile au lot 4) ; parenthèses non citées (`find . ( -name x )` doit être une erreur de syntaxe) ; `wc < fichier` compte comme un pipe ; `chmod` affiche un message alors que le vrai est silencieux - - [ ] PR 2b : développement des jokers (`*`, `?`, `[...]`) dans le terminal Linux/macOS + - [x] PR 2b (#419) : développement des jokers (`*`, `?`, `[...]`) dans le terminal Linux/macOS, à la manière de bash 5.2 ; `ls` avec plusieurs opérandes comme GNU `ls`, et `ls -d`. Après l'audit de fidélité (573 commandes) : jokers dans les redirections (`ambiguous redirect`), `cd` à plusieurs opérandes, `chmod`/`rm`/`mkdir`/`head`/`tail` sur tous leurs fichiers (en-têtes `==>`), `head -2`, `rm -f`, `ls -A`/`-r`/`-lF`/`--all`/`--` et code de sortie 2, classes `[[:alpha:]]` et `]` en tête de crochets. Dette notée : accolades (`*.{md,txt}`) ; un `$VAR` non cité qui contient `*` n'est pas développé ; `[a\-z]` (barre oblique inverse entre crochets) ; `documents//*` et `*//` ; `ls -R` non récursif ; `ls` range les noms à l'ordre des octets au lieu de la locale (`*.txt` avant `documents`, fichiers cachés en tête) ; GNU met entre guillemets un nom qui contient un caractère spécial (`cat: '*': No such file…`) ; `[[.d.]]` et `[[=d=]]` ; `./README.md` sans droit d'exécution rend 126 ; les commentaires `#` ne sont pas ignorés ; `cp` dit `is not a directory` au lieu de `Not a directory` ; `touch --` et options inconnues de `touch` ; `cd nope` sans le préfixe `bash:` ; `date *`, `pwd *` ne refusent pas leurs opérandes - [ ] PR 3 : `diff`, `Where-Object`, `Compare-Object` - [ ] PR 4 : leçon(s) « Trier, compter, découper » dans les trois environnements - [ ] **Lot 2** — archives et disque : `tar`, `gzip`, `zip`, `Compress-Archive`, `du`, `df`, `which`/`Get-Command` diff --git a/src/app/data/commandExamples.ts b/src/app/data/commandExamples.ts index 1db0bc6..0d3c46c 100644 --- a/src/app/data/commandExamples.ts +++ b/src/app/data/commandExamples.ts @@ -28,6 +28,7 @@ export const COMMAND_EXAMPLES: Record = { { command: 'ls', explanation: 'Liste les fichiers et dossiers visibles du dossier courant.', environments: UNIX }, { command: 'ls -la', explanation: '-l affiche le détail (droits, propriétaire, taille, date) et -a ajoute les fichiers cachés, ceux dont le nom commence par un point.', environments: UNIX }, { command: 'ls documents', explanation: 'Liste le contenu d\'un autre dossier sans t\'y déplacer.', environments: UNIX }, + { command: 'ls documents/*.md', explanation: 'L\'étoile est un joker : avant même que ls démarre, le shell la remplace par les fichiers qui correspondent. Si rien ne correspond, le mot reste tel quel et ls répond qu\'il ne trouve pas « *.md ».', environments: UNIX }, { command: 'Get-ChildItem', explanation: 'Liste le contenu du dossier courant dans un tableau : type (Mode), date de modification, taille en octets et nom. ls et dir sont des raccourcis de cette commande.', environments: WIN }, { command: 'Get-ChildItem -Name', explanation: 'Seulement les noms, un par ligne, sans le tableau.', environments: WIN }, { command: 'Get-ChildItem -Force', explanation: 'Ajoute les éléments cachés, comme le dossier .git. Sous Windows, un nom qui commence par un point ne cache rien : c\'est un attribut qui cache un fichier. ls -la, lui, ne fonctionne pas dans PowerShell.', environments: WIN }, diff --git a/src/app/data/commands/glob.ts b/src/app/data/commands/glob.ts new file mode 100644 index 0000000..409d237 --- /dev/null +++ b/src/app/data/commands/glob.ts @@ -0,0 +1,137 @@ +import { compareLocale } from './textTools'; + +/** + * Pathname expansion (globbing) as bash 5.2 does it in the Linux and macOS + * terminals, captured on Ubuntu 24.04 on 2 October 2026: + * - `*`, `?` and `[...]` (`[!x]` and `[^x]` negate) in an unquoted word; + * - names that start with a dot only match a pattern that starts with a dot, + * and `.` and `..` never do (bash 5.2's globskipdots); + * - a trailing slash keeps folders only (`echo *` then `/` lists the folders); + * - the matches are sorted in the locale's order, like `sort`; + * - a word that matches nothing is left as typed (`ls *.txt` → + * `ls: cannot access '*.txt'`). + * PowerShell does not expand words: its cmdlets read wildcards themselves. + */ + +export interface GlobEntry { name: string; isDir: boolean } + +/** The entries of the folder at these absolute path parts, or null when it is not a folder. */ +export type ListDir = (parts: string[]) => GlobEntry[] | null; + +/** Does this word hold a wildcard typed without quotes or backslash? */ +export const hasActiveWildcard = (meta: boolean[]) => meta.some(Boolean); + +/** The character classes bash knows inside brackets (`[[:alpha:]]*`). */ +const CHAR_CLASSES: Record = { + alpha: '\\p{L}', upper: '\\p{Lu}', lower: '\\p{Ll}', digit: '0-9', alnum: '\\p{L}\\p{N}', + space: '\\s', blank: ' \\t', punct: '\\p{P}\\p{S}', xdigit: '0-9A-Fa-f', + graph: '\\p{L}\\p{M}\\p{N}\\p{P}\\p{S}', print: '\\p{L}\\p{M}\\p{N}\\p{P}\\p{S} ', cntrl: '\\p{Cc}', +}; + +/** + * The bracket expression that starts at `chars[start]` (a `[`), as bash reads it: + * `!` or `^` negates, a `]` right after the opening is a member, and `[:name:]` + * is a class. Returns its RegExp text and where it ends; 'literal' when there is + * no closing bracket (the `[` is then an ordinary character); 'never' for an + * unknown class, which matches nothing. + */ +function bracketExpression(chars: string[], start: number): { re: string; end: number } | 'literal' | 'never' { + let i = start + 1; + const negate = chars[i] === '!' || chars[i] === '^'; + if (negate) i++; + let body = ''; + // A `-` right after a class is a plain member (`[[:digit:]-a]`), not a range. + let afterClass = false; + for (let first = true; i < chars.length; i++, first = false) { + const c = chars[i]; + if (c === ']' && !first) return { re: `[${negate ? '^' : ''}${body}]`, end: i }; + if (c === '[' && chars[i + 1] === ':') { + const close = chars.join('').indexOf(':]', i + 2); + if (close > 0) { + const name = chars.slice(i + 2, close).join(''); + if (!Object.prototype.hasOwnProperty.call(CHAR_CLASSES, name)) return 'never'; + body += CHAR_CLASSES[name]; + i = close + 1; + afterClass = true; + continue; + } + } + body += '\\[]^'.includes(c) || (c === '-' && afterClass) ? `\\${c}` : c; + afterClass = false; + } + return 'literal'; +} + +/** One path segment as a RegExp; null when the pattern cannot match anything (`[z-a]`). */ +function segmentRegex(chars: string[], meta: boolean[]): RegExp | null { + let re = ''; + for (let i = 0; i < chars.length; i++) { + const c = chars[i]; + // `**` is `*` (no globstar); one `.*` per run keeps backtracking low. + if (meta[i] && c === '*') { if (!re.endsWith('.*')) re += '.*'; } + else if (meta[i] && c === '?') re += '.'; + else if (meta[i] && c === '[') { + const bracket = bracketExpression(chars, i); + if (bracket === 'never') return null; + if (bracket === 'literal') { re += '\\['; continue; } + re += bracket.re; + i = bracket.end; + } else re += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + try { + return new RegExp(`^${re}$`, 'su'); + } catch { + return null; + } +} + +/** + * Expands `word` (with `meta[i]` true for each active wildcard character), + * relative to `cwd`. Returns the sorted matches, or [] when there is none. + */ +export function expandGlob(word: string, meta: boolean[], cwd: string[], list: ListDir): string[] { + // UTF-16 units, like the flags parseArgs keeps for each character. + const chars = word.split(''); + // Split into segments on "/", keeping each character's flag. + const segments: { chars: string[]; meta: boolean[] }[] = [{ chars: [], meta: [] }]; + chars.forEach((c, i) => { + if (c === '/') segments.push({ chars: [], meta: [] }); + else { + segments[segments.length - 1].chars.push(c); + segments[segments.length - 1].meta.push(meta[i] ?? false); + } + }); + const absolute = word.startsWith('/'); + let found: { parts: string[]; shown: string; isDir: boolean }[] = [{ parts: absolute ? [] : [...cwd], shown: absolute ? '/' : '', isDir: true }]; + + segments.forEach((seg, index) => { + const last = index === segments.length - 1; + if (seg.chars.length === 0) { + // A leading "/" (already in `shown`), "//", or a trailing "/": folders only. + if (index === 0) return; + found = found.filter((f) => f.isDir).map((f) => ({ ...f, shown: `${f.shown}/` })); + return; + } + const join = (shown: string, name: string) => (shown === '' || shown.endsWith('/') ? `${shown}${name}` : `${shown}/${name}`); + const text = seg.chars.join(''); + if (!seg.meta.some(Boolean)) { + // A literal segment: it must exist (a folder, unless it is the last one). + found = found.flatMap((f) => { + const parts = text === '.' ? f.parts : text === '..' ? f.parts.slice(0, -1) : [...f.parts, text]; + const parent = list(f.parts); + const entry = text === '.' || text === '..' ? { isDir: true } : parent?.find((e) => e.name === text); + if (!entry || (!last && !entry.isDir)) return []; + return [{ parts, shown: join(f.shown, text), isDir: entry.isDir }]; + }); + return; + } + const re = segmentRegex(seg.chars, seg.meta); + if (!re) { found = []; return; } + const dotted = seg.chars[0] === '.' && !seg.meta[0]; + found = found.flatMap((f) => (list(f.parts) ?? []) + .filter((e) => (dotted || !e.name.startsWith('.')) && e.name !== '.' && e.name !== '..' && re.test(e.name)) + .filter((e) => last || e.isDir) + .map((e) => ({ parts: [...f.parts, e.name], shown: join(f.shown, e.name), isDir: e.isDir }))); + }); + return found.map((f) => f.shown).sort(compareLocale); +} diff --git a/src/app/data/commands/shellSyntax.ts b/src/app/data/commands/shellSyntax.ts index dd71606..9d1ff10 100644 --- a/src/app/data/commands/shellSyntax.ts +++ b/src/app/data/commands/shellSyntax.ts @@ -10,8 +10,11 @@ import type { TerminalEnv } from './types'; export type Fd = 1 | 2; export type Redirect = - /** `>`, `>>`, `2>`, `&>` (fd 'both'): send a stream to a file. */ - | { kind: 'file'; fd: Fd | 'both'; append: boolean; target: string } + /** + * `>`, `>>`, `2>`, `&>` (fd 'both'): send a stream to a file. `target` has its + * outer quotes removed; `raw` is the word as typed, for bash to expand. + */ + | { kind: 'file'; fd: Fd | 'both'; append: boolean; target: string; raw: string } /** `2>&1`, `>&2`: point `fd` wherever `to` currently points. */ | { kind: 'dup'; fd: Fd; to: Fd }; @@ -21,6 +24,8 @@ export interface Stage { redirects: Redirect[]; /** `< file`: read standard input from a file. */ stdinFile?: string; + /** The `< file` word as typed. */ + stdinRaw?: string; } export interface ListItem { @@ -129,14 +134,16 @@ export function parseCommandLine(line: string, env: TerminalEnv): ParseResult { let words: string[] = []; let redirects: Redirect[] = []; let stdinFile: string | undefined; + let stdinRaw: string | undefined; let pendingOp: ListItem['op'] = null; const endStage = (next: string): string | null => { if (words.length === 0) return `syntax error near unexpected token \`${next}'`; - stages.push({ text: words.join(' '), redirects, ...(stdinFile !== undefined ? { stdinFile } : {}) }); + stages.push({ text: words.join(' '), redirects, ...(stdinFile !== undefined ? { stdinFile, stdinRaw } : {}) }); words = []; redirects = []; stdinFile = undefined; + stdinRaw = undefined; return null; }; @@ -155,8 +162,8 @@ export function parseCommandLine(line: string, env: TerminalEnv): ParseResult { } i++; const path = unquote(target.raw); - if (tk.inFile) stdinFile = path; - else redirects.push({ kind: 'file', fd: tk.fd, append: tk.append, target: path }); + if (tk.inFile) { stdinFile = path; stdinRaw = target.raw; } + else redirects.push({ kind: 'file', fd: tk.fd, append: tk.append, target: path, raw: target.raw }); } else if (tk.op === '|') { const err = endStage('|'); if (err) return { ok: false, error: err }; diff --git a/src/app/data/commands/windows.ts b/src/app/data/commands/windows.ts index 56c8e6d..0a6d5e0 100644 --- a/src/app/data/commands/windows.ts +++ b/src/app/data/commands/windows.ts @@ -138,7 +138,7 @@ function psErrors(cmdlet: string, lines: OutputLine[], state: TerminalState, env return lines.map((l) => { if (l.type !== 'error') return l; let m: RegExpMatchArray | null; - if ((m = l.text.match(/cannot create directory '(.+)': File exists$/))) return err(`New-Item: An item with the specified name ${win(m[1])} already exists.`); + if ((m = l.text.match(/cannot create directory ['‘](.+)['’]: File exists$/))) return err(`New-Item: An item with the specified name ${win(m[1])} already exists.`); if ((m = l.text.match(/^(?:mv|cp): cannot stat '(.+)': No such file or directory$/) ?? l.text.match(/^rm: cannot remove '(.+)': No such file or directory$/))) { return err(`${cmdlet}: Cannot find path '${win(m[1])}' because it does not exist.`); } diff --git a/src/app/data/landingContent.ts b/src/app/data/landingContent.ts index ca6c3a4..d593f0f 100644 --- a/src/app/data/landingContent.ts +++ b/src/app/data/landingContent.ts @@ -143,7 +143,7 @@ export const ROADMAP_IN_PROGRESS: readonly RoadmapGroup[] = [ { group: 'Des bases au niveau intermédiaire', items: [ - 'Chercher, trier, compter et découper : sort, uniq, cut, find et xargs sont là ; diff arrive', + 'Chercher, trier, compter et découper : sort, uniq, cut, find, xargs et les jokers (*, ?, [...]) sont là ; diff arrive', 'Archives et disque, sed et awk, les éditeurs nano et vim', 'Enquêter dans des journaux, écrire ses premiers scripts', 'Une mission finale sans indice, puis une page « Et après ? » vers des labos gratuits pour chaque métier', diff --git a/src/app/data/terminalEngine.ts b/src/app/data/terminalEngine.ts index 55ab40b..72ca91b 100644 --- a/src/app/data/terminalEngine.ts +++ b/src/app/data/terminalEngine.ts @@ -14,6 +14,7 @@ import type { PsCmdlet } from './commands/psParams'; import { GET_UNIQUE, GROUP_OBJECT, SORT_OBJECT, helpNote, psGetUnique, psGroupLines, psSortLines, runCut, runSort, runUniq, runWc } from './commands/textTools'; import type { ReadText } from './commands/textTools'; import { runFind, runXargs } from './commands/find'; +import { expandGlob } from './commands/glob'; import type { FindDeps } from './commands/find'; import { parseCommandLine, isPlainCommand, isNullDevice } from './commands/shellSyntax'; import type { Stage, Fd } from './commands/shellSyntax'; @@ -315,9 +316,24 @@ function deepCloneRoot(root: DirectoryNode): DirectoryNode { * 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[] { +type Expansion = { vars: Record; env: TerminalEnv; glob?: (word: string, meta: boolean[]) => string[] }; + +function parseArgs(input: string, expand?: Expansion): string[] { const result: string[] = []; let current = ''; + // One flag per character of `current`: true for a wildcard typed without quotes or backslash. + let meta: boolean[] = []; + const add = (text: string, active = false) => { + current += text; + for (let k = 0; k < text.length; k++) meta.push(active); + }; + const pushWord = () => { + const matches = expand?.glob && meta.some(Boolean) ? expand.glob(current, meta) : []; + if (matches.length) result.push(...matches); + else result.push(current); + current = ''; + meta = []; + }; // `""` is an empty argument (`ssh-keygen -N ""`); an unquoted empty expansion is not. let quoted = false; let inQuote = false; @@ -332,7 +348,7 @@ function parseArgs(input: string, expand?: { vars: Record; env: const ch = input[i]; const literal = inQuote && quoteChar === "'"; if (expand && !windows && !literal && ch === '\\' && input[i + 1] === '$') { - current += '$'; + add('$'); i++; continue; } @@ -340,14 +356,14 @@ function parseArgs(input: string, expand?: { vars: Record; env: // `\(`, `\*`); inside double quotes, only before $ ` " and \. PowerShell's escape is the backtick: // there a backslash separates folders. if (expand && !windows && ch === '\\' && i + 1 < input.length && (!inQuote || (quoteChar === '"' && '`"\\'.includes(input[i + 1])))) { - current += input[i + 1]; + add(input[i + 1]); if (!inQuote) quoted = true; 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'); + add(capValue(Object.prototype.hasOwnProperty.call(expand.vars, 'HOME') ? expand.vars.HOME : '/home/user')); continue; } if (expand && ch === '$' && !literal) { @@ -357,25 +373,25 @@ function parseArgs(input: string, expand?: { vars: Record; env: ? (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]) : ''; + add(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 add(ch); } else if (ch === '"' || ch === "'") { inQuote = true; quoted = true; quoteChar = ch; } else if (ch === ' ' || ch === '\t') { - if (current || quoted) { result.push(current); current = ''; quoted = false; } + if (current || quoted) { pushWord(); quoted = false; } } else { - current += ch; + add(ch, ch === '*' || ch === '?' || ch === '['); } } - if (current || quoted) result.push(current); + if (current || quoted) pushWord(); return result; } @@ -403,50 +419,114 @@ function cmdPwd(state: TerminalState, env: TerminalEnv = 'linux'): OutputLine[] return [{ text: absolutePath(state.cwd, env), type: 'output' }]; } -function cmdLs(state: TerminalState, args: string[]): OutputLine[] { - const flags = args.filter((a) => a.startsWith('-')); - const paths = args.filter((a) => !a.startsWith('-')); - const showAll = flags.some((f) => f.includes('a')); - const longFormat = flags.some((f) => f.includes('l')); +/** Long options of GNU ls the simulator knows, as their one-letter form. */ +const LS_LONG_OPTIONS: Record = { + '--all': 'a', '--almost-all': 'A', '--directory': 'd', '--reverse': 'r', '--classify': 'F', +}; - const targetPath = paths[0] ? resolvePath(state, paths[0]) : state.cwd; - const node = getNode(state.root, targetPath); +/** `ls` arguments as GNU ls reads them: bundled letters (`-la`), long names, and `--` before operands. */ +function lsArgs(args: string[]): { opts: Set; paths: string[] } { + const opts = new Set(); + const paths: string[] = []; + let operandsOnly = false; + for (const a of args) { + if (operandsOnly || a === '-' || !a.startsWith('-')) paths.push(a); + else if (a === '--') operandsOnly = true; + else if (a.startsWith('--')) { + if (Object.prototype.hasOwnProperty.call(LS_LONG_OPTIONS, a)) opts.add(LS_LONG_OPTIONS[a]); + } else for (const c of a.slice(1)) opts.add(c); + } + return { opts, paths }; +} - if (!node) return [{ text: `ls: cannot access '${paths[0]}': No such file or directory`, type: 'error' }]; - if (node.type === 'file') { - return longFormat - ? [{ text: formatLongEntry(paths[0] || '', node), type: 'output' }] - : [{ text: paths[0] || '', type: 'output' }]; +/** A name as `ls -F` marks it: `/` for a folder, `*` for an executable file. */ +function lsClassify(name: string, node: FSNode, opts: Set): string { + if (!opts.has('F')) return name; + if (node.type === 'directory') return `${name}/`; + return node.permissions[3] === 'x' ? `${name}*` : name; +} + +function cmdLs(state: TerminalState, args: string[]): OutputLine[] { + const { opts, paths } = lsArgs(args); + const longFormat = opts.has('l'); + const dirsAsFiles = opts.has('d'); + + if (paths.length <= 1 && !dirsAsFiles) { + const targetPath = paths[0] ? resolvePath(state, paths[0]) : state.cwd; + const node = getNode(state.root, targetPath); + if (!node) return [{ text: `ls: cannot access '${paths[0]}': No such file or directory`, type: 'error' }]; + if (node.type === 'file') return listFiles([[paths[0] || '', node]], opts); + return listDirectory(node, targetPath, opts); } + // Several operands (often from a wildcard: `ls documents/*`), as GNU ls prints them + // (captured on Ubuntu, 2 October 2026): errors first, then the files together, + // then each folder under a "name:" header, separated by blank lines. + const errors: OutputLine[] = []; + const files: [string, FSNode][] = []; + const dirs: [string, DirectoryNode, string[]][] = []; + for (const p of paths.length ? paths : ['.']) { + const target = resolvePath(state, p); + const node = getNode(state.root, target); + if (!node) errors.push({ text: `ls: cannot access '${p}': No such file or directory`, type: 'error' }); + else if (node.type === 'file' || dirsAsFiles) files.push([p, node]); + else dirs.push([p, node, target]); + } + const byName = (a: [string, ...T[]], b: [string, ...T[]]) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0); + files.sort(byName); + dirs.sort(byName); + if (opts.has('r')) { files.reverse(); dirs.reverse(); } + const out: OutputLine[] = [...errors]; + let printed = false; + if (files.length) { out.push(...listFiles(files, opts)); printed = true; } + for (const [name, node, target] of dirs) { + if (printed) out.push({ text: '', type: 'output' }); + out.push({ text: `${name}:`, type: 'output' }); + out.push(...listDirectory(node, target, opts).filter((l) => l.text !== '' || longFormat)); + printed = true; + } + return out; +} + +/** Files named on the command line: together on one line in a terminal, one per line otherwise. */ +function listFiles(files: [string, FSNode][], opts: Set): OutputLine[] { + if (opts.has('l')) return files.map(([name, n]) => ({ text: formatLongEntry(lsClassify(name, n, opts), n), type: 'output' as const })); + const names = files.map(([name, n]) => lsClassify(name, n, opts)); + if (!stdoutIsTerminal || opts.has('1')) return names.map((text) => ({ text, type: 'output' as const })); + return [{ text: names.join(' '), type: 'output' }]; +} + +/** The contents of one folder, as `ls` lists it. */ +function listDirectory(node: DirectoryNode, targetPath: string[], opts: Set): OutputLine[] { + // `-a` adds the hidden files and `.` and `..`; `-A` only the hidden files. + const showAll = opts.has('a'); + const showHidden = showAll || opts.has('A'); + const longFormat = opts.has('l'); + // Like `ls` in the C locale: one alphabetical list, hidden files first, no directories-first. const entries = Object.entries(node.children) .map(([name, child]): [string, FSNode] => [windowsName(targetPath, name, windowsPaths), child]) - .filter(([name]) => showAll || !name.startsWith('.')) + .filter(([name]) => showHidden || !name.startsWith('.')) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); if (showAll) { entries.unshift(['.', node], ['..', node]); } + if (opts.has('r')) entries.reverse(); + // `-F` marks directories with `/` and executables with `*`; plain `ls` marks nothing. + const shown = (name: string, n: FSNode) => (name === '.' || name === '..' ? name : lsClassify(name, n, opts)); if (!longFormat) { + const names = entries.map(([name, n]) => shown(name, n)); // Into a pipe or a file, a real `ls` writes one plain name per line // (that is what `ls | wc -l` counts); `-1` asks for it on screen too. - if (!stdoutIsTerminal) return entries.map(([name]) => ({ text: name, type: 'output' as const })); - // `-F` marks directories with `/` and executables with `*`; plain `ls` marks nothing. - const classify = flags.some((f) => f.includes('F')); - const names = entries.map(([name, n]) => { - if (!classify || name === '.' || name === '..') return name; - if (n.type === 'directory') return name + '/'; - return n.permissions[3] === 'x' ? name + '*' : name; - }); - if (flags.some((f) => f.includes('1'))) return names.map((text) => ({ text, type: 'output' as const })); + if (!stdoutIsTerminal || opts.has('1')) return names.map((text) => ({ text, type: 'output' as const })); return [{ text: names.join(' '), type: 'output' }]; } const lines: OutputLine[] = [{ text: `total ${entries.length}`, type: 'output' }]; for (const [name, n] of entries) { - lines.push({ text: formatLongEntry(name, n), type: 'output' }); + lines.push({ text: formatLongEntry(shown(name, n), n), type: 'output' }); } return lines; } @@ -552,6 +632,8 @@ function cmdChildItem(state: TerminalState, args: string[]): OutputLine[] { } function cmdCd(state: TerminalState, args: string[], env: TerminalEnv = 'linux'): { lines: OutputLine[]; newCwd?: string[] } { + // `cd d*` with two matches: bash refuses and stays where it is. + if (env !== 'windows' && args.length > 1) return { lines: [{ text: 'bash: cd: too many arguments', type: 'error' }] }; const target = args[0]; if (target === '-') { // `cd -` returns to $OLDPWD; bash prints where it lands. PowerShell 6.2+ (the @@ -586,6 +668,8 @@ function cmdMkdir(state: TerminalState, args: string[]): { lines: OutputLine[]; if (!paths.length) return { lines: [{ text: 'mkdir: missing operand', type: 'error' }] }; const newRoot = deepCloneRoot(state.root); + // Like GNU mkdir, a folder that cannot be made does not stop the others (`mkdir new documents`). + const errors: OutputLine[] = []; for (const p of paths) { const resolved = resolvePath(state, p); @@ -597,7 +681,7 @@ function cmdMkdir(state: TerminalState, args: string[]): { lines: OutputLine[]; } const next = cur.children[seg]; if (next.type !== 'directory') { - return { lines: [{ text: `mkdir: cannot create directory '${p}': Not a directory`, type: 'error' }] }; + return { lines: [{ text: `mkdir: cannot create directory ‘${p}’: Not a directory`, type: 'error' }] }; } cur = next; } @@ -616,15 +700,17 @@ function cmdMkdir(state: TerminalState, args: string[]): { lines: OutputLine[]; } const parent = getNode(newRoot, parentPath) as DirectoryNode; if (!parent || parent.type !== 'directory') { - return { lines: [{ text: `mkdir: cannot create directory '${p}': No such file or directory`, type: 'error' }] }; + errors.push({ text: `mkdir: cannot create directory ‘${p}’: No such file or directory`, type: 'error' }); + continue; } if (parent.children[name]) { - return { lines: [{ text: `mkdir: cannot create directory '${p}': File exists`, type: 'error' }] }; + errors.push({ text: `mkdir: cannot create directory ‘${p}’: File exists`, type: 'error' }); + continue; } parent.children[name] = makeDir({}); } } - return { lines: [], newRoot }; + return { lines: errors, newRoot }; } function cmdTouch(state: TerminalState, args: string[]): { lines: OutputLine[]; newRoot?: DirectoryNode } { @@ -679,10 +765,17 @@ function cmdCat(state: TerminalState, args: string[]): OutputLine[] { const MAX_ENV_VAR_LENGTH = 1024; /** Variables a command line expands: the session's, and PWD for the current folder. */ -function expansionVars(state: TerminalState, env: TerminalEnv): { vars: Record; env: TerminalEnv } { +function expansionVars(state: TerminalState, env: TerminalEnv): Expansion { const shown = varsForEnv(state.envVars, env); const pwd = env === 'windows' ? displayPathForEnv(state.cwd, env) : '/' + state.cwd.join('/'); - return { vars: { PWD: pwd, ...shown }, env }; + // bash expands wildcards against the files; PowerShell leaves them to each cmdlet. + const glob = env === 'windows' ? undefined : (word: string, meta: boolean[]) => expandGlob(word, meta, state.cwd, (parts) => { + const node = getNode(state.root, parts); + return node?.type === 'directory' + ? Object.entries(node.children).map(([name, child]) => ({ name, isDir: child.type === 'directory' })) + : null; + }); + return { vars: { PWD: pwd, ...shown }, env, glob }; } /** A variable value, cut at MAX_ENV_VAR_LENGTH so a huge variable cannot flood the terminal [H3]. */ @@ -710,9 +803,12 @@ function cmdEcho(args: string[]): OutputLine[] { function cmdRm(state: TerminalState, args: string[]): { lines: OutputLine[]; newRoot?: DirectoryNode } { const flags = args.filter((a) => a.startsWith('-')); const paths = args.filter((a) => !a.startsWith('-')); - const recursive = flags.some((f) => f.includes('r') || f.includes('R')); + const letters = flags.filter((f) => !f.startsWith('--')).join(''); + const recursive = /[rR]/.test(letters) || flags.includes('--recursive'); + // `-f` ignores missing files and says nothing (`rm -f *.tmp` when there is none). + const force = letters.includes('f') || flags.includes('--force'); - if (!paths.length) return { lines: [{ text: 'rm: missing operand', type: 'error' }] }; + if (!paths.length) return { lines: force ? [] : [{ text: 'rm: missing operand', type: 'error' }] }; // GNU rm refuses to wipe the whole system (`rm -rf /`) unless forced explicitly. if (recursive && paths.some((p) => resolvePath(state, p).length === 0) && !flags.includes('--no-preserve-root')) { return { lines: [ @@ -722,6 +818,8 @@ function cmdRm(state: TerminalState, args: string[]): { lines: OutputLine[]; new } const newRoot = deepCloneRoot(state.root); + // GNU rm reports each operand it cannot remove and still removes the others (`rm *`). + const errors: OutputLine[] = []; for (const p of paths) { const resolved = resolvePath(state, p); @@ -729,16 +827,18 @@ function cmdRm(state: TerminalState, args: string[]): { lines: OutputLine[]; new const name = resolved[resolved.length - 1]; const parent = getNode(newRoot, parentPath) as DirectoryNode; - if (!parent || !parent.children[name]) { - return { lines: [{ text: `rm: cannot remove '${p}': No such file or directory`, type: 'error' }] }; + if (!parent || parent.type !== 'directory' || !parent.children[name]) { + if (!force) errors.push({ text: `rm: cannot remove '${p}': No such file or directory`, type: 'error' }); + continue; } const target = parent.children[name]; if (target.type === 'directory' && !recursive) { - return { lines: [{ text: `rm: cannot remove '${p}': Is a directory (use -r)`, type: 'error' }] }; + errors.push({ text: `rm: cannot remove '${p}': Is a directory`, type: 'error' }); + continue; } delete parent.children[name]; } - return { lines: [], newRoot }; + return { lines: errors, newRoot }; } // ─── cp / mv ────────────────────────────────────────────────────────────────── @@ -976,22 +1076,49 @@ function cmdGrep(state: TerminalState, args: string[]): { lines: OutputLine[]; s return { lines: [...out, ...errors], status: errors.length ? 2 : selected ? 0 : 1 }; } +/** + * `head` and `tail` as GNU coreutils 9.4 prints them (captured on Ubuntu, 2 October + * 2026): `-n 3`, `-n3` or the old `-3`; `head -n -3` drops the last 3 lines and + * `tail -n +3` starts at line 3. With several files, each one under a + * `==> name <==` header, blank lines between them, and an error for a missing one. + */ function cmdHeadTail(state: TerminalState, args: string[], cmd: 'head' | 'tail'): OutputLine[] { - let n = 10; - let filePath = ''; - const parseN = (raw: string) => { const p = parseInt(raw, 10); if (!Number.isNaN(p)) n = p; }; + let count = '10'; + const files: string[] = []; + const parseN = (raw: string) => { if (/^[+-]?\d+$/.test(raw)) count = raw; }; for (let i = 0; i < args.length; i++) { if (args[i] === '-n' && args[i + 1]) { parseN(args[i + 1]); i++; } else if (args[i].startsWith('-n')) { parseN(args[i].slice(2)); } - else filePath = args[i]; - } + else if (/^-\d+$/.test(args[i])) { parseN(args[i].slice(1)); } + else files.push(args[i]); + } + const k = Math.abs(Number(count)); + const pick = (lines: string[]): string[] => { + if (cmd === 'head') return count.startsWith('-') ? lines.slice(0, Math.max(0, lines.length - k)) : lines.slice(0, k); + if (count.startsWith('+')) return lines.slice(Math.max(0, k - 1)); + return k === 0 ? [] : lines.slice(-k); + }; const pfx = `${cmd}:`; - if (!filePath) return [{ text: `${pfx} missing file operand`, type: 'error' }]; - const node = getNode(state.root, resolvePath(state, filePath)); - if (!node) return [{ text: `${pfx} cannot open '${filePath}': No such file or directory`, type: 'error' }]; - if (node.type === 'directory') return [{ text: `${pfx} ${filePath}: Is a directory`, type: 'error' }]; - const lines = node.content.split('\n'); - return (cmd === 'head' ? lines.slice(0, n) : lines.slice(-n)).map((line) => ({ text: line, type: 'output' as const })); + if (!files.length) return [{ text: `${pfx} missing file operand`, type: 'error' }]; + const out: OutputLine[] = []; + let headers = 0; + for (const file of files) { + const node = getNode(state.root, resolvePath(state, file)); + if (!node) { + out.push({ text: `${pfx} cannot open '${file}' for reading: No such file or directory`, type: 'error' }); + continue; + } + if (files.length > 1) { + if (headers++) out.push({ text: '', type: 'output' }); + out.push({ text: `==> ${file} <==`, type: 'output' }); + } + if (node.type === 'directory') { + out.push({ text: `${pfx} error reading '${file}': Is a directory`, type: 'error' }); + continue; + } + out.push(...pick(node.content.split('\n')).map((line) => ({ text: line, type: 'output' as const }))); + } + return out; } function cmdHead(state: TerminalState, args: string[]): OutputLine[] { @@ -1038,17 +1165,21 @@ function cmdWc(state: TerminalState, args: string[]): OutputLine[] { function cmdChmod(state: TerminalState, args: string[]): { lines: OutputLine[]; newRoot?: DirectoryNode } { if (args.length < 2) return { lines: [{ text: 'chmod: missing operand', type: 'error' }] }; - const mode = args[0]; - const filePath = args[1]; + const [mode, ...files] = args; + if (!applyChmodMode('-rw-r--r--', mode)) return { lines: [{ text: `chmod: invalid mode: '${mode}'`, type: 'error' }] }; const newRoot = deepCloneRoot(state.root); - const resolved = resolvePath(state, filePath); - const node = getNode(newRoot, resolved); - if (!node) return { lines: [{ text: `chmod: cannot access '${filePath}': No such file or directory`, type: 'error' }] }; - - const next = applyChmodMode(node.permissions, mode); - if (!next) return { lines: [{ text: `chmod: invalid mode: '${mode}'`, type: 'error' }] }; - node.permissions = next; - return { lines: [{ text: `Mode de '${filePath}' changé`, type: 'success' }], newRoot }; + // Every file gets the mode (`chmod +x documents/*`); a missing one is reported and skipped. + const lines: OutputLine[] = []; + for (const filePath of files) { + const node = getNode(newRoot, resolvePath(state, filePath)); + if (!node) { + lines.push({ text: `chmod: cannot access '${filePath}': No such file or directory`, type: 'error' }); + continue; + } + node.permissions = applyChmodMode(node.permissions, mode) ?? node.permissions; + lines.push({ text: `Mode de '${filePath}' changé`, type: 'success' }); + } + return { lines, newRoot }; } /** @@ -1347,6 +1478,20 @@ function openFailure(typed: string, reason: string, env: TerminalEnv, cmdlet = ' : { text: `bash: ${typed}: ${reason}`, type: 'error' }; } +/** + * The file a redirection names. bash expands the word like an argument (variables, + * `~`, wildcards) and needs exactly one result: `> *` or `> $UNSET` is an + * "ambiguous redirect" (bash 5.2, captured on Ubuntu, 2 October 2026). + * PowerShell takes the path as typed. + */ +function redirectTarget(state: TerminalState, raw: string, unquoted: string, env: TerminalEnv): { target: string } | { error: OutputLine } { + if (env === 'windows') return { target: unquoted }; + const words = parseArgs(raw, expansionVars(state, env)); + if (words.length !== 1) return { error: { text: `bash: ${raw}: ambiguous redirect`, type: 'error' } }; + if (words[0] === '') return { error: { text: 'bash: : No such file or directory', type: 'error' } }; + return { target: words[0] }; +} + /** Checks a redirection target the way the shell opens it: before the command runs. */ function checkWritable(state: TerminalState, typed: string, env: TerminalEnv, cmdlet?: string): { path: string[] } | { error: OutputLine } { const path = resolvePath(state, typed); @@ -1868,14 +2013,16 @@ function runPipeline(state: TerminalState, stages: Stage[], env: TerminalEnv): P fds[r.fd] = fds[r.to]; continue; } + const named = redirectTarget(base, r.raw, r.target, env); + if ('error' in named) { openError = named.error; break; } let sink: Sink; - if (isNullDevice(r.target, env)) { + if (isNullDevice(named.target, env)) { sink = { kind: 'null' }; } else { - const target = checkWritable(base, r.target, env); + const target = checkWritable(base, named.target, env); if ('error' in target) { openError = target.error; break; } const key = target.path.join('/'); - if (!files.has(key)) files.set(key, { path: target.path, typed: r.target, append: r.append, chunks: [] }); + if (!files.has(key)) files.set(key, { path: target.path, typed: named.target, append: r.append, chunks: [] }); sink = { kind: 'file', key }; } if (r.fd === 'both') { fds[1] = sink; fds[2] = sink; } else fds[r.fd] = sink; @@ -1883,9 +2030,13 @@ function runPipeline(state: TerminalState, stages: Stage[], env: TerminalEnv): P let stdin = piped; if (!openError && stage.stdinFile !== undefined) { - const node = getNode(base.root, resolvePath(base, stage.stdinFile)); - if (node?.type === 'file') stdin = node.content; - else openError = openFailure(stage.stdinFile, node ? 'Is a directory' : 'No such file or directory', env); + const named = redirectTarget(base, stage.stdinRaw ?? stage.stdinFile, stage.stdinFile, env); + if ('error' in named) openError = named.error; + else { + const node = getNode(base.root, resolvePath(base, named.target)); + if (node?.type === 'file') stdin = node.content; + else openError = openFailure(named.target, node ? 'Is a directory' : 'No such file or directory', env); + } } piped = ''; @@ -2639,8 +2790,11 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com case 'pwd': return { lines: cmdPwd(newState, env), newState }; - case 'ls': - return { lines: cmdLs(newState, args), newState }; + case 'ls': { + // GNU ls exits with 2 when an operand cannot be read. + const lines = cmdLs(newState, args); + return { lines, newState, status: lines.some((l) => l.type === 'error') ? 2 : 0 }; + } case 'cd': { const { lines, newCwd } = cmdCd(newState, args, env); diff --git a/src/test/shellLayer.test.ts b/src/test/shellLayer.test.ts index 6fd9c1a..af3ed54 100644 --- a/src/test/shellLayer.test.ts +++ b/src/test/shellLayer.test.ts @@ -38,13 +38,13 @@ describe('shell syntax — parseCommandLine', () => { expect(r.list).toHaveLength(1); expect(r.list[0].stages).toHaveLength(1); expect(r.list[0].stages[0].text).toBe('grep "a|b" f'); - expect(r.list[0].stages[0].redirects).toEqual([{ kind: 'file', fd: 1, append: false, target: 'x > y.txt' }]); + expect(r.list[0].stages[0].redirects).toEqual([{ kind: 'file', fd: 1, append: false, target: 'x > y.txt', raw: '"x > y.txt"' }]); }); it('keeps redirections in the order they are written', () => { const r = parseCommandLine('cmd > out.txt 2>&1', 'linux'); expect(r.ok && r.list[0].stages[0].redirects).toEqual([ - { kind: 'file', fd: 1, append: false, target: 'out.txt' }, + { kind: 'file', fd: 1, append: false, target: 'out.txt', raw: 'out.txt' }, { kind: 'dup', fd: 2, to: 1 }, ]); }); diff --git a/src/test/terminalEngine.test.ts b/src/test/terminalEngine.test.ts index ab747fd..e94141c 100644 --- a/src/test/terminalEngine.test.ts +++ b/src/test/terminalEngine.test.ts @@ -3890,6 +3890,180 @@ describe('find, xargs, rmdir (Phase 5d, lot 1)', () => { expect(sh('find . -mtime 1 -bogus')).toEqual(["find: unknown predicate `-bogus'"]); }); + it('wildcards: bash 5.2 expands *, ? and [...] against the files (Linux and macOS)', () => { + expect(sh('echo *')).toEqual(['documents downloads projets']); + expect(sh('echo .*')).toEqual(['.bashrc .profile .zshrc']); + expect(sh('echo documents/*')).toEqual(['documents/notes.txt documents/rapport.md']); + expect(sh('echo documents/*.md')).toEqual(['documents/rapport.md']); + expect(sh('echo *.txt')).toEqual(['*.txt']); + expect(sh('ls *.txt')).toEqual(["ls: cannot access '*.txt': No such file or directory"]); + expect(sh('echo [dp]*')).toEqual(['documents downloads projets']); + expect(sh('echo ?ownloads')).toEqual(['downloads']); + expect(sh('echo */')).toEqual(['documents/ downloads/ projets/']); + expect(sh('echo projets/*')).toEqual(['projets/README.md projets/script.sh']); + expect(sh('echo projets/.*')).toEqual(['projets/.env']); + expect(sh("echo '*'")).toEqual(['*']); + expect(sh('echo "documents/*"')).toEqual(['documents/*']); + expect(sh('echo \\*')).toEqual(['*']); + expect(sh('echo doc*/no*')).toEqual(['documents/notes.txt']); + expect(sh('echo */*.md')).toEqual(['documents/rapport.md projets/README.md']); + expect(sh('echo ~/doc*')).toEqual(['/home/user/documents']); + expect(sh('echo [!d]*')).toEqual(['projets']); + expect(sh('echo [^d]*')).toEqual(['projets']); + expect(sh('echo [a-e]*')).toEqual(['documents downloads']); + expect(sh('echo x*y')).toEqual(['x*y']); + expect(sh('echo [z-a]*')).toEqual(['[z-a]*']); + expect(sh('echo documents/[')).toEqual(['documents/[']); + // Sorted in the locale's order, like sort: case and punctuation only break ties. + expect(sh('touch B.txt a.txt c.txt; echo *.txt')).toEqual(['a.txt B.txt c.txt']); + expect(sh('touch Zed alpha .cache _under 10 9; echo *')).toEqual(['10 9 alpha documents downloads projets _under Zed']); + expect(sh("mkdir 'mon dossier'; touch 'mon dossier/f.txt'; echo mon*/*")).toEqual(['mon dossier/f.txt']); + }); + + it('wildcards reach every command: rm, wc, grep, cp, cat', () => { + expect(sh('rm documents/*.txt; ls documents')).toEqual(['rapport.md']); + expect(sh('wc -l documents/*')).toEqual([' 6 documents/notes.txt', ' 10 documents/rapport.md', ' 16 total']); + expect(sh('grep -l Rapport documents/*')).toEqual(['documents/rapport.md']); + expect(sh('cp documents/* downloads/; ls downloads')).toEqual(['notes.txt rapport.md']); + expect(sh('cat documents/*.md | head -2')).toEqual(['# Rapport Mensuel', '']); + // PowerShell leaves wildcards to each cmdlet: the word reaches it as typed. + expect(sh('echo documents\\*', 'windows')).toEqual(['documents\\*']); + }); + + it('ls with several operands: errors, then files, then each folder under a header (GNU ls)', () => { + expect(sh('ls documents/*')).toEqual(['documents/notes.txt documents/rapport.md']); + expect(sh('ls documents/* | cat')).toEqual(['documents/notes.txt', 'documents/rapport.md']); + expect(sh('ls documents projets')).toEqual(['documents:', 'notes.txt rapport.md', '', 'projets:', 'README.md script.sh']); + expect(sh('ls documents projets | cat')).toEqual(['documents:', 'notes.txt', 'rapport.md', '', 'projets:', 'README.md', 'script.sh']); + expect(sh('ls documents .bashrc projets')).toEqual(['.bashrc', '', 'documents:', 'notes.txt rapport.md', '', 'projets:', 'README.md script.sh']); + expect(sh('ls nope documents .bashrc')).toEqual([ + "ls: cannot access 'nope': No such file or directory", '.bashrc', '', 'documents:', 'notes.txt rapport.md', + ]); + expect(sh('ls -a documents downloads')).toEqual(['documents:', '. .. notes.txt rapport.md', '', 'downloads:', '. ..']); + expect(sh('ls -d documents projets')).toEqual(['documents projets']); + expect(sh('ls -1 documents/notes.txt projets')).toEqual(['documents/notes.txt', '', 'projets:', 'README.md', 'script.sh']); + expect(sh('ls downloads documents')).toEqual(['documents:', 'notes.txt rapport.md', '', 'downloads:']); + expect(sh('ls -F projets documents/notes.txt')).toEqual(['documents/notes.txt', '', 'projets:', 'README.md script.sh*']); + }); + + // Captured with bash 5.2 / coreutils 9.4 on Ubuntu (2 October 2026); bash run from a + // script adds "line 1:" after "bash:", which an interactive shell does not print. + it('audit fixes (#419): a wildcard in a redirection is expanded and must name one file', () => { + expect(sh('echo hi > documents/*.md; cat documents/rapport.md')).toEqual(['hi']); + expect(sh('echo hi >> documents/n*; tail -n 2 documents/notes.txt')).toEqual(['Fin du fichier', 'hi']); + expect(sh('echo hi > d*; ls | cat')).toEqual(['bash: d*: ambiguous redirect', 'documents', 'downloads', 'projets']); + expect(sh('echo hi > *')).toEqual(['bash: *: ambiguous redirect']); + expect(sh('echo hi > [dp]*')).toEqual(['bash: [dp]*: ambiguous redirect']); + expect(sh('echo hi > new*.txt; ls | cat')).toEqual(['documents', 'downloads', 'new*.txt', 'projets']); + expect(sh('echo hi > $NOPE_UNSET')).toEqual(['bash: $NOPE_UNSET: ambiguous redirect']); + expect(sh('cat < documents/n* | head -1')).toEqual(['Mes notes importantes']); + expect(sh('cat < d*')).toEqual(['bash: d*: ambiguous redirect']); + expect(sh('cat < *.md')).toEqual(['bash: *.md: No such file or directory']); + expect(sh("echo hi > 'd*'; cat 'd*'")).toEqual(['hi']); + }); + + it('audit fixes (#419): cd, chmod, rm and mkdir with several operands', () => { + const s = createInitialState(); + const cd = processCommand(s, 'cd d*', 'linux'); + expect(cd.lines.map((l) => l.text)).toEqual(['bash: cd: too many arguments']); + expect(cd.newState.cwd).toEqual(s.cwd); + expect(sh('cd documents projets')).toEqual(['bash: cd: too many arguments']); + expect(sh('cd p*; pwd')).toEqual(['/home/user/projets']); + // chmod gives every file the mode (the confirmation line is the simulator's, see plan.md). + const modes = sh('chmod +x documents/*; ls -l documents').filter((l) => l.startsWith('-')).map((l) => l.slice(0, 10)); + expect(modes).toEqual(['-rwxr-xr-x', '-rwxr-xr-x']); + expect(sh('chmod 644 nope1 nope2')).toEqual([ + "chmod: cannot access 'nope1': No such file or directory", "chmod: cannot access 'nope2': No such file or directory", + ]); + expect(sh('chmod +x nope documents/notes.txt; ls -l documents').filter((l) => !l.startsWith('Mode')).map((l) => l.slice(0, 10))) + .toEqual(["chmod: can", 'total 2', '-rwxr-xr-x', '-rw-r--r--']); + expect(sh('rm *')).toEqual([ + "rm: cannot remove 'documents': Is a directory", "rm: cannot remove 'downloads': Is a directory", "rm: cannot remove 'projets': Is a directory", + ]); + expect(sh('rm nope documents/notes.txt; ls documents')).toEqual(["rm: cannot remove 'nope': No such file or directory", 'rapport.md']); + expect(sh('rm -f nope documents/notes.txt; ls documents')).toEqual(['rapport.md']); + expect(sh('rm -f')).toEqual([]); + expect(sh('rm --force nope')).toEqual([]); + expect(sh('rm -r d*; ls | cat')).toEqual(['projets']); + expect(sh('mkdir new1 documents new2; ls | cat')).toEqual([ + 'mkdir: cannot create directory ‘documents’: File exists', 'documents', 'downloads', 'new1', 'new2', 'projets', + ]); + expect(sh('mkdir d*')).toEqual([ + 'mkdir: cannot create directory ‘documents’: File exists', 'mkdir: cannot create directory ‘downloads’: File exists', + ]); + }); + + it('audit fixes (#419): head and tail with several files, and the -N form', () => { + expect(sh('head -n 1 documents/*')).toEqual(['==> documents/notes.txt <==', 'Mes notes importantes', '', '==> documents/rapport.md <==', '# Rapport Mensuel']); + expect(sh('tail -n 1 documents/*')).toEqual(['==> documents/notes.txt <==', 'Fin du fichier', '', '==> documents/rapport.md <==', "Excellent travail de l'équipe."]); + expect(sh('head -n2 documents/notes.txt projets/README.md')).toEqual([ + '==> documents/notes.txt <==', 'Mes notes importantes', 'Tâches du jour:', '', '==> projets/README.md <==', '# Mes Projets', '', + ]); + expect(sh('head *')).toEqual([ + '==> documents <==', "head: error reading 'documents': Is a directory", '', + '==> downloads <==', "head: error reading 'downloads': Is a directory", '', + '==> projets <==', "head: error reading 'projets': Is a directory", + ]); + expect(sh('head -n 1 nope documents/notes.txt')).toEqual([ + "head: cannot open 'nope' for reading: No such file or directory", '==> documents/notes.txt <==', 'Mes notes importantes', + ]); + expect(sh('tail -n 1 documents/notes.txt nope')).toEqual([ + '==> documents/notes.txt <==', 'Fin du fichier', "tail: cannot open 'nope' for reading: No such file or directory", + ]); + expect(sh('head nope')).toEqual(["head: cannot open 'nope' for reading: No such file or directory"]); + expect(sh('tail documents')).toEqual(["tail: error reading 'documents': Is a directory"]); + expect(sh('head -2 documents/notes.txt')).toEqual(['Mes notes importantes', 'Tâches du jour:']); + expect(sh('tail -2 documents/notes.txt')).toEqual(['3. Maîtriser les permissions', 'Fin du fichier']); + // Review follow-ups: 0, +N and -N counts. + expect(sh('tail -n 0 documents/notes.txt')).toEqual([]); + expect(sh('tail -0 documents/notes.txt')).toEqual([]); + expect(sh('head -n 0 documents/notes.txt')).toEqual([]); + expect(sh('tail -n +5 documents/notes.txt')).toEqual(['3. Maîtriser les permissions', 'Fin du fichier']); + expect(sh('tail -n+5 documents/notes.txt')).toEqual(['3. Maîtriser les permissions', 'Fin du fichier']); + expect(sh('tail -n -2 documents/notes.txt')).toEqual(['3. Maîtriser les permissions', 'Fin du fichier']); + expect(sh('head -n -4 documents/notes.txt')).toEqual(['Mes notes importantes', 'Tâches du jour:']); + expect(sh('head -n +2 documents/notes.txt')).toEqual(['Mes notes importantes', 'Tâches du jour:']); + }); + + it('audit fixes (#419): ls options -A, -r, -F with -l, long names, -- and exit status 2', () => { + expect(sh('ls -A projets | cat')).toEqual(['.env', 'README.md', 'script.sh']); + expect(sh('ls -r documents | cat')).toEqual(['rapport.md', 'notes.txt']); + expect(sh('ls -ra projets | cat')).toEqual(['script.sh', 'README.md', '.env', '..', '.']); + expect(sh('ls -1r projets')).toEqual(['script.sh', 'README.md']); + expect(sh('ls -rd documents projets | cat')).toEqual(['projets', 'documents']); + expect(sh('ls -r documents projets | cat')).toEqual(['projets:', 'script.sh', 'README.md', '', 'documents:', 'rapport.md', 'notes.txt']); + expect(sh('ls -F projets | cat')).toEqual(['README.md', 'script.sh*']); + expect(sh('ls -lF projets').map((l) => l.split(' ').pop())).toEqual(['2', 'README.md', 'script.sh*']); + expect(sh('ls --all documents projets | cat')).toEqual([ + 'documents:', '.', '..', 'notes.txt', 'rapport.md', '', 'projets:', '.', '..', '.env', 'README.md', 'script.sh', + ]); + expect(sh('ls --directory documents projets | cat')).toEqual(['documents', 'projets']); + expect(sh('ls -- -x')).toEqual(["ls: cannot access '-x': No such file or directory"]); + expect(sh('ls -- documents projets | cat')).toEqual(['documents:', 'notes.txt', 'rapport.md', '', 'projets:', 'README.md', 'script.sh']); + expect(processCommand(createInitialState(), 'ls nope', 'linux').status).toBe(2); + expect(processCommand(createInitialState(), 'ls nope documents', 'linux').status).toBe(2); + expect(processCommand(createInitialState(), 'ls documents', 'linux').status).toBe(0); + }); + + it('audit fixes (#419): bracket classes and a leading ] in a wildcard', () => { + expect(sh('echo [[:alpha:]]*')).toEqual(['documents downloads projets']); + expect(sh('echo [[:lower:]]*')).toEqual(['documents downloads projets']); + expect(sh('echo [[:upper:]]*')).toEqual(['[[:upper:]]*']); + expect(sh('echo [[:digit:]]*')).toEqual(['[[:digit:]]*']); + expect(sh('echo [[:foo:]]*')).toEqual(['[[:foo:]]*']); + expect(sh('echo [[:alpha:]')).toEqual(['[[:alpha:]']); + expect(sh('echo []d]*')).toEqual(['documents downloads']); + expect(sh('echo [!]d]*')).toEqual(['projets']); + expect(sh('echo []')).toEqual(['[]']); + expect(sh('echo [!]')).toEqual(['[!]']); + expect(sh('touch ab Ab aB AB b B; echo [[:lower:]]*')).toEqual(['ab aB b documents downloads projets']); + expect(sh('touch 1 a; echo [[:digit:]-a]*')).toEqual(['1 a']); + expect(sh('touch x; echo [[:graph:]]')).toEqual(['x']); + expect(sh('touch x; echo [[:print:]]')).toEqual(['x']); + expect(sh('echo [[:cntrl:]]*')).toEqual(['[[:cntrl:]]*']); + expect(sh('echo hi > ""')).toEqual(['bash: : No such file or directory']); + }); + it('audit fixes: rmdir -p, -v, --ignore-fail-on-non-empty, ".", options', () => { expect(sh('mkdir -p a/b/c; rmdir -p a/b/c; ls -d a')).toEqual(["ls: cannot access 'a': No such file or directory"]); expect(sh('mkdir -p a/b/c; touch a/x; rmdir -p a/b/c; ls a')).toEqual(["rmdir: failed to remove directory 'a': Directory not empty", 'x']);