diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d3848..b14839b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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* diff --git a/STORY.md b/STORY.md index db42550..84ab34e 100644 --- a/STORY.md +++ b/STORY.md @@ -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. diff --git a/src/app/data/commandCatalogue.ts b/src/app/data/commandCatalogue.ts index 1059b3f..51e14a0 100644 --- a/src/app/data/commandCatalogue.ts +++ b/src/app/data/commandCatalogue.ts @@ -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', @@ -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", ], }, ], diff --git a/src/app/data/commandExamples.ts b/src/app/data/commandExamples.ts index c1259ca..c865c7f 100644 --- a/src/app/data/commandExamples.ts +++ b/src/app/data/commandExamples.ts @@ -28,8 +28,9 @@ 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: '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: [ diff --git a/src/app/data/commands/psParams.ts b/src/app/data/commands/psParams.ts new file mode 100644 index 0000000..6208e3e --- /dev/null +++ b/src/app/data/commands/psParams.ts @@ -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 ).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; + /** Values given by name or by position, by the parameter's full name; a list stays `a.txt,b.txt` (see psList). */ + values: Record; +} + +/** 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; +} diff --git a/src/app/data/commands/windows.ts b/src/app/data/commands/windows.ts index 8d62a0c..56c8e6d 100644 --- a/src/app/data/commands/windows.ts +++ b/src/app/data/commands/windows.ts @@ -1,10 +1,14 @@ import type { TerminalState, TerminalEnv, CommandOutput, OutputLine, DirectoryNode } from './types'; import { dotnetDateFormat, psDefaultDate, strftime } from './dateFormat'; +import { COPY_ITEM, MOVE_ITEM, REMOVE_ITEM, parsePsArgs, psList, psNotSimulated } from './psParams'; +import type { PsArgs } from './psParams'; export interface WindowsCmdDeps { cmdPwd: (state: TerminalState, env: TerminalEnv) => OutputLine[]; cmdCd: (state: TerminalState, args: string[], env?: TerminalEnv) => { lines: OutputLine[]; newCwd?: string[] }; cmdLs: (state: TerminalState, args: string[]) => OutputLine[]; + /** Get-ChildItem as PowerShell prints it (a table per folder). */ + childItems: (state: TerminalState, args: string[]) => OutputLine[]; cmdCat: (state: TerminalState, args: string[]) => OutputLine[]; cmdMkdir: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode }; cmdTouch: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode }; @@ -14,6 +18,8 @@ export interface WindowsCmdDeps { cmdEcho: (args: string[]) => OutputLine[]; cmdGrep: (state: TerminalState, args: string[]) => OutputLine[]; cmdEnv: (state: TerminalState) => OutputLine[]; + /** How many items a folder holds, or undefined when the path is not a folder. */ + childCount: (state: TerminalState, path: string) => number | undefined; /** 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. */ @@ -98,8 +104,8 @@ function getHistory(state: TerminalState): OutputLine[] { ].map((text) => ({ text, type: 'output' as const })); } -/** `-Recurse`, or any prefix PowerShell accepts for it (`-r`, `-rec`…). */ -const RECURSE_PARAM = /^-r(e(c(u(r(se?)?)?)?)?)?$/i; +/** New-Item's `-Force`, or a prefix of it (`-f` is enough: no other New-Item parameter starts with f). */ +const FORCE_PARAM = /^-f(o(r(ce?)?)?)?$/i; const EXECUTION_POLICIES = ['Restricted', 'AllSigned', 'RemoteSigned', 'Unrestricted', 'Bypass', 'Undefined', 'Default']; @@ -115,17 +121,32 @@ function psPath(args: string[]): string | undefined { return i >= 0 ? args[i + 1] : args.find((a) => !a.startsWith('-')); } -/** `Copy-Item a b`, or `-Path a -Destination b` in any order → `[a, b]`. */ -function psSourceAndDestination(args: string[]): string[] { - const NAMED = ['-path', '-literalpath', '-destination']; - const valueOf = (names: string[]) => { - const i = args.findIndex((a) => names.includes(a.toLowerCase())); - return i >= 0 ? args[i + 1] : undefined; - }; - const positional = args.filter((a, i) => !a.startsWith('-') && !(i > 0 && NAMED.includes(args[i - 1].toLowerCase()))); - const source = valueOf(['-path', '-literalpath']) ?? positional.shift(); - const destination = valueOf(['-destination']) ?? positional.shift(); - return [source, destination].filter((a): a is string => a !== undefined); +/** `Copy-Item a b`, `-Path a -Destination b` in any order, or `a.txt, b.txt docs` → the sources, then the destination. */ +function psSourceAndDestination({ values }: PsArgs): string[] { + return [...psList(values.Path ?? values.LiteralPath), ...(values.Destination !== undefined ? [values.Destination] : [])]; +} + +/** + * The shared file commands answer like GNU tools; under PowerShell the same + * failure reads differently. These are PowerShell 7.6's messages (en-US, + * checked on 1 October 2026), with the full Windows path it shows. + */ +function psErrors(cmdlet: string, lines: OutputLine[], state: TerminalState, env: TerminalEnv, deps: WindowsCmdDeps): OutputLine[] { + if (env !== 'windows') return lines; + const win = (p: string) => deps.winPath(state, p); + const err = (text: string): OutputLine => ({ text, type: 'error' }); + 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(/^(?: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.`); + } + if ((m = l.text.match(/^touch: cannot touch '(.+)': No such file or directory$/))) return err(`New-Item: Could not find a part of the path '${win(m[1])}'.`); + if (/^mv: cannot move '.+' to '.+': No such file or directory$/.test(l.text)) return err('Move-Item: Could not find a part of the path.'); + if ((m = l.text.match(/^cd: (.+): No such file or directory$/))) return err(`Set-Location: Cannot find path '${win(m[1])}' because it does not exist.`); + return l; + }); } /** @@ -150,7 +171,7 @@ export function handleWindows( case 'sl': { const { lines, newCwd } = deps.cmdCd(newState, args, env); if (newCwd) newState = { ...newState, cwd: newCwd, previousCwd: newState.cwd }; - return { lines, newState }; + return { lines: psErrors('Set-Location', lines, newState, env, deps), newState }; } // ── ls equivalents ──────────────────────────────────────────────────────── @@ -160,7 +181,7 @@ export function handleWindows( if (args[0]?.toLowerCase() === 'env:') { return { lines: deps.cmdEnv(newState), newState }; } - return { lines: deps.cmdLs(newState, args), newState }; + return { lines: env === 'windows' ? deps.childItems(newState, args) : deps.cmdLs(newState, args), newState }; // ── Get-Item: the item itself (not its content), or a "cannot find path" error ── case 'get-item': @@ -275,38 +296,77 @@ export function handleWindows( const leaf = valueOf(['-name']); const name = path && leaf ? `${path}/${leaf}` : leaf ?? path; if (!name) return { lines: [{ text: 'New-Item: -Name ou chemin requis', type: 'error' }], newState }; + // -Force accepts a folder that is already there and replaces a file with an empty one. + const force = args.some((a) => FORCE_PARAM.test(a)); if (isDir) { - const { lines, newRoot } = deps.cmdMkdir(newState, [name]); + const { lines, newRoot } = deps.cmdMkdir(newState, force ? ['-p', name] : [name]); if (newRoot) newState = { ...newState, root: newRoot }; - return { lines, newState }; - } else { - const { lines, newRoot } = deps.cmdTouch(newState, [name]); - if (newRoot) newState = { ...newState, root: newRoot }; - return { lines, newState }; + return { lines: psErrors('New-Item', lines, newState, env, deps), newState }; + } + if (env === 'windows' && force && 'content' in deps.readFile(newState, name)) { + const { root: newRoot, errors } = deps.writeFile(newState, name, '', false, 'New-Item'); + return { lines: errors, newState: { ...newState, root: newRoot } }; } + // Unlike touch, New-Item refuses a file that is already there. + if (env === 'windows' && 'content' in deps.readFile(newState, name)) { + return { lines: [{ text: `New-Item: The file '${deps.winPath(newState, name)}' already exists.`, type: 'error' }], newState }; + } + const { lines, newRoot } = deps.cmdTouch(newState, [name]); + if (newRoot) newState = { ...newState, root: newRoot }; + return { lines: psErrors('New-Item', lines, newState, env, deps), newState }; } // ── cp equivalents ──────────────────────────────────────────────────────── case 'copy-item': case 'cpi': case 'copy': { - const cpArgs = psSourceAndDestination(args); - // -Recurse copies a folder with its content, like `cp -r`. - const recurse = args.some((a) => RECURSE_PARAM.test(a)); - const { lines, newRoot } = deps.cmdCp(newState, [...(recurse ? ['-r'] : []), '--', ...cpArgs]); + const parsed = parsePsArgs(COPY_ITEM, args); + if (!('switches' in parsed)) return { lines: [parsed], newState }; + const skipped = psNotSimulated(COPY_ITEM, parsed, ['Container', 'Filter', 'Include', 'Exclude', 'PassThru', 'Credential', 'WhatIf', 'Confirm', 'FromSession', 'ToSession']); + if (skipped) return { lines: skipped, newState }; + const cpArgs = psSourceAndDestination(parsed); + // -Recurse copies a folder with its content, like `cp -r`. Without it, + // Copy-Item still copies the folder, empty. + const [source, destination] = cpArgs; + if (!parsed.switches.has('Recurse') && cpArgs.length === 2 && source !== undefined && destination !== undefined && deps.childCount(newState, source) !== undefined) { + const into = deps.childCount(newState, destination) !== undefined; + const target = into ? `${destination}/${source.replace(/[\\/]+$/, '').split(/[\\/]/).pop()}` : destination; + const { lines, newRoot } = deps.cmdMkdir(newState, ['-p', target]); + if (newRoot) newState = { ...newState, root: newRoot }; + return { lines: psErrors('Copy-Item', lines, newState, env, deps), newState }; + } + const { lines, newRoot } = deps.cmdCp(newState, [...(parsed.switches.has('Recurse') ? ['-r'] : []), '--', ...cpArgs]); if (newRoot) newState = { ...newState, root: newRoot }; - return { lines, newState }; + return { lines: psErrors('Copy-Item', lines, newState, env, deps), newState }; } // ── mv equivalents ──────────────────────────────────────────────────────── case 'move-item': case 'mi': case 'move': { - const mvArgs = psSourceAndDestination(args); + const parsed = parsePsArgs(MOVE_ITEM, args); + if (!('switches' in parsed)) return { lines: [parsed], newState }; + const skipped = psNotSimulated(MOVE_ITEM, parsed, ['Filter', 'Include', 'Exclude', 'PassThru', 'Credential', 'WhatIf', 'Confirm']); + if (skipped) return { lines: skipped, newState }; + const mvArgs = psSourceAndDestination(parsed); + const [source, destination] = mvArgs; + if (env === 'windows' && mvArgs.length === 2 && source !== undefined && destination !== undefined) { + const read = (p: string) => deps.readFile(newState, p); + const isFile = (p: string) => 'content' in read(p); + const is = (p: string, error: 'missing' | 'directory') => { const r = read(p); return 'error' in r && r.error === error; }; + // Move-Item does not overwrite a file without -Force. + if (isFile(source) && isFile(destination) && !parsed.switches.has('Force')) { + return { lines: [{ text: 'Move-Item: Cannot create a file when that file already exists.', type: 'error' }], newState }; + } + // A missing `dir/` in an existing folder is no folder to move into: PowerShell renames the file to `dir`. + const bare = destination.replace(/[\\/]+$/, ''); + const cut = Math.max(bare.lastIndexOf('/'), bare.lastIndexOf('\\')); + if (bare !== destination && is(bare, 'missing') && is(cut >= 0 ? bare.slice(0, cut) || '/' : '.', 'directory')) mvArgs[1] = bare; + } const { lines, newRoot, newCwd } = deps.cmdMv(newState, ['--', ...mvArgs]); if (newRoot) newState = { ...newState, root: newRoot }; if (newCwd) newState = { ...newState, cwd: newCwd }; - return { lines, newState }; + return { lines: psErrors('Move-Item', lines, newState, env, deps), newState }; } // ── rm equivalents ──────────────────────────────────────────────────────── @@ -314,12 +374,21 @@ export function handleWindows( case 'ri': case 'del': case 'erase': { - const rmArgs = args.filter((a) => !a.startsWith('-')); - // -Recurse removes a folder with its content, like `rm -r`. - const recurse = args.some((a) => RECURSE_PARAM.test(a)); - const { lines, newRoot } = deps.cmdRm(newState, recurse ? ['-r', ...rmArgs] : rmArgs); + const parsed = parsePsArgs(REMOVE_ITEM, args); + if (!('switches' in parsed)) return { lines: [parsed], newState }; + const skipped = psNotSimulated(REMOVE_ITEM, parsed, ['Filter', 'Include', 'Exclude', 'Credential', 'WhatIf', 'Confirm', 'Stream']); + if (skipped) return { lines: skipped, newState }; + const rmArgs = psList(parsed.values.Path ?? parsed.values.LiteralPath); + // -Recurse removes a folder with its content, like `rm -r`. An empty + // folder goes without it; for one with content, PowerShell asks first. + const recurse = parsed.switches.has('Recurse'); + if (!recurse && rmArgs.some((a) => (deps.childCount(newState, a) ?? 0) > 0)) { + return { lines: [{ text: "PowerShell demande une confirmation avant de supprimer un dossier qui n'est pas vide. Ce terminal d'entraînement ne pose pas la question : ajoute -Recurse pour supprimer le dossier et tout son contenu.", type: 'info' }], newState }; + } + const empty = rmArgs.some((a) => deps.childCount(newState, a) === 0); + const { lines, newRoot } = deps.cmdRm(newState, recurse || empty ? ['-r', ...rmArgs] : rmArgs); if (newRoot) newState = { ...newState, root: newRoot }; - return { lines, newState }; + return { lines: psErrors('Remove-Item', lines, newState, env, deps), newState }; } // ── echo equivalents ────────────────────────────────────────────────────── @@ -391,9 +460,10 @@ export function handleWindows( // ── mkdir alias ─────────────────────────────────────────────────────────── case 'md': { - const { lines, newRoot } = deps.cmdMkdir(newState, args); + // mkdir is New-Item in a function: -Force accepts a folder that is already there. + const { lines, newRoot } = deps.cmdMkdir(newState, args.map((a) => (FORCE_PARAM.test(a) ? '-p' : a))); if (newRoot) newState = { ...newState, root: newRoot }; - return { lines, newState }; + return { lines: psErrors('New-Item', lines, newState, env, deps), newState }; } // ── script execution policy (PowerShell only) ───────────────────────────── diff --git a/src/app/data/curriculum.ts b/src/app/data/curriculum.ts index 44844f6..4d6ff5c 100644 --- a/src/app/data/curriculum.ts +++ b/src/app/data/curriculum.ts @@ -250,7 +250,11 @@ export const curriculum: Module[] = [ { type: 'code', content: '$ ls\ndocuments downloads projets', + contentByEnv: { + windows: 'PS> Get-ChildItem\n\n Directory: C:\\Users\\user\n\nMode LastWriteTime Length Name\n---- ------------- ------ ----\nd---- 3/30/2026 10:00 AM documents\nd---- 3/30/2026 10:00 AM downloads\nd---- 3/30/2026 10:00 AM projets\n-a--- 3/30/2026 10:00 AM 107 .bashrc\n-a--- 3/30/2026 10:00 AM 86 .profile\n-a--- 3/30/2026 10:00 AM 177 .zshrc', + }, label: 'ls simple', + labelByEnv: { windows: 'Get-ChildItem (alias : ls, dir)' }, }, { type: 'text', @@ -259,12 +263,19 @@ export const curriculum: Module[] = [ { type: 'code', content: '$ ls documents\nnotes.txt rapport.md', + contentByEnv: { + windows: 'PS> Get-ChildItem documents\n\n Directory: C:\\Users\\user\\documents\n\nMode LastWriteTime Length Name\n---- ------------- ------ ----\n-a--- 3/30/2026 10:00 AM 142 notes.txt\n-a--- 3/30/2026 10:00 AM 179 rapport.md', + }, label: 'ls avec chemin', + labelByEnv: { windows: 'Get-ChildItem avec chemin' }, }, { type: 'tip', content: 'Sur la plupart des systèmes, `ls` colore les répertoires en bleu et les fichiers exécutables en vert pour les distinguer visuellement.', + contentByEnv: { + windows: 'PowerShell affiche un tableau : le mode (`d` pour un dossier, `a` pour un fichier), la date de modification, la taille en octets (`Length`) et le nom. Les dossiers viennent d\'abord. `ls` et `dir` sont des alias de `Get-ChildItem` : ils affichent le même tableau.', + }, }, ], exercise: { @@ -289,31 +300,51 @@ export const curriculum: Module[] = [ type: 'text', content: '`ls` accepte des options (ou "flags") qui modifient son comportement. Les deux plus importantes sont `-l` (format long) et `-a` (afficher les cachés).', + contentByEnv: { + windows: '`Get-ChildItem` accepte des paramètres qui modifient son comportement. Il montre déjà les détails (un tableau) ; le paramètre important ici est `-Force`, qui affiche aussi les éléments cachés.', + }, }, { type: 'code', content: '$ ls -l\ntotal 3\ndrwxr-xr-x 2 user user 4096 Mar 30 10:00 documents\ndrwxr-xr-x 2 user user 4096 Mar 30 10:00 downloads\ndrwxr-xr-x 2 user user 4096 Mar 30 10:00 projets', + contentByEnv: { + windows: 'PS> Get-ChildItem\n\n Directory: C:\\Users\\user\n\nMode LastWriteTime Length Name\n---- ------------- ------ ----\nd---- 3/30/2026 10:00 AM documents\nd---- 3/30/2026 10:00 AM downloads\nd---- 3/30/2026 10:00 AM projets\n-a--- 3/30/2026 10:00 AM 107 .bashrc\n-a--- 3/30/2026 10:00 AM 86 .profile\n-a--- 3/30/2026 10:00 AM 177 .zshrc', + }, label: 'ls -l (format long)', + labelByEnv: { windows: 'Get-ChildItem (déjà détaillé)' }, }, { type: 'info', content: 'Le format long montre : les permissions, le nombre de liens, le propriétaire, le groupe, la taille, la date de modification et le nom.', + contentByEnv: { + windows: 'Le tableau montre : le mode (`d` dossier, `a` fichier, `h` caché), la date de modification, la taille en octets (`Length`) et le nom.', + }, }, { type: 'code', content: '$ ls -a\n. .. .bashrc .profile .zshrc documents downloads projets', + contentByEnv: { + windows: 'PS> cd projets\nPS> git init\nInitialized empty Git repository in C:/Users/user/projets/.git/\n\n# Sans -Force, le dossier .git n\'apparaît pas\nPS> Get-ChildItem -Name\n.env\nREADME.md\nscript.sh\n\n# Avec -Force, il apparaît : h (caché) dans la colonne Mode\nPS> Get-ChildItem -Force\n\n Directory: C:\\Users\\user\\projets\n\nMode LastWriteTime Length Name\n---- ------------- ------ ----\nd--h- 3/30/2026 10:00 AM .git\n-a--- 3/30/2026 10:00 AM 223 .env\n-a--- 3/30/2026 10:00 AM 116 README.md\n-a--- 3/30/2026 10:00 AM 67 script.sh', + }, label: 'ls -a (fichiers cachés)', + labelByEnv: { windows: 'Get-ChildItem -Force (éléments cachés)' }, }, { type: 'text', content: 'Les fichiers commençant par un `.` sont cachés. Vous pouvez combiner les flags : `ls -la` ou `ls -al` — les deux sont équivalents.', + contentByEnv: { + windows: 'Sous Windows, un nom qui commence par un `.` n\'est pas caché : `.bashrc` ou `.env` apparaissent sans `-Force`. Est caché ce qui porte l\'attribut « caché » (le `h` de la colonne Mode), comme le dossier `.git` que Git crée.', + }, }, { type: 'warning', content: 'Les répertoires `.` (répertoire courant) et `..` (répertoire parent) apparaissent toujours avec `ls -a`. C\'est normal !', + contentByEnv: { + windows: '`ls -la` n\'existe pas en PowerShell : `ls` y est un alias de `Get-ChildItem`, qui répond « A parameter cannot be found that matches parameter name \'la\' ». Tapez `Get-ChildItem -Force`.', + }, }, ], exercise: { diff --git a/src/app/data/terminalEngine.ts b/src/app/data/terminalEngine.ts index 0024576..90c8f18 100644 --- a/src/app/data/terminalEngine.ts +++ b/src/app/data/terminalEngine.ts @@ -9,6 +9,7 @@ import { fingerprintLine, keygenOptions, keygenType, sshKeygen } from './command import { handleAiHelp } from './commands/ai'; import { cmdEnv, handleEnv } from './commands/env'; import { handleWindows } from './commands/windows'; +import { GET_CHILD_ITEM, parsePsArgs, psNotSimulated } from './commands/psParams'; import { parseCommandLine, isPlainCommand, isNullDevice } from './commands/shellSyntax'; import type { Stage, Fd } from './commands/shellSyntax'; import { UNIX_DEFAULT_PATH, varsForEnv } from './commands/shellVars'; @@ -436,6 +437,106 @@ function cmdLs(state: TerminalState, args: string[]): OutputLine[] { return lines; } +/** `*.md`, `?ote*` against a name, case-insensitive, as PowerShell wildcards. */ +const psWildcard = (pattern: string) => + new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`, 'i'); + +/** One row of Get-ChildItem's table; every date is the one `ls -l` shows (30 March, 10:00). */ +function childItemRow(name: string, node: FSNode, hidden: boolean): string { + const mode = node.type === 'directory' ? `d--${hidden ? 'h' : '-'}-` : `-a-${hidden ? 'h' : '-'}-`; + const length = node.type === 'file' ? String(new TextEncoder().encode(node.content).length) : ''; + return `${mode.padEnd(16)}${'3/30/2026 10:00 AM'.padStart(18)}${length.padStart(15)} ${name}`; +} + +/** + * Get-ChildItem (and its aliases `ls`, `dir`, `gci` under PowerShell), as + * PowerShell 7.6 prints it (checked on 1 October 2026, en-US): a table per + * directory under `Directory: C:\…`, folders first, then files, each sorted + * by name. Only `.git` is hidden (git marks it so on Windows); a name that + * starts with a dot is an ordinary file. Into a cmdlet that does not format + * (see stdoutIsFormatted), one name per line, so `Get-ChildItem | + * Measure-Object` counts the items as PowerShell does. + */ +function cmdChildItem(state: TerminalState, args: string[]): OutputLine[] { + const fail = (text: string): OutputLine[] => [{ text: `Get-ChildItem: ${text}`, type: 'error' }]; + const parsed = parsePsArgs(GET_CHILD_ITEM, args); + if (!('switches' in parsed)) return [parsed]; + const { switches: set, values } = parsed; + if (values.Depth !== undefined && !/^\d+$/.test(values.Depth)) { + return fail(`Cannot bind parameter 'Depth'. Cannot convert value "${values.Depth}" to type "System.UInt32". Error: "The input string '${values.Depth}' was not in a correct format."`); + } + const skipped = psNotSimulated(GET_CHILD_ITEM, parsed, ['Include', 'Exclude', 'Attributes', 'ReadOnly', 'System', 'FollowSymlink']); + if (skipped) return skipped; + + let typedPath: string | undefined = values.Path ?? values.LiteralPath; + let filter = values.Filter; + // A wildcard in the last segment filters that folder: `Get-ChildItem *.md`. + if (typedPath !== undefined && values.LiteralPath === undefined && /[*?]/.test(typedPath)) { + const cut = Math.max(typedPath.lastIndexOf('/'), typedPath.lastIndexOf('\\')); + filter = typedPath.slice(cut + 1); + typedPath = cut >= 0 ? typedPath.slice(0, cut) || '/' : undefined; + } + const basePath = typedPath !== undefined ? resolvePath(state, typedPath) : state.cwd; + const base = getNode(state.root, basePath); + if (!base) return fail(`Cannot find path '${displayPathForEnv(basePath, 'windows')}' because it does not exist.`); + + const recurse = set.has('Recurse') || values.Depth !== undefined; + const depth = values.Depth !== undefined ? Number(values.Depth) : Infinity; + const matchesFilter = filter ? psWildcard(filter) : null; + const shown = (name: string, node: FSNode, hidden: boolean) => + (set.has('Hidden') ? hidden : set.has('Force') || !hidden) + && !(set.has('Directory') && node.type !== 'directory') + && !(set.has('File') && node.type !== 'file') + && (!matchesFilter || matchesFilter.test(name)); + + // Each folder listed: its items, then each sub-folder in turn. + type Item = { key: string; name: string; node: FSNode; hidden: boolean }; + const byName = (a: string, b: string) => { + const x = a.toLowerCase(); + const y = b.toLowerCase(); + return x < y ? -1 : x > y ? 1 : 0; + }; + const groups: { path: string[]; items: Item[] }[] = []; + const walk = (path: string[], dir: DirectoryNode, level: number) => { + const children = Object.entries(dir.children) + .map(([key, node]): Item => ({ key, name: windowsName(path, key, true), node, hidden: key === '.git' })) + .sort((a, b) => (a.node.type === b.node.type ? byName(a.name, b.name) : a.node.type === 'directory' ? -1 : 1)); + groups.push({ path, items: children.filter((c) => shown(c.name, c.node, c.hidden)) }); + if (!recurse || level >= depth) return; + for (const c of children) { + if (c.node.type === 'directory' && (!c.hidden || set.has('Force') || set.has('Hidden'))) walk([...path, c.key], c.node, level + 1); + } + }; + if (base.type === 'file') { + const key = basePath[basePath.length - 1]; + groups.push({ path: basePath.slice(0, -1), items: [{ key, name: key, node: base, hidden: false }] }); + } else { + walk(basePath, base, 0); + } + + const relative = (path: string[], name: string) => [...path.slice(basePath.length), name].join('\\'); + if (set.has('Name') || !stdoutIsFormatted) { + return groups.flatMap((g) => g.items.map(({ name }) => ({ + text: set.has('Name') && base.type === 'directory' ? relative(g.path, name) : name, + type: 'output' as const, + }))); + } + const lines: OutputLine[] = []; + for (const g of groups) { + if (!g.items.length) continue; + lines.push( + { text: '', type: 'output' }, + { text: ` Directory: ${displayPathForEnv(g.path, 'windows')}`, type: 'output' }, + { text: '', type: 'output' }, + { text: 'Mode LastWriteTime Length Name', type: 'output' }, + { text: '---- ------------- ------ ----', type: 'output' }, + ...g.items.map(({ name, node, hidden }) => ({ text: childItemRow(name, node, hidden), type: 'output' as const })), + ); + } + if (lines.length) lines.push({ text: '', type: 'output' }); + return lines; +} + function cmdCd(state: TerminalState, args: string[], env: TerminalEnv = 'linux'): { lines: OutputLine[]; newCwd?: string[] } { const target = args[0]; if (target === '-') { @@ -1191,6 +1292,17 @@ function runScript(state: TerminalState, call: ScriptCall, env: TerminalEnv): Co */ let stdoutIsTerminal = true; +/** + * Whether PowerShell would format what the command running now outputs: on + * the screen, into a file (`> liste.txt`), or into a cmdlet that formats + * (`| Tee-Object`, `| Out-File`). Into any other cmdlet it passes objects, + * which this simulator stands in for with one name per line. + */ +let stdoutIsFormatted = true; + +/** Cmdlets that receive objects and write them as PowerShell shows them. */ +const FORMATTING_CMDLETS = new Set(['tee-object', 'tee', 'out-file', 'out-string', 'out-host', 'oh', 'format-table', 'ft']); + /** Where a stream goes: the screen, the next command, a file, or nowhere. */ type Sink = { kind: 'screen' } | { kind: 'pipe' } | { kind: 'null' } | { kind: 'file'; key: string }; @@ -1441,7 +1553,8 @@ function runFilter(state: TerminalState, text: string, stdin: string, env: Termi if (lower.includes('-character')) out.push(`Characters : ${stdin.length}`); return same(outLines(out)); } - return same(outLines([`Count : ${items.length}`, 'Average :', 'Sum :', 'Maximum :', 'Minimum :', 'Property :'])); + // PowerShell 7.6's list (1 October 2026): a blank line, then every property, empty ones included. + return same(outLines(['', `Count : ${items.length}`, ...['Average', 'Sum', 'Maximum', 'Minimum', 'StandardDeviation', 'Property'].map((p) => `${p.padEnd(17)} : `), ''])); } case 'tee': @@ -1562,12 +1675,16 @@ function runPipeline(state: TerminalState, stages: Stage[], env: TerminalEnv): P } const outerTerminal = stdoutIsTerminal; + const outerFormatted = stdoutIsFormatted; + const nextCmd = isLast ? '' : (stages[i + 1].text.trim().split(/\s+/)[0] ?? '').toLowerCase(); stdoutIsTerminal = outerTerminal && fds[1].kind === 'screen'; + stdoutIsFormatted = outerFormatted && (fds[1].kind !== 'pipe' || FORMATTING_CMDLETS.has(nextCmd)); let result: CommandOutput; try { result = stdin !== undefined ? runFilter(base, stage.text, stdin, env) : runSimple(base, stage.text, env); } finally { stdoutIsTerminal = outerTerminal; + stdoutIsFormatted = outerFormatted; } if (result.clear) { screen = []; clear = true; } ok = result.status !== undefined ? result.status === 0 : !result.lines.some((l) => l.type === 'error'); @@ -2139,6 +2256,13 @@ function splitSingleQuoted(line: string): string[] { /** PowerShell aliases whose cmdlet behaves differently from the Unix command of the same name. */ const PS_ALIASES: Record = { + ls: 'get-childitem', + cp: 'copy-item', + mv: 'move-item', + rm: 'remove-item', + cd: 'set-location', + chdir: 'set-location', + mkdir: 'md', cat: 'get-content', type: 'get-content', history: 'get-history', @@ -2225,8 +2349,12 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com // Dependencies for Windows/macOS alias handler const winDeps: WindowsCmdDeps = { - cmdPwd, cmdCd, cmdLs, cmdCat, cmdMkdir, cmdTouch, + cmdPwd, cmdCd, cmdLs, childItems: cmdChildItem, cmdCat, cmdMkdir, cmdTouch, cmdCp, cmdMv, cmdRm, cmdEcho, cmdGrep, cmdEnv, + childCount: (s, p) => { + const node = getNode(s.root, resolvePath(s, p)); + return node?.type === 'directory' ? Object.keys(node.children).length : undefined; + }, readFile: (s, p) => { const node = getNode(s.root, resolvePath(s, p)); if (!node) return { error: 'missing' }; diff --git a/src/test/lessonTheoryGaps.ts b/src/test/lessonTheoryGaps.ts index 156bfa5..dcf72bf 100644 --- a/src/test/lessonTheoryGaps.ts +++ b/src/test/lessonTheoryGaps.ts @@ -129,4 +129,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 = 34; +export const BASH_SHOWN_ON_WINDOWS_MAX = 32; diff --git a/src/test/shellLayer.test.ts b/src/test/shellLayer.test.ts index aa8fd41..6fd9c1a 100644 --- a/src/test/shellLayer.test.ts +++ b/src/test/shellLayer.test.ts @@ -21,8 +21,13 @@ function cat(state: TerminalState, file: string, env: TerminalEnv = 'linux'): st const text = (lines: OutputLine[]) => lines.map((l) => l.text).join('\n'); const errors = (lines: OutputLine[]) => lines.filter((l) => l.type === 'error'); -/** What a real `ls` writes into a pipe or a file: one plain name per line. */ -const entries = (env: TerminalEnv) => text(run(env, env === 'windows' ? 'Get-ChildItem' : 'ls').last) +/** + * The items of the folder, one per name: what a real `ls` writes into a pipe or + * a file, and what `Get-ChildItem -Name` prints. Into a pipe, the simulator's + * Get-ChildItem passes names (PowerShell passes objects, and Tee-Object or + * Out-File would write the table). + */ +const entries = (env: TerminalEnv) => text(run(env, env === 'windows' ? 'Get-ChildItem -Name' : 'ls').last) .split(/\s+/).filter(Boolean).map((n) => n.replace(/\/$/, '')); describe('shell syntax — parseCommandLine', () => { @@ -308,16 +313,29 @@ describe('PowerShell — redirections and pipeline cmdlets', () => { expect(cat(state, 'bonjour.txt', 'windows')).toBe('Bonjour le monde!'); }); - it('Tee-Object -FilePath shows and writes', () => { - const listing = entries('windows').join('\n'); - const { state, last } = run('windows', 'Get-ChildItem | Tee-Object -FilePath ma-liste.txt'); - expect(text(last)).toBe(listing); - expect(cat(state, 'ma-liste.txt', 'windows')).toBe(listing); + // Tee-Object and Out-File format the objects they receive, as the screen does (PowerShell 7.6, 1 October 2026). + it('Tee-Object -FilePath shows the table and writes it', () => { + const table = [ + '', + ' Directory: C:\\Users\\user\\documents', + '', + 'Mode LastWriteTime Length Name', + '---- ------------- ------ ----', + '-a--- 3/30/2026 10:00 AM 142 notes.txt', + '-a--- 3/30/2026 10:00 AM 179 rapport.md', + ].join('\n'); + const { state, last } = run('windows', 'Get-ChildItem documents | Tee-Object -FilePath ma-liste.txt'); + expect(text(last)).toContain(table); + expect(cat(state, 'ma-liste.txt', 'windows')).toContain(table); + const redirected = run('windows', 'Get-ChildItem documents > liste.txt'); + expect(cat(redirected.state, 'liste.txt', 'windows')).toContain(table); + const outFile = run('windows', 'Get-ChildItem documents | Out-File liste.txt'); + expect(cat(outFile.state, 'liste.txt', 'windows')).toContain(table); }); it('Measure-Object counts the items', () => { const n = entries('windows').length; - expect(text(run('windows', 'Get-ChildItem | Measure-Object').last)).toContain(`Count : ${n}`); + expect(text(run('windows', 'Get-ChildItem | Measure-Object').last)).toContain(`Count : ${n}`); }); it('Select-Object -First keeps the first lines', () => { diff --git a/src/test/terminalEngine.test.ts b/src/test/terminalEngine.test.ts index 2af1cd4..917624f 100644 --- a/src/test/terminalEngine.test.ts +++ b/src/test/terminalEngine.test.ts @@ -287,6 +287,182 @@ describe('PowerShell aliases — navigation', () => { }); }); +/** + * Get-ChildItem as PowerShell 7.6 prints it (en-US, 1 October 2026), checked + * on a folder laid out like the simulated home: a table per folder, folders + * first, `.git` hidden (git marks it so), names starting with a dot visible. + * Lengths depend on file contents, so file rows are checked by layout. + */ +describe('Get-ChildItem — the table PowerShell prints', () => { + const run = (...cmds: string[]) => { + let state = createInitialState(); + let lines: string[] = []; + for (const c of cmds) { + const r = processCommand(state, c, 'windows'); + state = r.newState; + lines = r.lines.map((l) => l.text); + } + return lines; + }; + const HEADER = ['Mode LastWriteTime Length Name', '---- ------------- ------ ----']; + const dirRow = (name: string, mode = 'd----') => `${mode} 3/30/2026 10:00 AM ${name}`; + const fileRow = (name: string) => new RegExp(`^-a--- {11}3/30/2026 10:00 AM +\\d+ ${name.replace('.', '\\.')}$`); + + it('lists a folder under its Directory heading, folders first, dot files visible', () => { + const out = run('Get-ChildItem'); + expect(out.slice(0, 8)).toEqual(['', ' Directory: C:\\Users\\user', '', ...HEADER, dirRow('documents'), dirRow('downloads'), dirRow('projets')]); + ['.bashrc', '.profile', '.zshrc'].forEach((name, i) => expect(out[8 + i]).toMatch(fileRow(name))); + expect(out.slice(-1)).toEqual(['']); + // The name column starts where the header's Name does. + expect(out[8].indexOf('.bashrc')).toBe(HEADER[0].indexOf('Name')); + }); + + it('ls, dir and gci are the same cmdlet under PowerShell', () => { + expect(run('ls')).toEqual(run('Get-ChildItem')); + expect(run('gci projets')).toEqual(run('dir projets')); + }); + + it('hides only .git: -Force shows it, -Hidden shows only it', () => { + const repo = (cmd: string) => run('cd projets', 'git init', cmd); + expect(repo('Get-ChildItem').join('\n')).not.toContain('.git\n'); + expect(repo('Get-ChildItem -Force')[5]).toBe(dirRow('.git', 'd--h-')); + expect(repo('Get-ChildItem -Hidden').slice(5)).toEqual([dirRow('.git', 'd--h-'), '']); + }); + + it('-Name prints names; -Recurse goes into each folder after listing it', () => { + expect(run('Get-ChildItem -Name')).toEqual(['documents', 'downloads', 'projets', '.bashrc', '.profile', '.zshrc']); + expect(run('Get-ChildItem -Recurse -Name').slice(6)).toEqual(['documents\\notes.txt', 'documents\\rapport.md', 'projets\\.env', 'projets\\README.md', 'projets\\script.sh']); + const tables = run('Get-ChildItem -Recurse').filter((l) => l.startsWith(' Directory: ')); + // downloads is empty: no table for it. + expect(tables).toEqual([' Directory: C:\\Users\\user', ' Directory: C:\\Users\\user\\documents', ' Directory: C:\\Users\\user\\projets']); + }); + + it('-Directory, -File, a file path and a wildcard filter the rows', () => { + expect(run('Get-ChildItem -Directory').slice(5, -1)).toEqual([dirRow('documents'), dirRow('downloads'), dirRow('projets')]); + expect(run('Get-ChildItem -Name -File projets')).toEqual(['.env', 'README.md', 'script.sh']); + expect(run('Get-ChildItem documents/notes.txt')[1]).toBe(' Directory: C:\\Users\\user\\documents'); + expect(run('Get-ChildItem -Name documents\\*.md')).toEqual(['rapport.md']); + expect(run('Get-ChildItem downloads')).toEqual([]); + }); + + it('resolves shortened parameters as PowerShell does, and says why it cannot', () => { + expect(run('gci -fo -n projets')).toEqual(['.env', 'README.md', 'script.sh']); + expect(run('Get-ChildItem -la')).toEqual(["Get-ChildItem: A parameter cannot be found that matches parameter name 'la'."]); + expect(run('ls -a')).toEqual(["Get-ChildItem: Parameter cannot be processed because the parameter name 'a' is ambiguous. Possible matches include: -Attributes -Directory -File -Hidden -ReadOnly -System."]); + expect(run('ls -f')).toEqual(["Get-ChildItem: Parameter cannot be processed because the parameter name 'f' is ambiguous. Possible matches include: -Filter -Force."]); + expect(run('ls -p')).toEqual(["Get-ChildItem: Parameter cannot be processed because the parameter name 'p' is ambiguous. Possible matches include: -Path -ProgressAction -PipelineVariable -LiteralPath."]); + expect(run('ls -d x')).toEqual(['Get-ChildItem: Cannot bind parameter \'Depth\'. Cannot convert value "x" to type "System.UInt32". Error: "The input string \'x\' was not in a correct format."']); + expect(run('Get-ChildItem nope')).toEqual(["Get-ChildItem: Cannot find path 'C:\\Users\\user\\nope' because it does not exist."]); + }); + + it('into a pipe, one item per line: Measure-Object counts the items', () => { + expect(run('Get-ChildItem | Measure-Object')).toEqual([ + '', + 'Count : 6', + 'Average : ', + 'Sum : ', + 'Maximum : ', + 'Minimum : ', + 'StandardDeviation : ', + 'Property : ', + '', + ]); + }); + + it('a parameter without its value names the type PowerShell expects', () => { + const missing = (name: string, type: string) => [`Get-ChildItem: Missing an argument for parameter '${name}'. Specify a parameter of type '${type}' and try again.`]; + expect(run('ls -Depth')).toEqual(missing('Depth', 'System.UInt32')); + expect(run('ls -Path')).toEqual(missing('Path', 'System.String[]')); + expect(run('ls -Filter')).toEqual(missing('Filter', 'System.String')); + expect(run('ls -ErrorAction')).toEqual(missing('ErrorAction', 'System.Management.Automation.ActionPreference')); + expect(run('ls -OutBuffer')).toEqual(missing('OutBuffer', 'System.Int32')); + }); + + it('-Path:value binds like -Path value', () => { + expect(run('Get-ChildItem -Path:documents -Name')).toEqual(['notes.txt', 'rapport.md']); + }); + + it('rm, cp and mv are Remove-Item, Copy-Item and Move-Item, with their parameters and messages', () => { + expect(run('rm -rf documents')).toEqual(["Remove-Item: A parameter cannot be found that matches parameter name 'rf'."]); + expect(run('rm -f documents/notes.txt')).toEqual(["Remove-Item: Parameter cannot be processed because the parameter name 'f' is ambiguous. Possible matches include: -Filter -Force."]); + expect(run('cp -f a.txt c.txt')).toEqual(["Copy-Item: Parameter cannot be processed because the parameter name 'f' is ambiguous. Possible matches include: -Force -Filter."]); + expect(run('mv -f a.txt d.txt')).toEqual(["Move-Item: Parameter cannot be processed because the parameter name 'f' is ambiguous. Possible matches include: -Force -Filter."]); + expect(run('cp -p a.txt e.txt')).toEqual(["Copy-Item: Parameter cannot be processed because the parameter name 'p' is ambiguous. Possible matches include: -Path -PassThru -ProgressAction -PipelineVariable -LiteralPath."]); + expect(run('cp -a documents docs3')).toEqual(["Copy-Item: A parameter cannot be found that matches parameter name 'a'."]); + expect(run('mv -n a.txt z.txt')).toEqual(["Move-Item: A parameter cannot be found that matches parameter name 'n'."]); + expect(run('mv nope.txt documents')).toEqual(["Move-Item: Cannot find path 'C:\\Users\\user\\nope.txt' because it does not exist."]); + expect(run('cp nope.txt x.txt')).toEqual(["Copy-Item: Cannot find path 'C:\\Users\\user\\nope.txt' because it does not exist."]); + expect(run('rm nope.txt')).toEqual(["Remove-Item: Cannot find path 'C:\\Users\\user\\nope.txt' because it does not exist."]); + // One bare word per position: Path (and Destination); a list is written with commas. + const two = ['Set-Content a.txt a', 'Set-Content b.txt b']; + expect(run(...two, 'mv a.txt b.txt documents')).toEqual(["Move-Item: A positional parameter cannot be found that accepts argument 'documents'."]); + expect(run(...two, 'cp a.txt b.txt documents')).toEqual(["Copy-Item: A positional parameter cannot be found that accepts argument 'documents'."]); + expect(run(...two, 'rm a.txt b.txt')).toEqual(["Remove-Item: A positional parameter cannot be found that accepts argument 'b.txt'."]); + expect(run(...two, 'mv -Path a.txt b.txt documents')).toEqual(["Move-Item: A positional parameter cannot be found that accepts argument 'documents'."]); + expect(run(...two, 'rm a.txt, b.txt', 'Get-ChildItem -Name -File')).toEqual(['.bashrc', '.profile', '.zshrc']); + expect(run(...two, 'rm -- a.txt', 'Get-ChildItem -Name -File')).toEqual(['.bashrc', '.profile', '.zshrc', 'b.txt']); + expect(run('ls documents *.md extra')).toEqual(["Get-ChildItem: A positional parameter cannot be found that accepts argument 'extra'."]); + // -r is -Recurse for all three. + expect(run('cp -r documents docs2', 'Get-ChildItem -Name docs2')).toEqual(['notes.txt', 'rapport.md']); + expect(run('rm -r documents', 'Get-ChildItem -Name -Directory')).toEqual(['downloads', 'projets']); + }); + + it('Remove-Item deletes an empty folder without -Recurse, and does not pretend to ask for a full one', () => { + expect(run('mkdir vide', 'rm vide', 'Get-ChildItem -Name -Directory')).toEqual(['documents', 'downloads', 'projets']); + const full = processCommand(createInitialState(), 'Remove-Item documents', 'windows'); + expect(full.lines.map((l) => l.type)).toEqual(['info']); + expect(processCommand(full.newState, 'Get-ChildItem -Name documents', 'windows').lines.map((l) => l.text)).toEqual(['notes.txt', 'rapport.md']); + }); + + it('Copy-Item copies a folder without -Recurse, empty', () => { + expect(run('Copy-Item documents vide', 'Get-ChildItem -Name vide')).toEqual([]); + expect(run('Copy-Item documents vide', 'Get-ChildItem -Name -Directory')).toEqual(['documents', 'downloads', 'projets', 'vide']); + }); + + // New-Item also prints the item it made; that table is not simulated yet, so only the absence of an error is checked. + it('-Force: New-Item replaces a file with an empty one, and accepts a folder that exists', () => { + const errorsOf = (...cmds: string[]) => { + let state = createInitialState(); + let errors: string[] = []; + for (const c of cmds) { + const r = processCommand(state, c, 'windows'); + state = r.newState; + errors = r.lines.filter((l) => l.type === 'error').map((l) => l.text); + } + return errors; + }; + expect(errorsOf('Set-Content a.txt hello', 'New-Item -Force a.txt')).toEqual([]); + expect(run('Set-Content a.txt hello', 'New-Item -Force a.txt', 'Get-ChildItem a.txt').slice(-2)).toEqual(['-a--- 3/30/2026 10:00 AM 0 a.txt', '']); + expect(errorsOf('New-Item -ItemType Directory -Force projets')).toEqual([]); + expect(errorsOf('mkdir -Force projets')).toEqual([]); + }); + + it('file cmdlets fail with PowerShell 7.6 messages, not GNU ones (en-US, 1 October 2026)', () => { + const before = ['Set-Content ci.yml "name: CI"', 'Set-Content a.txt "a"', 'New-Item -ItemType Directory docs']; + expect(run(...before, 'New-Item -ItemType Directory docs')).toEqual(['New-Item: An item with the specified name C:\\Users\\user\\docs already exists.']); + expect(run(...before, 'mkdir docs')).toEqual(['New-Item: An item with the specified name C:\\Users\\user\\docs already exists.']); + expect(run(...before, 'New-Item a.txt')).toEqual(["New-Item: The file 'C:\\Users\\user\\a.txt' already exists."]); + expect(run(...before, 'New-Item -ItemType File nope/x.txt')).toEqual(["New-Item: Could not find a part of the path 'C:\\Users\\user\\nope\\x.txt'."]); + expect(run(...before, 'Move-Item ci.yml gh/workflows/')).toEqual(['Move-Item: Could not find a part of the path.']); + expect(run(...before, 'Move-Item nofile.yml docs')).toEqual(["Move-Item: Cannot find path 'C:\\Users\\user\\nofile.yml' because it does not exist."]); + expect(run(...before, 'Copy-Item nofile.txt docs')).toEqual(["Copy-Item: Cannot find path 'C:\\Users\\user\\nofile.txt' because it does not exist."]); + expect(run(...before, 'Remove-Item nofile.txt')).toEqual(["Remove-Item: Cannot find path 'C:\\Users\\user\\nofile.txt' because it does not exist."]); + expect(run('cd nope')).toEqual(["Set-Location: Cannot find path 'C:\\Users\\user\\nope' because it does not exist."]); + }); + + it('Move-Item refuses to overwrite a file without -Force, and renames to a missing dir/', () => { + const before = ['Set-Content ci.yml "name: CI"', 'Set-Content a.txt "a"', 'New-Item -ItemType Directory docs']; + expect(run(...before, 'Move-Item ci.yml a.txt')).toEqual(['Move-Item: Cannot create a file when that file already exists.']); + expect(run(...before, 'Move-Item ci.yml a.txt -Force', 'Get-Content a.txt')).toEqual(['name: CI']); + // docs exists, docs/sub does not: the file becomes docs\sub (PowerShell 7.6). + expect(run(...before, 'Move-Item ci.yml docs/sub/', 'Get-ChildItem -Name -File docs')).toEqual(['sub']); + }); + + it('ls on Linux and macOS is still GNU / BSD ls', () => { + expect(processCommand(createInitialState(), 'ls', 'linux').lines.map((l) => l.text)).toEqual(['documents downloads projets']); + }); +}); + describe('PowerShell aliases — file operations', () => { it('Get-Content reads a file', () => { const state = createInitialState(); @@ -1625,22 +1801,22 @@ describe('Windows paths in PowerShell', () => { }); it('accepts drive paths, C:\\Users is the parent of the home', () => { - expect(run('windows', 'ls C:\\Users\\user\\projets')).toEqual(['README.md script.sh']); + expect(run('windows', 'ls -Name C:\\Users\\user\\projets')).toEqual(['.env', 'README.md', 'script.sh']); expect(run('windows', 'cd C:\\Users\\user\\downloads', 'pwd')).toEqual(['C:\\Users\\user\\downloads']); expect(run('windows', 'cd ..', 'pwd')).toEqual(['C:\\Users']); expect(run('windows', 'cd \\', 'pwd')).toEqual(['C:\\']); }); it('mkdir creates missing parent folders, like New-Item -ItemType Directory', () => { - expect(run('windows', 'mkdir archives\\2025', 'ls archives')).toEqual(['2025']); + expect(run('windows', 'mkdir archives\\2025', 'ls -Name archives')).toEqual(['2025']); expect(run('linux', 'mkdir archives/2025')[0]).toContain('No such file or directory'); }); it('New-Item takes the path after -ItemType Directory, and creates its parents (PowerShell 7, 1 October 2026)', () => { - expect(run('windows', 'New-Item -ItemType Directory .github/workflows', 'ls .github')).toEqual(['workflows']); - expect(run('windows', 'New-Item -ItemType Directory .github\\workflows', 'ls .github')).toEqual(['workflows']); - expect(run('windows', 'New-Item -ItemType Directory .github\\workflows', 'ls').join(' ')).not.toMatch(/\bDirectory\b/); - expect(run('windows', 'New-Item -Path archives -Name 2025 -ItemType Directory', 'ls archives')).toEqual(['2025']); + expect(run('windows', 'New-Item -ItemType Directory .github/workflows', 'ls -Name .github')).toEqual(['workflows']); + expect(run('windows', 'New-Item -ItemType Directory .github\\workflows', 'ls -Name .github')).toEqual(['workflows']); + expect(run('windows', 'New-Item -ItemType Directory .github\\workflows', 'ls -Name')).not.toContain('Directory'); + expect(run('windows', 'New-Item -Path archives -Name 2025 -ItemType Directory', 'ls -Name archives')).toEqual(['2025']); }); it('Tab completes after a backslash on Windows, keeping the backslash', () => { @@ -1651,15 +1827,15 @@ describe('Windows paths in PowerShell', () => { }); it('C:\\ shows Users (never the internal home) for ls, cd and Tab', () => { - expect(run('windows', 'ls C:\\')).toEqual(['Users tmp']); + expect(run('windows', 'ls -Name C:\\')).toEqual(['tmp', 'Users']); expect(run('windows', 'cd \\', 'cd users', 'pwd')).toEqual(['C:\\Users']); expect(getTabCompletions('cd C:\\U', createInitialState(), 'windows')).toEqual(['cd C:\\Users\\']); expect(run('linux', 'ls /')).toEqual(['home tmp']); }); it('Copy-Item and Move-Item read -Path / -Destination in any order', () => { - expect(run('windows', 'Copy-Item -Destination downloads -Path documents/notes.txt', 'ls downloads')).toEqual(['notes.txt']); - expect(run('windows', 'Move-Item -Destination projets documents/rapport.md', 'ls projets')).toEqual(['README.md rapport.md script.sh']); + expect(run('windows', 'Copy-Item -Destination downloads -Path documents/notes.txt', 'ls -Name downloads')).toEqual(['notes.txt']); + expect(run('windows', 'Move-Item -Destination projets documents/rapport.md', 'ls -Name projets')).toEqual(['.env', 'rapport.md', 'README.md', 'script.sh']); }); it('bash does not treat a backslash as a separator', () => { @@ -1776,7 +1952,7 @@ describe('cp / mv — destination directory (GNU semantics)', () => { it('Remove-Item -Recurse removes a folder with its content', () => { const { out } = session('windows', 'mkdir archives', 'Remove-Item -Recurse archives'); - expect(out('ls')).toEqual(['documents downloads projets']); + expect(out('ls -Name -Directory')).toEqual(['documents', 'downloads', 'projets']); }); it('Move-Item and Copy-Item -Recurse follow the same rules on Windows', () => {