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

Filter by extension

Filter by extension

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

---

## 🪟 1er octobre 2026 — Sous Windows, `ls` répond comme PowerShell

*Environnement Windows · `Get-ChildItem` · sorties vérifiées sur PowerShell 7.6*

- **`ls`, `dir`, `gci` et `Get-ChildItem` affichent le vrai tableau de PowerShell** : la ligne `Directory: C:\…`, puis les colonnes Mode, LastWriteTime, Length et Name, les dossiers d'abord. Avant, l'élève Windows voyait une liste de noms à la façon de Linux.
- Les fichiers qui commencent par un point (`.bashrc`, `.env`) sont visibles : sous Windows, c'est un attribut qui cache un fichier, pas son nom. Seul `.git` est caché, et `Get-ChildItem -Force` le montre.
- `-Name`, `-Force`, `-Hidden`, `-Recurse`, `-Depth`, `-Directory`, `-File` et `-Filter` fonctionnent. Les noms de paramètres abrégés sont compris comme dans PowerShell, et `ls -la` ou `ls -a` sont refusés avec le vrai message.
- **Les erreurs de `New-Item`, `mkdir`, `Move-Item`, `Copy-Item`, `Remove-Item` et `cd` sont celles de PowerShell 7.6**, avec le chemin Windows complet. `New-Item` refuse un fichier qui existe déjà (avec `-Force`, il le remplace par un fichier vide), et `Move-Item` n'écrase pas un fichier sans `-Force`.
- **Sous Windows, `rm`, `cp` et `mv` sont `Remove-Item`, `Copy-Item` et `Move-Item`**, avec leurs paramètres : `rm -r` fonctionne, `rm -rf` est refusé comme dans un vrai PowerShell, et plusieurs fichiers s'écrivent avec des virgules (`rm a.txt, b.txt`). Un dossier vide se supprime sans `-Recurse`, et `Copy-Item` sans `-Recurse` copie le dossier, vide.
- `Get-ChildItem > liste.txt`, `| Tee-Object` et `| Out-File` écrivent le tableau, comme à l'écran. `Measure-Object` affiche toutes ses lignes.
- La page Référence suit : les colonnes du tableau, `Get-ChildItem -Name`, et les pièges `ls -la` et `rm -rf` dans PowerShell.
- Les leçons `ls` et `ls -la` ont leur version PowerShell. Leçons qui montraient du bash à un élève Windows : **34 → 32**.

---

## 📱 1er octobre 2026 — Le haut de l'app installée sur iPhone, deuxième essai

*App installée sur l'écran d'accueil · iOS 26 · retour sur un iPhone 14*
Expand Down
14 changes: 14 additions & 0 deletions STORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -1255,6 +1255,20 @@ C'est la version appliquée. Dans l'app installée sur iPhone, le haut de l'app

---

### Un `ls` qui parle PowerShell (1er octobre 2026)

La leçon `ls -la` enseignait aux élèves Windows ce qu'on enseigne sous Linux : un fichier dont le nom commence par un point est caché, `-a` le montre. Dans un vrai PowerShell, c'est faux deux fois. `.bashrc` y est visible comme n'importe quel fichier, parce que Windows cache un fichier par un attribut, pas par son nom. Et `ls -la` n'existe pas : PowerShell cherche un paramètre dont le nom commence par « la », n'en trouve aucun, et le dit. `ls -a` est plus curieux encore : six paramètres commencent par un « a » une fois les alias comptés, et PowerShell refuse de choisir.

Le simulateur affiche maintenant le tableau que l'élève verra chez lui, colonne par colonne. Chaque sortie vient d'un PowerShell 7.6 lancé sur cette machine, et un détail l'a rappelé : la première capture titrait « Répertoire : », parce que Windows est réglé en français ici. L'élève qui a un Windows en anglais lira « Directory: ». Les leçons montrent la version anglaise, celle de la documentation.

Les erreurs ont suivi le même chemin. `New-Item` sur un fichier qui existe le dit, là où `touch` se contente de changer sa date. `Move-Item` refuse d'écraser un fichier sans `-Force`. Les messages reprennent ceux de PowerShell, avec le chemin Windows complet.

L'audit de fidélité a ensuite trouvé ce que j'avais laissé passer : sous Windows, `rm`, `cp` et `mv` répondaient encore comme sous Linux. Dans PowerShell, ce sont trois raccourcis vers des cmdlets qui ont leurs propres paramètres. `rm -r` marche parce que `-r` est le début de `-Recurse`, mais `rm -rf` est refusé : aucun paramètre ne s'appelle « rf ». Un élève qui recopie une commande trouvée pour Linux doit voir cette erreur ici plutôt que chez lui.

Une capture ratée m'a aussi appris quelque chose : lancé depuis le dossier du projet, mon premier script a laissé quatre fichiers vides à sa racine. Ils n'ont pas atteint le dépôt, mais depuis, chaque capture tourne dans un dossier temporaire neuf.

---

## Épilogue ouvert

Il y a des questions auxquelles on n'a pas encore de réponse.
Expand Down
6 changes: 5 additions & 1 deletion src/app/data/commandCatalogue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ const baseCatalogue: BaseCategory[] = [
compatibility: ['linux', 'macos', 'windows'],
syntax: 'ls [options] [chemin]',
summary: "Lister le contenu d'un dossier",
commonErrors: ["Confondre ls et dir selon l'environnement"],
commonErrors: [
"Confondre ls et dir selon l'environnement",
"Taper ls -la dans PowerShell : ce paramètre n'existe pas, Get-ChildItem -Force affiche les éléments cachés",
],
},
{
id: 'cd',
Expand Down Expand Up @@ -199,6 +202,7 @@ const baseCatalogue: BaseCategory[] = [
commonErrors: [
'Confondre suppression fichier et dossier',
'Utiliser rm -rf sans comprendre',
"Taper rm -rf dans PowerShell : rm y est Remove-Item, qui ne connaît pas -rf. Écrire Remove-Item dossier -Recurse",
],
},
],
Expand Down
5 changes: 3 additions & 2 deletions src/app/data/commandExamples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ export const COMMAND_EXAMPLES: Record<string, CommandExample[]> = {
{ 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: 'Get-ChildItem', explanation: 'Liste le contenu du dossier courant. ls et dir sont des raccourcis de cette commande.', environments: WIN },
{ command: 'Get-ChildItem -Force', explanation: 'Ajoute les fichiers cachés. ls -la, lui, ne fonctionne pas dans PowerShell.', environments: WIN },
{ 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 },
{ command: 'Get-ChildItem documents', explanation: 'Liste le contenu d\'un autre dossier sans t\'y déplacer.', environments: WIN },
],
cd: [
Expand Down
185 changes: 185 additions & 0 deletions src/app/data/commands/psParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import type { OutputLine } from './types';

/** A cmdlet parameter; `type` (the .NET type PowerShell names in its errors) marks one that takes a value. */
export type PsParam = { name: string; aliases?: string[]; type?: string };

const STRING_ARRAY = 'System.String[]';

/**
* The parameters every cmdlet has, in the order PowerShell 7.6 lists them
* (checked on 1 October 2026 with `(Get-Command <cmdlet>).Parameters`).
*/
const PS_COMMON_PARAMS: PsParam[] = [
{ name: 'Verbose', aliases: ['vb'] }, { name: 'Debug', aliases: ['db'] },
...['ErrorAction:ea', 'WarningAction:wa', 'InformationAction:infa', 'ProgressAction:proga']
.map((s) => ({ name: s.split(':')[0], aliases: [s.split(':')[1]], type: 'System.Management.Automation.ActionPreference' })),
...['ErrorVariable:ev', 'WarningVariable:wv', 'InformationVariable:iv', 'OutVariable:ov']
.map((s) => ({ name: s.split(':')[0], aliases: [s.split(':')[1]], type: 'System.String' })),
{ name: 'OutBuffer', aliases: ['ob'], type: 'System.Int32' }, { name: 'PipelineVariable', aliases: ['pv'], type: 'System.String' },
];
/** Added to the common ones by cmdlets that change something. */
const SHOULD_PROCESS_PARAMS: PsParam[] = [{ name: 'WhatIf', aliases: ['wi'] }, { name: 'Confirm', aliases: ['cf'] }];

/** A cmdlet's parameters in the three tiers PowerShell resolves a shortened name through. */
export interface PsCmdlet {
name: string;
/** Its own parameters, in declaration order. */
own: PsParam[];
/** -WhatIf and -Confirm. */
shouldProcess?: boolean;
/** Those the file system adds (dynamic parameters), tried last. */
fileSystem?: PsParam[];
/** The parameters bare words bind to, in order (`Copy-Item a b` is -Path a -Destination b). */
positions: string[];
}

const PATH_PARAMS: PsParam[] = [
{ name: 'Path', type: STRING_ARRAY }, { name: 'LiteralPath', aliases: ['PSPath', 'LP'], type: STRING_ARRAY },
];
const FILTER_PARAMS: PsParam[] = [
{ name: 'Filter', type: 'System.String' }, { name: 'Include', type: STRING_ARRAY }, { name: 'Exclude', type: STRING_ARRAY },
];
const CREDENTIAL: PsParam = { name: 'Credential', type: 'System.Management.Automation.PSCredential' };

export const GET_CHILD_ITEM: PsCmdlet = {
name: 'Get-ChildItem',
own: [
...PATH_PARAMS, ...FILTER_PARAMS,
{ name: 'Recurse', aliases: ['s', 'r'] }, { name: 'Depth', type: 'System.UInt32' }, { name: 'Force' }, { name: 'Name' },
],
fileSystem: [
{ name: 'Attributes', type: 'System.Management.Automation.FlagsExpression`1[System.IO.FileAttributes]' }, { name: 'FollowSymlink' },
{ name: 'Directory', aliases: ['ad'] }, { name: 'File', aliases: ['af'] }, { name: 'Hidden', aliases: ['ah', 'h'] },
{ name: 'ReadOnly', aliases: ['ar'] }, { name: 'System', aliases: ['as'] },
],
positions: ['Path', 'Filter'],
};

export const REMOVE_ITEM: PsCmdlet = {
name: 'Remove-Item',
own: [...PATH_PARAMS, ...FILTER_PARAMS, { name: 'Recurse' }, { name: 'Force' }, CREDENTIAL],
shouldProcess: true,
fileSystem: [{ name: 'Stream', type: STRING_ARRAY }],
positions: ['Path'],
};

export const COPY_ITEM: PsCmdlet = {
name: 'Copy-Item',
own: [
...PATH_PARAMS, { name: 'Destination', type: 'System.String' }, { name: 'Container' }, { name: 'Force' },
...FILTER_PARAMS, { name: 'Recurse' }, { name: 'PassThru' }, CREDENTIAL,
],
shouldProcess: true,
fileSystem: [
{ name: 'FromSession', type: 'System.Management.Automation.Runspaces.PSSession' },
{ name: 'ToSession', type: 'System.Management.Automation.Runspaces.PSSession' },
],
positions: ['Path', 'Destination'],
};

export const MOVE_ITEM: PsCmdlet = {
name: 'Move-Item',
own: [...PATH_PARAMS, { name: 'Destination', type: 'System.String' }, { name: 'Force' }, ...FILTER_PARAMS, { name: 'PassThru' }, CREDENTIAL],
shouldProcess: true,
positions: ['Path', 'Destination'],
};

/**
* The parameter a typed `-name` binds to, or the error PowerShell 7.6 gives:
* an exact name or alias first, then a prefix among the cmdlet's own
* parameters (`-d` is Get-ChildItem's -Depth, `-f` is ambiguous between
* -Filter and -Force), then the common ones (`-ea`), then the file system's
* (`-a` is ambiguous between six of Get-ChildItem's).
*/
export function resolvePsParam(cmdlet: PsCmdlet, typed: string): PsParam | string {
const t = typed.toLowerCase();
const common = [...PS_COMMON_PARAMS, ...(cmdlet.shouldProcess ? SHOULD_PROCESS_PARAMS : [])];
const fileSystem = cmdlet.fileSystem ?? [];
const exact = [...cmdlet.own, ...common, ...fileSystem].find((p) => [p.name, ...(p.aliases ?? [])].some((n) => n.toLowerCase() === t));
if (exact) return exact;
// Name matches are listed before alias matches (`-p`: Path, ProgressAction, PipelineVariable, LiteralPath).
const matching = (tier: PsParam[]) => [
...tier.filter((p) => p.name.toLowerCase().startsWith(t)),
...tier.filter((p) => !p.name.toLowerCase().startsWith(t) && (p.aliases ?? []).some((a) => a.toLowerCase().startsWith(t))),
];
const ambiguous = (list: PsParam[]) => `Parameter cannot be processed because the parameter name '${typed}' is ambiguous. Possible matches include: ${list.map((p) => `-${p.name}`).join(' ')}.`;
const own = matching(cmdlet.own);
if (own.length === 1) return own[0];
if (own.length > 1) return ambiguous(matching([...cmdlet.own, ...common]));
const shared = matching(common);
if (shared.length === 1) return shared[0];
if (shared.length > 1) return ambiguous(shared);
const dynamic = matching(fileSystem);
if (dynamic.length === 1) return dynamic[0];
if (dynamic.length > 1) return ambiguous(dynamic);
return `A parameter cannot be found that matches parameter name '${typed}'.`;
}

/**
* The note shown for a parameter this simulator does not act on, rather than
* quietly ignoring it; null when none of `names` was given.
*/
export function psNotSimulated(cmdlet: PsCmdlet, parsed: PsArgs, names: string[]): OutputLine[] | null {
const given = names.find((n) => parsed.switches.has(n) || parsed.values[n] !== undefined);
return given ? [{ text: `Le paramètre -${given} de ${cmdlet.name} n'est pas simulé dans ce terminal d'entraînement.`, type: 'info' }] : null;
}

export interface PsArgs {
/** Switches given, by their full name. */
switches: Set<string>;
/** Values given by name or by position, by the parameter's full name; a list stays `a.txt,b.txt` (see psList). */
values: Record<string, string>;
}

/** The items of a list value: `Remove-Item a.txt, b.txt` removes both. */
export const psList = (value: string | undefined): string[] =>
value === undefined ? [] : value.split(',').map((v) => v.trim()).filter(Boolean);

/**
* Binds a cmdlet's arguments as PowerShell 7.6 does (checked on 1 October
* 2026): `-Path docs` and `-Path:docs` alike, bare words to the cmdlet's
* positions, `a.txt, b.txt` as one list, everything after `--` as bare
* words. Otherwise, the error line PowerShell prints (`Remove-Item: A
* parameter cannot be found that matches parameter name 'rf'.`, or `A
* positional parameter cannot be found that accepts argument 'b.txt'.` for
* `rm a.txt b.txt`).
*/
export function parsePsArgs(cmdlet: PsCmdlet, typedArgs: string[]): PsArgs | OutputLine {
const fail = (text: string): OutputLine => ({ text: `${cmdlet.name}: ${text}`, type: 'error' });
// `a.txt, b.txt`, `a.txt ,b.txt` and `a.txt , b.txt` are one argument.
const args: string[] = [];
for (const arg of typedArgs) {
const last = args.length - 1;
if (last >= 0 && (args[last].endsWith(',') || arg.startsWith(','))) args[last] += arg;
else args.push(arg);
}
const parsed: PsArgs = { switches: new Set(), values: {} };
const bare: string[] = [];
let endOfParameters = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--' && !endOfParameters) { endOfParameters = true; continue; }
if (endOfParameters || !arg.startsWith('-') || arg === '-') { bare.push(arg); continue; }
const colon = arg.indexOf(':');
const typed = colon > 0 ? arg.slice(1, colon) : arg.slice(1);
const inline = colon > 0 ? arg.slice(colon + 1) : undefined;
const param = resolvePsParam(cmdlet, typed);
if (typeof param === 'string') return fail(param);
if (param.type) {
const value = inline || args[i + 1];
if (value === undefined) return fail(`Missing an argument for parameter '${param.name}'. Specify a parameter of type '${param.type}' and try again.`);
parsed.values[param.name] = value;
if (!inline) i++;
} else if (!/^\$false$/i.test(inline ?? '')) {
// `-Force:$false` turns the switch off.
parsed.switches.add(param.name);
}
}
// A position is free unless its parameter was named (-LiteralPath takes the place of -Path).
const free = cmdlet.positions.filter((p) => parsed.values[p] === undefined && !(p === 'Path' && parsed.values.LiteralPath !== undefined));
for (const [i, word] of bare.entries()) {
if (i >= free.length) return fail(`A positional parameter cannot be found that accepts argument '${word}'.`);
parsed.values[free[i]] = word;
}
return parsed;
}
Loading
Loading